|
汽车零部件采购、销售通信录 填写你的培训需求,我们帮你找 招募汽车专业培训老师
前言
中间件相关技术在计算机分布式系统中发展了很多年,尤其在互联网服务、大型商业系统中得到广泛使用。随着智能网联汽车的发展,现代汽车也逐步增加了以太网支持,这让之前的很多分布式系统技术也可以运用到汽车软件中,比如SOA软件架构。所以,基于SOA的中间件也得到了越来越多的重视。
但是大家在讨论这些问题时,对很多概念表述其实很模糊。什么是中间件,不同语境下其含义差别很大。对于什么是SOA,自动驾驶系统需要SOA吗,很多人也很困惑。本文结合中间件的发展历史、软件架构方法论,自动驾驶的特殊要求,做了一个综合性分析,给出这些问题的一家之言。
第一章对典型的中间件产品做了一些介绍和综述,并阐明了中间件产品的核心概念,简述中间件技术在互联网和车载系统两个领域的应用。
第二章对中间件涉及到的关键技术逐一进行说明,作为后续分析的知识基础。
第三章对软件架构的分析方法和软件架构风格做了通用性的论述,并以此方法论逐层递进推导SOA软件架构。
第四章在前文的基础上,进一步分析自动驾驶对SOA中间件的要求。并以Adaptive AutoSAR和 GENIVI 技术体系为基础,举例说明如何对其进行改进与扩充,以实现满足自动驾驶要求的中间件系统。
本文的读者定位为从事车载软件开发、自动驾驶系统开发的系统工程师,产品经理、软件架构师、算法工程师、软件开发工程师及测试人员。因为智能驾驶需要很多不同专业的人协同工作,并不是所有人都是软件或汽车软件背景。有些论述对计算机软件专业的朋友可能是基本常识,但对其它专业的朋友而言并不熟悉,为了能让各种不同背景的人都能一定程度上理解文章内容,本文尽量采用非常通俗的语言来描述,并配合各种图来进行阐述。本文避免使用有歧义的术语,所有术语在第一次出现时都给出其在本文的准确定义。
内容庞杂,水平有限,如有错误欢迎留言指正。谢谢!
1.中间件基础概念
1.1 典型的中间件产品的介绍
1.1.1 CORBA 及其衍生物
中间件这个词已经被使用了很多年,其含义范畴也在不停的演变。最早可以追溯到1991 年CORBA 1.0 标准的诞生[1].CORBA 官方自述:"CORBA 是由对象管理组(OMG) 开发的标准,用于提供分布式对象之间的互操作性。CORBA 是世界领先的中间件解决方案,支持信息交换,独立于硬件平台、编程语言和操作系统。"[2].
不过 CORBA 标准过于庞大复杂,很多公司都参与制定标准,为各自的利益加入很多复杂而不实用的特性,同时很多公司又独立各自另搞一套。所以实际上CORBA 并没有大范围流行,尤其在互联网应用中实际很少有人采用。其开源版本omniCORBA [3]从1997年发布第一版到2020年仍在持续更新,其网站主页一如20年前一般简洁朴素。
几个参与CORBA标准开发人员后来也嫌 CORBA 过于复杂,然后成立了一个公司开发了一个轻量级的支持“分布式对象”的系统[4],Zeroc ICE . 它汲取了 CORBA 最核心的特性,做了更简洁高效的实现,并支持10多种语言的绑定。同时提供了一个中心化的 发布订阅服务(Ice Storm), 支持网格计算等等。经过20多年的发展,已经非常成熟可靠。在军工、通信、游戏等领域有很多使用者,但知名度始终不是非常高,或许跟创始人的经营理念有关系,并没有做较大的推广。
CORBA ,ZeroC Ice 的应用场景都是开发跨网络的分布式应用。其作为中间件的核心作用主要有:
使用接口定义语言(IDL)进行规范化的通讯协议描述,让应用开发者关注通讯内容的业务语意,不需要去定义协议报文的细节。以本地函数调用的方式对远程对象进行操作,中间件对应用层屏蔽具体通讯的细节。
它们都提供的接口定义语言(IDL)来描述通讯接口。提供工具根据 IDL 生成目标语言的代码骨架,与目标语言集成。这也是大多数通讯中间件的典型做法。
1.1.2 互联网时代的企业级中间件
互联网时代,很多人对中间件的认知是从 J2EE 体系的企业级中间件开始的。J2EE的核心理念是将企业应用的商务逻辑实现在一个个的 Enterprise Java Bean(EJB) 中。EJB可以类比与CORBA 中的远程对象,运行在由J2EE中间件平台提供容器中。容器由中间件供应商开发,提供了EJB运行所需要的环境。应用开发人员只需要开发与业务逻辑相关的 EJB组件。
图1. 1 J2EE中间件
J2EE 定义了一系列标准,涉及到接口定义、名字服务、远程调用、数据库访问、事务处理等等。重量级的商业实现有 WebLogic 和WebSphere, 轻量级开源的有 JBoss 和 Tomcat,这些都被称为J2EE中间件或J2EE应用服务器。然而在实际应用中,J2EE暴露出很多问题。一方面规范多而复杂,解决简单问题也需要先了解太多的技术内容,学习曲线陡峭;另一方面其运作体制是几个大厂商定期开会,定义、发布标准,各厂商实现应用服务器产品再发布更新,用户获取新版版本。这个周期对于快速迭代的互联网来说,实在太慢了。
J2EE和CORBA 有一个共同的问题,就是过于注重标准的完备性,而不关注开发的实用性。实际开发实践中,被广泛采用的是Spring Framework。它不算完整的J2EE实现,但是也使用了很多 J2EE 规范,却更注重实用性,快速迭代解决现实问题。
1.1.3 轻量级的 RPC 框架
相对于重量级作为应用服务器存在的中间件,轻量级的RPC(远程过程调用)框架被使用得更为广泛。这些RPC框架最基本的用途就是简化网络通讯程序的编写。
一般编写一个采用TCP或UDP的通讯程序,至少要自己定义通讯协议的报文格式;根据协议用代码进行报文内容的拼装与解析;如果直接使用原生的Socket API ,没有经验的话很容易掉入各种陷阱;还要考虑异步IO机制以得到更好的性能。轻量级的RPC框架的目的就是帮助开发人员把这些与业务逻辑无关的通讯底层工作都做了,通讯协议使用IDL定义,通讯相关代码都自动使用工具自动生成出来。开发人员集中精力处理与业务逻辑相关的数据处理。
Apache Thrift 是比较常用的一个 RPC 框架。它定位为一个“可扩展的跨语言服务实现”。其设计中也重点体现了功能扩展方便,增加语言支持也很方便。官方版本已经提供了C++、Java、Python、PHP、Ruby、Erlang、Perl、Haskell、C#、 Cocoa、JavaScript、Node.js、Smalltalk、OCaml 和Delphi等语言的支持。其架构上也可以支持方便的替换不同的通讯通道(TCP/UDP 或共享内存)和数据序列化方式。而对线程调度、异步操作等都只做了最精简的实现,这让它移植到一个新的语言也比较简单。其重点放在了跨语言的互操作性上。
相对于 Thrift,gRPC 更强调性能,对语言的支持略少一些,包括Java、C#、Go、 JavaScript、、Swift 和NodeJS等。其序列化协议就是 Protobuf,传输协议为HTTP/2,两者都是固定的,不能替换,这两者在性能上的优势也是gRPC高性能的原因之一。尤其是其序列化协议 Probobuf ,得到了广泛的应用。
与前文提到的 CORBA、ZeroCICE、EJB这些面向对象的中间件不同的是,Thrift 和gRPC都是面向服务的。在分布式中间件设计中,“面向服务”实际上比“面向对象”更简单一些,但是在很多场合,反而更实用。关于这一点,在下文的3.5.1 节有更详细的讨论。
1.1.4 消息中间件
基于消息的通讯中间件也在很多领域被广泛运用。RPC机制的视角是:从“客户-服务”之间的需要一个通讯接口。基于消息的通讯其视角直接是数据(消息)本身,不关心谁是客户,谁是服务器。给数据一个主题(常称作 Topic),数据生产者给数据标记主题并发送出来,数据需求这根据主题索取数据,一般称作“发布/订阅模式”。
典型消息中间件协议及相关产品有DDS和MQTT两个体系。前者更强调高可靠性和实时性,尤其对数据通信的服务质量策略(QoS)有丰富的支持。后者强调低带宽占用,只需要极少的代码和有限的带宽就可以实现并工作,所以在物联网上得到广泛的运用。
RPC和消息通讯各有优势,往往被结合起来使用,SOME/IP协议对这两者都有支持,详见下文2.4.5与2.4.6节,甚至还可以互相实现,详见3.5.2节。
1.2 中间件的产品概念
通过上面的介绍,我们可以看出,中间件的概念其实并没有一个统一的定义。大家讨论中间件的时候,都用这个词,但可能讨论的并不是同一件事情。"中间件"这个词本身是一个就是一个相对概念。在分层设计是软件架构中的一种典型做法。任何一层相对其上下两层来说都是"中间层"。
通常意义上,我们讲的“中间件”是把特定应用开发所需要的一些共性技术或组件提取出来作为一个通用的产品,有这样的产品做基础,应用开发就会简单快速。
中间件产品的具体功能是与不同的行业应用领域相关的。但是不同的行业应用领域,其特定软件技术,或软件设计模式又是高度相通的。所以我们在说起“中间件”时,有时候指的是某个特定行业的功能需求,有时指的是一个软件设计模式,有时又是某一项具体技术,比如说通讯通道或者序列化技术等。其概念经常是随着上下文飘忽不定的。
为了后文的讨论更加精准,图1.2从“最小核心” “应用领域拓展”、“关键技术”和“软件架构设计”几个角度对“中间件”这个概念做一个澄清。
“最小核心”和“应用领域拓展”在这一章阐述。“关键技术”和“软件架构设计”内容较多,专门作为一章来表述。
图中红色边框的部分,是本文讨论的内容范围,这些在自动驾驶相关的中间件技术中都会涉及到。
图1. 2中间件的两个应用领域
1.2.1最小核心与两个应用领域
“中间件”往往是“分布式中间件”这个概念的简写,所以中间件通常有一个狭义的最小核心,即在分布式领域中负责解决通讯问题。这个最小核心里又涉及两种通讯方式,一种是远程过程调用(RPC),一种是消息传递。RPC有明确的服务接口定义,主要用于1对1的通讯,消息传递更关注数据的主题与结构,不一定需要明确的服务接口定义,可以进行多对多的通讯。
这两种方式并不是完全互斥的,实际上典型中间件产品或通讯协议两者都会兼收并蓄。比如 SOME/IP协议的 Request/Response Communication 机制([7]4.2.2)相当于RPC,Event([7]4.2.4)相当于消息通讯。在实现上,我们也可以利用RPC机制去实现消息通讯,也可以利用消息通讯去实现RPC调用。“中间件”的最小核心关注的是数据通讯。 图中列出了两个应用领域路线:1.最小核心→ Web 应用框架→企业级应用中间件2.最小核心→车载中间件→自动驾驶中间件这两个领域虽然在最小核心上是一样的,但是其领域拓展的技术方向差别很大。
1.2.2 Web应用领域
第1条领域路线是从 Web 应用中间件到企业级应用中间件。要知道,最早计算机应用都是单机的,然后逐步变成分布式跨网络的。单机的计算机程序要运行,需要基本的资源,包括 CPU,内存,持久化存储(磁盘)。同样,Web应用程序也需要相关的资源,下表是一个非严格意义上的对比,旨在显示二者的在抽象层面上的共性。
资源/技术 | 单机应用 | Web应用程序 | 计算资源 | CPU | 负载均衡集群 | 高速易失存储 | 内存 RAM | 分布式高速缓存 | 持久化存储 | 磁盘文件系统 | 数据库,对象/关系映射 (O/R Mapping) | MVC 框架 | 如 MFC[8] | 如:Apache Struts [9] | 应用间交互 | 进程间通讯 (IPC) | 远程过程调用(RPC ) 或消息传递 |
这个表格并不算完整,它只是说明与单机应用相比,Web应用只是在各方面资源和技术的上采用了分布式技术而已。原来单机程序由操作系统提供的支持,改由各种分布式技术来提供。这些技术的不同实现的组合,形成了Web应用的中间件系统。既然是分布式的,其内核自然少不了RPC与消息系统。
在此基础上,继续叠加一下企业应用所需要的功能,如事务处理,工作流等等,企业应用往往有复杂的逻辑,有些被抽象成分布式对象来表达其数据和行为(面向对象思想在分布式系统上的延展),就又涉及对象的容器技术,对象的生命周期管理等等,这就形成了支持企业级应用的中间件系统。
1.2.3 车载应用领域
第2条领域路线是将中间件应用到车辆上,这时候关注的是实时性、可靠性,更多的总线类型支持、诊断的支持等等,这些对车载软件而言是基础功能,只要是装在车上的 ECU都需要。这些基础功能做好了,可以复用到所有车载 ECU 的开发上。因此叫车载中间件,也叫基础软件,典型的是AutoSAR。
当车载中间件被用于自动驾驶系统时,又会有一些特别的要求。比如需要能满足更高的带宽需求以支持传感器数据传输,对任务执行的时间确定性要求,对异构平台的要求等等。对这些要求的满足构成适用于自动驾驶的中间件系统。
更进一步,为了能更快速的开发自动驾驶应用,如果把自动驾驶应用开发所需要的公共部分形成一个应用开发框架,让传感器适配、感知融合、规划决策、地图及定位、控制执行的适配等关键部分都有标准的开发模式并提供基础的实现,那么这个框架也可以叫自动驾驶的开发框架,是对车载中间件在自动驾驶领域的进一步扩展。
2.中间件的关键技术
有时候我们谈“中间件”实际是在指其中的某个关键技术或者是软件设计模式。与中间件相关的技术点非常多,这一章我们列举出其中的主要部分,梳理其相互关系,并对每个技术点及其相关产品做简要说明。
2.1泛化的RPC概念模型
先来看最小核心中“远程过程调用(RPC)”范畴内最主要的几个概念。图2.1以UML 类推描述了几个概念之间的依赖关系,虚线箭头A -----> B ,表示A依赖于B。
图2. 1 RPC相关概念依赖关系
接口定义语言(IDL)规范描述了通讯接口定义的数据结构,调用的输入参数与返回值等等。用户程序需要根据这个规范先编写“应用程序的接口定义”。一般中间件产品会支持多种开发语言,需要为每一种语言提供对应的“代码生成工具”,根据接口定义生成该语言的代码。生成的代码典型地包含两部分,一部分是服务端的代码桩(stub),代码桩定义了语言特定的软件接口,用户继承代码桩并提供具体的功能实现。另一部分为代理(Proxy)代码,服务的使用者通过代理代码访问远程的RPC实现。代理代码对用户屏蔽了实际的通讯细节。
实际的通讯细节由“中间件运行时(Runtime)”来执行。中间件运行时不仅仅要负责实际的通讯过程,还需要设计合适的任务调度机制,线程模型来保证RPC请求和处理的高效,给用户提供的API接口简洁易用。
“中间件运行时”需要采用合适的“数据序列化机制”来保证用户定义的复杂数据结构能够在“通讯通道”中传输,中间件运行时还需要能支持多种通讯通道来让中间件可以被应用到更广泛的场景。“通讯通道”往往需要符合特定的“通讯协议”规范。
2.2接口定义语言
2.2.1 IDL示例
我们先来看一个接口定义的描述
interface E03Methods { version {major 1 minor 2 } method foo{ in { Int32 x1 String x2 } out Int32 y1 String y2 } errorstdErrorTypeEnum } broadcastmyStatus { out { Int32 myCurrentValue } } enumerationstdErrorTypeEnum { NO_FAULT MY_FAULT }}
这是来自 GENIVI Common API 的一个例子,这里是源代码的链接。
这里使用的 IDL 语言规范是 GENIVIFranca[10][11]. 这个接口描述里定义了一个名为 E03Methords 的 RPC 接口,定义了一个名为 “foo” 的方法,它包含有两个输入参数,分别为Int32和 String。“foo” 方法还有两个返回值。还有一个表示方法调用成功或失败的枚举类型。
接口描述里还包含了一个表示广播数据的 muStatus 类型,广播一个 Int32类型的数据。 下面的代码示例是gRPC 的接口定义,示例源代码在这里。gRPC 采用google protobuf 作为接口定义语言。示例中定义了一个名为Greeter 的服务接口,包含一个 SayHello 方法,指定了输入和输出的数据类型。
// The greeter service definition.service Greeter { // Sends agreeting rpc SayHello (HelloRequest) returns (HelloReply) {}}
// The request message containing the user's name.message HelloRequest { string name =1;}
// The response message containing the greetingsmessage HelloReply { string message =1;} Zeroc ICE 也有自己的IDL 语言规范,称作 Slice[13]。示例源码:struct TimeOfDay { short hour; // 0 - 23 short minute; // 0 - 59 short second; // 0 - 59}; interface Clock { TimeOfDay getTime(); void setTime(TimeOfDay time);}; CORBA 遵循 OMG 组织的IDL.ROS 也有自己的消息格式规范.Apache Thrift 的 IDL 规范.
Adaptive AutoSAR 的IDL是在 ARXML 格式规范里,这个格式不适合手动编写,一般使用特定的工具来编辑。
Web Service 也有自己IDL标准,称作 Web 服务描述语言(WSDL).[15] 图2.2以SysML需求图的形式列举了典型的IDL所需要包含的能力。可以归结为“数据类型支持能力”和“接口描述能力”两部分。
图2. 2 IDL能力需求
2.2.2 数据类型支持能力
一般来说基本数据类型都会得到支持,即各种长度的整数、单/双精度浮点数、布尔值,字符串这些。枚举类型往往也会支持。
自定义数据结构的能力是IDL必须提供的,这是用户定义自己应用所需数据结构的基础。复杂数据类型支持一般包括“数据结构的嵌套”以及“集合类型”。有的会支持数据类型的继承。
2.2.3 接口描述能力
接口描述能力一般可以表达为类似一个函数的定义,包括函数名称,输入参数,输出参数。参数一般支持自定义数据类型和复杂数据类型,有的可以支持多态,但映射到目标开发语言时会有一定难度。
接口的方法一般默认是双向调用,意味着会有返回值。绑定到特定开发语言的时候,代码API如何获取返回值是一个需要精细设计的问题,涉及到同步调用和异步调用的问题,下文详述。
单向调用表示请求者只是发出对远程的操作,但是不关心返回值。这个能力有的 IDL 规范会在IDL描述中指定,如 Franca:
?reAndForget 关键字就是代表单向的含义。
interface Watchdog {methodstillAlive ?reAndForget { in { UInt16 health }}}
有的产品不在IDL中指定单向还是双向,但是代码生成时两个调用方式都在支持,在使用时有用户自己选用,如Zeroc ICE 的 oneway 代理。
在 IDL 中做单向声明会更准确的表明的服务接口的语意,便于代码生成和运行时做优化,也能在服务实现时根据这个语意做明确的处理。
QoS(Quality ofService,服务质量)是一个比较大的话题,基于RPC的中间件很少在 IDL 中描述QoS要求,往往在代码中体现。后文详述。 2.2.4 广播与属性
发布订阅的设计模式能够有效的降低软件系统中各部分的耦合。基于消息中间件天生就是基于发布订阅的模式来设计,如各种DDS的实现。基于RPC的通讯中间件早期并没有明确的广播消息支持,但是也有其它方法实现发布订阅模式(后文详述). AUTOSAR 服务模型将服务定义为提供的方法、事件和字段的集合[16]。这是要求通讯中间件明确支持事件的广播([14]3.4.2节)。GENIVIFranca[10][11]IDL 通过 broadcast关键字来支持。
服务或接口的 “字段” 也叫属性(Field, Attribute ) 可以读取、设置、和广播,后文详述。 2.2.5 高级特性
有些特定的IDL规范还会提供一些独特的功能。比如 gRPC 提供了流式的输入输出方式。
rpc BidiHello(streamHelloRequest) returns (streamHelloResponse); 客户端可以连续发送多个请求,服务端可以连续对多个消息进行响应,这充分利用底层的数据通路,提高整体的响应速度和传输吞吐量。
Franca IDL 提供了对状态机的定义方式,称作协议状态机(PSM),如:
contract { vars { UInt32 count; } PSM { initial idle state idle { on callsetActivePlayer ?> working { count= count + 1 } } state working {on signal attachOutput [count<100] ?> idleon signal attachOutput [count>=100] ?> silence } state silence { }}}
根据IDL生成的代码中也能根据定义的状态支持对应的转换机制和对服务请求的约束。很多自动驾驶功能有自己的状态机设置,很适合使用这种方式来描述。
2.3 通讯通道与通讯协议的可替换性
通讯通道和通讯协议是我们常说的名词,这两个概念相关性很强,有时候指的是同一件事情,有时候又有些差别。这里先做一些澄清。
网络协议一般都是分层结构,ISO/OSI 参考模型定义了七层,从下往上依次是物理层、链路层、网络层(IP层)、传输层(TCP,UDP)、会话层、表示层和应用层。实际互联网使用协议栈并没有会话层和表示层,这两层的功能往往会由各自程序的应用层处理。
每一层的协议有不同的实现方式,这些不同的实现对于上层协议来说,就是不同的通讯通道。比如,网络层(IP层),其底下的通讯通道可以是同轴电缆构成的以太网,也可以是WiFi,甚至可以是USB。每一种通道有其特定的物理层和链路层协议。
车载以太网常用的SOME/IP协议实际是应用层协议,它是基于TCP或UDP的,HTTP协议也是应用层协议,它是基于TCP的。实际上HTTP协议在互联网领域已经变成了一个通用的通讯通道(或者通讯方式)的代称,还有很多协议是基于HTTP实现,比如 Soap 协议,gRPC的数据传输协议。
所以通讯通道和通讯协议是相对的,要根据上下文去判断。
车载的 SOA 应用的通讯目的是实现不同服务之间的数据交互,其底下的通讯通道可以是使用SOME/IP协议的以太网,也可以是共享内存,还可以是DDS,不同通道有不同的协议实现方式。而DDS本身也可以基于以太网或共享内存。不同的设计方式各有优缺点,要根据实际需要选择。
IDL只负责服务接口的定义,用户代码只有与生成的代码和Runtime接口进行交互,一般不用直接和通讯通道与通讯协议交互。这就为Runtime 替换不同的通讯通道和通讯协议提供了可能性。
Adaptive AutoSAR 通讯管理规范就明确提出要求:通讯实现不绑定到特定的通讯协议,SOME/IP协议是必须支持的,但要求能替换成其它协议([16]P10)。
实际上各家Adaptive AutoSAR 厂家的产品,往往会在SOME/IP 协议外,支持 DDS或共享内存的通讯方式。如华为的MDC平台使用了自研的Adaptive AutoSAR,就对SOME/IP、DDS和共享内存都能够支持。
图2.3是Apache Thrift 的概念图,下面两层中,Protocol 负责序列化,Transport 层负责实际的数据传输通道,默认一般会支持TCP 传输,TCP数据的协议格式是 Thrift私有格式,Thrift 文档中并不强调这个协议格式,各语言的运行时采用一样的协议格式就可以互相通讯。用户可以做自己的Transport 层实现,比如实现共享内存的通讯。
图2. 3 Thrift序列化协议与传输通道
Genivi Common API 也可以绑定不同的通讯通道和协议,目前已经支持的有 d-bus 和 SOME/IP。与 Thrift需要用户在代码上指定使用哪种通道不同,Common API 用户
图2. 4 GENIVI 多协议绑定
编写代码时不需关注底下绑定的是那种通讯通道和协议,应用部署时可以通过配置文件指定,程序运行时根据配置文件动态加载指定的通讯通道。
图2.4来自Common API 文档,libCommonAPI-xx.so 是通讯通道绑定实现,可以有多种实现,程序启动时加载配置文件指定的那个。当然,用户可以自行开发更多的通讯通道和协议,比如共享内存或DDS。
2.4 SOME/IP协议
SOME/IP,全称为Scalableservice-Oriented MiddlewarE over IP,是由BMW集团提出的,后来进入AutoSAR 标准[7]。伴随着SOME/IP协议一直有几个标签:
这里我们以这几个标签为出发点,尝试讲清楚以下几个问题:
为什么需要SOME/IP协议,或者说需要它解决什么问题SOME/IP 是什么,不是什么,或者说它定义了什么,没有定义什么SOME/IP 与以太网的关系SOME/IP 如何支持SOA服务进行通讯
2.4.1 为什么需要SOME/IP协议
汽车内部通讯最常用的就是 Can总线,但是Can总线的局限性也很明显:
速度低,普通Can <500kbps, 高速Can 1Mbps, Can FD 5Mbps有效数据载荷之外的额外开销大,甚至超过50%报文有效数据长度太小,普通Can 8字节,Can FD 64 字节
Can FD 是在能与传统Can协议兼容前提下做的扩充,但架不住汽车上功能越来越多越来越复杂,尤其是智能座舱,智能驾驶,OTA 等相关功能的引入,需要更快速、支持大量数据传递、更能适应复杂汽车软件架构的通讯协议。针对上面 Can 总线的局限,SOME/IP 协议提供了至少以下几方面的能力提高。
速度提高:基于以太网,典型车载以太网有100Mbps,1000Mbps的以太网也很常见,尤其是在智能座舱和智能驾驶的域控制器中,千兆以太网是标配。将来随着光纤以太网的应用,速度达到1G~10Gbps 也不会太远。报文长度扩大:基于UDP协议时,SOME/IP 报文的长度最大可以有1400字节,超过1400字节,可以使用TCP协议。Classic AutoSAR 有一个 SOME/IP TransportProtocol[18],这个协议支持对超过1400字节的报文进行分隔传输。SOME/IP 协议的设计上引入了面向服务的概念,有利于各种车载应用的模块化设计和互操作。
2.4.2 SOME/IP是什么
SOME/IP 核心是两个协议,一个用于服务之间进行数据交互(称作SOME/IP协议[7]),一个用于服务发现(称作SOME/IP-SD协议)。协议内容包括:
每个协议有一个报文格式,SOME/IP-SD的报文格式是基于SOME/IP报文格式跟报文格式相关的数据交换的内容和时序的约定
这两个协议是定义在 AutoSAR Foundation 部分。
图2. 5 SOME/IP 与 AutoSar
这意味着,Classic AutoSAR 和 Adaptive AutoSAR 都应该支持这个协议。
SOME/IP 核心就是这两个报文格式以及数据交换的一些时序约定。Zeroc ICE、gRPC、Thrift这些完整中间件产品,往往不会特别强调自己的应用层协议格式细节,一般也不会给出明确的应用层协议文档。
当然 SOME/IP这两个协议的设计细节还是非常精巧的,这也是它能够支持所谓的面向服务架构的基础。这里先不说报文格式的细节,后面会进一步提到。
2.4.3 SOME/IP 不是什么
正因为只是以非常精简克制的方式,仅仅定义了数据交换的报文格式,这意味着不同的中间件产品只要基于这个协议就可以互操作。各自产品在其它方面进行各自的比拼,比如性能,易用的API等。下面我们来说说 SOME/IP 不是什么。
前面已经说到,SOME/IP 不是完整的中间件产品,完成一个中间件核心功能的还缺少很多方面的东西。
没有定义数据序列化机制没有定义接口定义语言形式没有定义数据传输的连接管理机制(对TCP)没有定义中间件“运行时”应该以怎样的方式调度RPC请求没有定义应用软件的编程接口
2.4.4 SOME/IP与以太网
UDP 还是 TCP
既然SOME/IP 底下的通讯协议可以是UDP或 TCP,那么什么时候该用什么底层协议,有什么差别?我们先看看 UDP和 TCP 的区别
| UDP | TCP | 有效载荷数据大小 | 1472 | 流式传输,无限制 | 连接建立时间 | 无 | 三次握手,时间长 | 保证到达 | 不保证 | 保证,失败会重传 | 接收顺序保证 | 不保证 | 保证 | 流量控制 | 不控制,收不过来就扔掉 | 慢启动,拥塞控制 | 广播支持 | 支持(广播或多播) | 不支持,面向连接,只能一对一 | 可以看到TCP是可靠传输,保证数据到达的顺序,但是不支持广播,只能一对一连接。SOME/IP 底层通道如果使用TCP协议,带来的直接便利就是可以在一个 request/response 动作中传输大量数据,比如一次把 2MB的图像数据传递出去。理论上这么做没有问题,但是实际应用中很少这么做。原因在于以下几个问题:
TCP 有一个连接建立的时间,根据请求和接收端的距离,服务器负载情况,网络负载情况,时间不定,从零点几毫秒到几百毫秒不等。如果每次“请求/响应”动作不能共享同一个连接,就每次都要新建一个连接。
TCP数据传输时,有一个“慢启动”的过程。因为TCP协议栈不知道当前物理通道的实际带宽是多少,它会以一个较低的速度发送数据,如果丢包率很低就逐步提高速度,当丢包率提高,确认时间变长就再降低速度,最后稳定到一个合适的传输速率。
TCP 只能一对一连接。SOME/IP 协议中有 Event 和 Field 消息类型。请求者可以要求订阅 Event消息 或 Field 的变化。如果使用TCP,要实现这个要求就需要服务提供者向多个订阅客户端每个都发起一条TCP连接,数据也要发送多次。
这些问题导致每一次数据传输的时间并不稳定。比如说我们要在一个千兆bps的以太网上传递每帧3MB 大小的图像数据,理论上只需要24毫秒,但是因为上面的原因,这个时间可能会在24~100毫秒的区间内抖动,而且还会有累积的延迟。这对视频播放类应用没有太大影响,但是如果用于自动驾驶应用中传递摄像头数据,如果我们要求稳定的30FPS的帧率,就无法保证。而且 TCP的实现是直接在 OS 内核协议栈中实现的,用户层代码也没有太多的进行改进的空间。
如果使用 UDP,这些问题就可以避免,不过要约束一下数据报文的大小。使用UDP:
不用建立连接,直接发送数据发送速率没有约束,但是接收方收不到会丢弃,需要发送方选择一个合适的发送速率不保证顺序,那就让SOME/IP 报文不超过一个UDP报文的有效载荷大小,就不用把SOME/IP数据在UDP层拆成多个包。UDP不保证可靠到达,那就要在SOME/IP 的协议实现层来做错误处理,比如重新发送SOME/IP请求UDP 可以支持广播或多播,适合用来支持 Event 和 Field 类型的数据传输。
根据上面的分析,在车载应用中,绝大部分场合,我们都应该使用基于 UDP 的SOME/IP,并控制每一次消息传递的大小在 1400 字节以内。那么图像数据远远超出了这个大小范围,应该怎么办,后文会提到其它的解决办法。
关于UDP数据包长度
SOME/IP 在UDP通道下有效载荷最大1400 字节。这个数字是在 AutoSAR SOME/IP 文档[7]中出现的。尝试还原一下计算方式,在以太网链路层,由以太网的物理特性决定了数据帧的有效载荷最大为1500(不包括帧头部和帧尾部)[19],术语叫做MTU(Maximum Transmission Unit)。网络IP包的首部要占用20字节,传输层UDP头部要占8字节,SOME/IP头部又需要32字节。所以剩余的有效载荷为1500-20 – 8 -32 =1440字节。这个比 SOME/IP 文档描述的多了40字节,没查到这40字节被用到了哪里,也许只是 SOME/IP 协议直接限定1400字节,留了一个余量。
另外,UDP 的报文大小限制是64K,所以并不是UDP装不下超过1400 字节的SOME/IP报文。这个1400限制的意义是它可以被装入到一个IP报文内,也可以被装入到一个链路层数据帧中。IP 层不需要对UDP报文分割重组,链路层也不需要IP层分割重组。这样带来的好处是错误重传的几率降低,也意味着一个 SOME/IP 报文传递的时间更稳定,这在实时性要求高时很有意义。
需要注意的是,并不是说你把SOME/IP报文限制在1400字节以内,IP层和数据链路层就不会进行拆包再重组了。因为 MTU值在不同环境下是不一致的。1500是 IEEE 802.3协议指定的[19],但是你使用的网络设备可能指定了更低的值。Internet 上标准的MTU是576字节[20]。很多路由器或网关上的 MTU值也是这个设置。所以要确认你的SOME/IP报文不会被拆成多个网络或链路层报文,最好追溯并确认底层协议栈的设置。
从另一方面讲,某些链路层的MTU大于1500,也意味着理论上可以增大SOME/IP报文的大小。如在FDDI中,MTU为4352字节;在 IP over ATM中,MTU为9180字节。
TCP/UDP 之外的其它选择
TCP与 UDP是在几十年前设计的,当时以太网速度慢、延迟长、错误率高。尤其是TCP协议很多特性都是为了在网络基础设施不理想的情况下保证可靠性。而且这几十年的协议发展速度非常的慢。
SOME/IP 协议文档中虽然说了传输层基于TCP或 UDP,但实际工程上,使用其它协议也无不可。在传输层,为了解决 TCP 协议的一些问题,也发展出一些新的协议。如 SCTP 和 QUIC。
SCTP 协议全称 StreamControl Transmission Protocol[21]。SCTP 是一种新的 IP 传输协议,与UDP和 TCP处于同等级别,为应用程序提供传输层功能。与TCP 一样,SCTP 提供可靠的传输服务,确保数据在网络上按顺序无错误地传输。与 TCP 一样,SCTP 是一种面向会话的机制,这意味着在传输数据之前在 SCTP 关联的端点之间创建关系,并保持这种关系直到所有数据传输成功完成。
但是,相比 TCP,SCTP至少有两个对SOME/IP友好的特性:
支持在一个连接上同时进行多个数据流程的传递(TCP只允许一个),这可以让 SOME/IP通过一条连接在多个流上同时发起并发的请求。以数据块为单位传输的(TCP是以字节为单位),让 SOME/IP把一次 RPC调用包装在一条数据块中
另外,SCTP 提供了更优化的拥塞控制策略,提高了传输的效率。
图2. 6各协议的关系
但是 SCTP 仍然有较繁琐的连接建立过程(改善了安全性)。这方面,QUIC协议有更好的表现。
QUIC全称Quick UDPInternet Connection。是基于UDP实现的可靠数据传输协议。QUIC 协议的主要目的,是为了整合 TCP 协议的可靠性和 UDP 协议的速度和效率。相对TCP它有如下重点优化:
简化了连接的建立过程,加快了连接的速度支持一个连接中多个传输流多种手段改进了拥塞控制算法,
这些特性让 QUIC 成为 TCP 的最好替代者。工程实践中可以考虑采用。HTTP3.0协议就是要求底下采用QUIC协议。
但是在需要广播或多播的场合,还是只能使用 UDP。 其实SOME/IP 协议的下层通道甚至可以不使用以太网,比如采用SPI。车载域控制器中往往为了实现功能安全会在SoC之外配一个满足ASIL-D规范的的MCU,如图2.7:
图2. 7使用 SPI 做备份通道
SoC 与 MCU 之间除了以太网连接外,还有 SPI 作为冗余备份。这种情况下,可以在SPI驱动中实现对 SOME/IP报文的传输。应用程序只需要使用 SOME/IP 协议,而不关心底下实际的数据通道。
2.4.5 SOME/IP 与RPC
我们结合 SOME/IP 的协议来看它对 RPC的支持。报文格式如下:
图2. 8 SOME/IP 报文头部格式
其中32位的 Message ID 由两部分组成,一个是Service ID,一个是 Method ID。假如以Franca规范定义的一个服务 HelloWorld的定义如下,其中包含了一个方法sayHello,
interface HelloWorld { version {major 0 minor 1 } method sayHello { in { String name } out { String message } }}
当这个服务接口与SOME/IP进行绑定时,就需要指定其Service ID 和 Method ID(此时,报文中的Message Type 为 0x00或 0x01)。如下:
define org.genivi.commonapi.someip.deployment for interfacecommonapi.examples.HelloWorld { SomeIpServiceID= 4660 methodsayHello { SomeIpMethodID= 30000 SomeIpReliable = true in { name { SomeIpStringEncoding = utf16le } } }}
如果我们使用Thrift 或 gRPC的时候,是不需要这么显式的进行Service ID 和 Method ID的声明的。因为Thrift 或 gRPC是作为独立的产品存在,Service 和 Method 的识别机制是各自的协议内部实现了,在代码生成时已经为我们处理好了。用户不需要直接进行指定。而SOME/IP只是一个纯粹的通讯协议,两个不同厂商的SOME/IP实现也是可以相互通讯的,所以其Service 和 Method的ID生成规则不能由各自的实现库自己指定,而是需要将ID的指定能力暴露给用户来确定。
2.4.6 SOME/IP 与消息通讯
当SOME/IP 报文中的MessageType 为 0x02时,代表是一个Event,这时候的报文是基于消息通讯中的一个消息报文,不需要回复。报文中的 Method ID此时为 Event ID。
例如以Franca规范定义的一个服务 MyService的定义如下,其中包含了一个事 件myStatus,
interface MyService{ version {major 1 minor 2 } broadcastmyStatus { out { Int32 myCurrentValue } }}
绑定到SOME/IP时需要为其指定 EvernID。
define org.genivi.commonapi.someip.deployment forinterface commonapi.examples. MyService { SomeIpServiceID = 4660 broadcastmyStatus { SomeIpEventID= 33010 SomeIpEventGroups = { 33010 } out { } } }
EventGroup 的定义会被服务发现报文使用,这里不作详述。
当以UDP报文发送事件消息时,可以使用UDP的多播机制,同一个多播组的侦听者都能收到消息,这是SOME/IP消息通讯的基础实现形式。
基于DDS和MQTT的消息中间件实现会比这个更复杂一些,基于UDP的多播只是它们实现组内广播的一种物理形式,在不同的网络环境中有不同的实现方式,也可以在某个局部网络采用存储转发的方式,还有更多的QoS支持。
但SOME/IP的消息通讯仅仅定义了一个广播报文形式与消息发现机制,协议本身除了UDP多播外不涉及其它多播实现方式,也不涉及QoS,具体的实现可以进一步扩充这些特性的支持。
2.5 共享内存及零拷贝
2.5.1 共享内存加速的必要性
中间件技术带来了软件架构上的便利,让用户可以把复杂的功能拆解成多个不同的服务,协同工作。但是也带来另外的问题,就是通讯的延迟。下表展示了计算机系统中不同形式的数据传输所需要的时间。可以看到,一次主内存访问的时间约100纳秒,但是如果数据在二级缓存中,访问速度就提高了一个数量级。从主内存读取1MB数据,约60000纳秒,但是通过千兆以太网传输,速度就下降了至少两个数量级。
操作 | 时间 | 执行一条指令 | 1 纳秒 | 从一级缓存读取数据 | 0.5纳秒 | 分支预测 | 5纳秒 | 从二级缓存读取数据 | 7纳秒 | 互斥锁 lock/unlock | 25纳秒 | 从主内存读取数据 | 100纳秒 | 从主内存读取1MB连续数据(DDR4, 17GB/s) | 60,000纳秒 | 千兆以太网传送1MB 数据 | 10000000纳秒 | 也可以用更直观的方式来看,如自动驾驶典型使用的200万像素摄像头,分辨率1920x1080 以YUV422格式记录,大小月4MB。通过千兆以太传递,需要时间约在35毫秒左右,这意味着每秒最多只有30帧。如果是800万像素的摄像头,最多只能到每秒8帧。但是如果通过内存传递数据(DDR4,17GB/s),200万像素可以达到4000FPS,800万像素可以到 1000FPS,差异巨大。
所以在利用中间件技术带来的架构便利的同时,要考虑如何利用共享内存技术进行通讯的加速,这在自动驾驶产品开发中尤其重要。
2.5.2 具体技术问题及冰羚(iceoryx)介绍
最近博世推出的开源中间件产品iceoryx[25]受到较多关注。不过Iceoryx并不是我们在第二章所定义的完整中间件概念。它专注于共享内存技术,但是设计良好的API可以使它能比较方便的与其它中间件进行集成,如 Adaptive AutoSAR,ROS等。这一节我们结合冰羚简述共享内存技术需要解决的问题。
共享内存技术是IPC(Inter-ProcessCommunication,进程间通信)的一种。
注:IPC只是一个统称,除了共享内存外,进程间的同步锁,信号量,名字管道,消息队列,网络Socket 等都是IPC。但是在涉及到中间件的文章中,因为中间件本身就是主要用于分布式场景,通过网络进行通讯,往往提起 IPC 的时候实际指的就是共享内存技术。严格来说这并不严谨,所以本文中的 “IPC” 指的就是其原意--- 任何可以进行进程间通讯的技术。如果读者在其它地方看到“IPC”,请注意根据上下文分辨其实际含义。
进程地址空间与共享内存
进程是操作系统中非常重要的基础概念之一,每个进程都有自己独立的虚拟地址空间,互相之间不能跨界访问。操作系统负责把每个进程的虚拟地址空间映射到实际的物理内存,如图2. 9。
图2. 9虚拟内存映射
为了实现进程间通过内存的数据共享,操作系统至少要提供两类系统调用。一个是将本进程的虚拟地址映射到物理内存区(创建共享内存),一个是提供进程间的同步机制。
同步机制是必须的,共享内存区对于进程A和进程B是一块竞争资源。如果同时进行读写访问会产生不可预料的错误。进程A写入数据完成后,需要有机制通知进程B可以读取。进程B读取完数据后,也需要告诉进程A可以写入新的数据。
同步机制可以使用操作系统提供的互斥锁、信号量等同步原语。所以,可以认为,冰羚提供的最基本的功能就是对操作系统共享内存操作函数和同步机制的封装。可以使用简便的API(C或 C++)进行上述操作,而不用理解操作系统同步原语的细节。
多帧数据缓存、流控、发布订阅
一般来说,共享内存最简单的使用方式就是两个进程映射同一块内存区到自己的虚拟地址空间后,进程A写入数据,完成后通过同步机制唤醒进程B读取数据,进程B使用完数据后,通知进程A写入新数据。可以认为这是一个单帧的数据交换。
然而实际应用场景远比这复杂。试想一个低速场景下的环视自动泊车应用。四个环视鱼眼摄像头采集视频数据,同时有4个模块需要以不同的方式使用这些摄像头的数据:
“环视拼接算法”需要将4个摄像头的画面进行畸变矫正后拼接成一个顶视图画面,为了人观看流畅,需要至少30fps 帧率“障碍物检测算法”需要检测画面中的障碍物,因为是低速场景,要求15fps的帧率“停车位检测算法”需要检测画面中的划线停车位,需要10fps 的帧率“行车记录仪”将画面编码保存为视频数据,需要20fps 的帧率。
图2. 10环视泊车应用共享内存示例
这里我们可以看到有几个难点:
产生的数据是连续的多帧有多个消费者多个消费者的帧率不同
一个能够容纳多帧数据的缓冲区以及对应的缓冲区管理机制是必须,数据在一个帧缓冲区填写好后,不用等消费者用完,就可以在另一个缓冲区中写入新数据,这可以大大提高系统的数据吞吐量。但是到底保留多少个缓冲区?数据消费者读取太慢,所有缓冲区都满了,那么是放弃新数据,还是阻塞写入者,让数据生产得慢一些?这就涉及到缓冲区管理和流量控制的机制了。这也是我们使用共享内存的时候常遇到的问题。冰羚内置了多帧缓冲区管理和流量控制的能力。
当有一个帧内存被多个消费者使用时,冰羚内部实现会对每一个帧内存维护一个计数器,表示当前的消费者数量。消费者释放这一块内存区时,计数器减1。计数器为0时,该内存区可以用来写新数据。再上面的例子中,极端情况下,三个较慢帧率的数据消费者进程(行程记录编码,停车位检测算法,障碍物检测算法)各锁定了一个帧内存区没有释放,但只要还有两个帧内存区,一样能保证拼接算法以更高的帧率获取数据而不会被其它进程阻塞住。当然,更多的帧容量能防止消费者计算时间的抖动,效果更好。
前面的例子中,一个数据生产者,有多个数据的消费者,典型的实现机制是使用操作系统提供的信号量同步机制,让多个消费者等待新数据的到达。冰羚提供了一套设计精巧的发布订阅机制API,封装了底层的进程间数据同步机制。
零拷贝
前面说到,200万像素摄像头每帧数据4MB,如果每秒30帧,就有120MB的数据。如果多个消费者都需要拷贝数据到自己的缓冲区,那么每秒钟会有几百MB的数据在传输,大量消耗内存带宽和CPU资源,也消耗了额外的内存空间。
最好的解决办法就是在这块共享内存区中,数据生产者写入,数据消费者读取,数据就在原地,不需要做额外的复制。这就是所谓的零拷贝。
道理很简单,但是真正实现起来还是有很多细节技术。一方面是消费者和生产者之间的同步机制,前面已经讲过。其实多帧缓存也跟为了达到零拷贝的目的相关。不同消费者可以同时锁住一个数据帧的内存,同时使用数据进行处理,因为它们各自的处理速度不一样(帧率不同),一个消费者已经完成处理并释放数据帧内存区,而另一个仍然占用。所以需要在另外的帧数据区中读写新的数据。
另外,API形式上也有一些技巧。对与C语言,比较简单,一般是直接将一个共享内存地址转换成C Struct 的指针再对这个 Struct 进行读写。C++ 稍微复杂一些,我们需要把 C++ 的对象放在共享内存区。C++的 new 操作符实际有两步动作1.分配内存2.调用构造函数
默认的内存分配是在堆中进行,我们可以单独重载new 操作符的内存分配部分,让它从共享内存中分配。或者直接给new 操作符提供共享内存中的某个地址,再这个地址上调用构造函数(一般称为placement new)。
以上这些为实现零拷贝所需要的技术在冰羚中都有实现,提供了很好的 API 接口。C++的STL是很难在共享内存中使用的,因为很多STL类型自带了内存分配机制。为了解决这个问题,冰羚还提供了一套类似STL形式的容器类型,可以在共享内存场景中使用。
2.5.3工程上的其它难题
冰羚这样的共享内存库让使用共享内存非常的方便。而且它的API是非侵入型的,可以比较容易与其它软件库集成。但是在实际工程中还是在某些场景依然有一定难度。
有些现有的程序库已经有自己的缓冲区管理和对零拷贝的设计,如 V4L (Video for Linux,常用于摄像头数据采集),与冰羚集成需要协调两者数据缓冲区的使用机制。
自动驾驶使用的高性能AI芯片往往是异构系统。除了有 Cortex-A核心外,还有R核心,M核心。还会有ISP处理单元,DSP处理单元,NPU等。并不是都运行Linux系统。典型的如 TI TDA2/TDA4 系列,是在其中的 Cortex-M核心或Cortex-R核心上运行RTOS系统,执行摄像头数据捕获动作。这种情况下依然要使用共享内存,并支持零拷贝,就需要根据芯片本身提供的能力基础上,再做较多的工作。
为了达到更高的算力,很多高性能SoC芯片都开始支持单板上放多个芯片,并通过PCIe接口连接数据通路(如:TDA4,征程5)。PCIe能提供超过20GB/s的带宽,远超千兆以太网。多个SoC芯片通过PCIe互联,操作系统的PCIe驱动可以支持多个芯片通过内存访问的方式进行数据交互,还可以通过DMA技术减少CPU负载。
理论上,冰羚这种软件库可以进一步扩展,基于PCIe支持跨芯片和OS的共享内存机制,在具体实现上需要与PCIe驱动在缓冲区管理,同步机制上进行适配。如果可以做到,就可以对上层屏蔽PCIe相关的操作,让多个芯片/OS之间基于共享内存的数据交换时跟单个芯片/OS在API接口上仍然保持一样简洁,可以大大简化应用层的开发。
另外,Android 从4.0 开始引入了新的内存分配管理机制 ION,目前已经进入的Linux 内核主线。它被用于在用户空间的进程之间和内核空间的模块之间进行内存共享,可以实现零拷贝的数据运用。尤其对摄像头数据采集、显示输出等涉及大量数据传输的场合非常有用,同样适用于自动驾驶领域的摄像头数据处理。ION 在内核空间和用户空间分别提供了一套可以相互协作的 API 接口。冰羚如果要更好的应用与自动驾驶领域,可以考虑与ION 的集成。
2.6 数据的序列化
程序使用的数据在内存中有其存在的形式,往往跟所使用的程序语言对数据的表示形式相关。最简单的是一个不含指针的C语言结构体表示的数据,其内容就在一块连续的内存区间里。如果结构体中包含了指针,那么有可能一部分数据在栈上,一部分数据在堆上。如果使用C++STL中的容器来保存数据,数据也不会在一个连续的内存区,STL还支持各种内存分配器,内存布局会更复杂。对于Java、C#、Python等具有垃圾收集机制的语言来说,用户不应该知道数据的具体内存位置,一个包含多个字段的数据类型其数据几乎不可能在一个连续的内存区中。
当我们需要存储数据或者在通讯线路上发送数据时,我们需要把内存中的数据结构转换一段连续的表示形式,可以是一段连续的二进制数据,也可以是一段连续的文本,这个过程叫序列化。反过来,将一段连续的二进制数据或连续的文本,转换成内存中的数据结构,就叫反序列化。程序之间要通过网络进行通讯,就离不开序列化和反序列化操作。
序列化有两个关键的衡量指标,序列化过程的速度和序列化结果的大小。如果序列化动作很频繁,就希望速度快一些,如果对网络传输速度更看重,就希望序列化结果小一些,也就是在时间和空间中寻找平衡点。
也有从可读性来考虑序列化的方式,一般来说,序列化成文本(如:JSON,XML等)结果会比较大,但是人直接可读;序列化成二进制会比较小,可读性极差。
Google 的 ProtoBuf是广泛使用的序列化库,性能和大小都得到很好的优化。更重要的是数据类型使用专门的IDL规范来定义,程序中用来执行序列化和反序列化的代码可以使用工具根据IDL文件自动生成,而且支持多种语言。这样就大大简化了开发工作。
通讯中间件产品都会有自己的序列化和反序列化协议,用来定义不同的数据类型如何转换成连续的数据表示形式。有的直接使用ProtoBuf,有的有自己的默认实现,也可以由用户自定义来进行扩展。
2.7 异步IO与任务调度
分布式中间件必然涉及到大量的网络I/O 操作。为了保证I/O 操作不阻塞用户线程的执行,中间件对异步I/O的支持就非常重要。中间件对异步I/O 的支持体现在两个方向,一个是如何充分利用操作系统提供的异步I/O机制(如Linux 的 epoll),一个是如何提供方便的程序语言特定的API。
操作系统提供的异步I/O的基本能力及相关的系统调用,各语言都有自己的异步I/O库来给用户提供更好用的API接口。
图2. 11中间件与异步I/O
图2. 11列举了用户代码、中间件Runtime、异步I/O库以及操作系统接口之间的关系。例如:Thrift 的C++ 版本基于 libevent库实现,gRPC的C/C++ 实现使用了libuv,而Java 实现使用了 Netty。
中间件在这里其了一个作用,就是不让用户直接使用异步I/O库的代码,用户进行数据通信时直接访问代码是中间件根据IDL定义生成的代码。用户不需要了解太多异步I/O编程的知识,这些复杂性由中间件Runtime和生成的代码来处理。
同时中间件 Runtime 还要处理与异步I/O相关的线程模型和任务调度机制,在下文4.3.3 节有更详细的讨论。
2.8 QoS
服务质量策略(QoS)是分布式通讯中一个比较重要的概念。一方面通讯通道会有各种现实的物理约束,比如数据传输会出错,带宽有限,带宽会有波动,通讯会有延迟、拥塞;另一方面通讯参与者对传输的及时性、可靠性的需求是不同的,不同类型数据的重要性也不一样。
QoS 用于为不同类型业务提供区别性的服务策略,给那些对带宽、时延、时延抖动、丢包率等敏感的业务提供更加优先的服务等级,使业务能满足用户正常、高性能使用的需求。
下面是一些典型的QoS特性:
可靠性(Reliability)
这个 QoS 特性涉及到在可靠和高效之间的平衡。最可靠的情况是“保障所有数据按照顺序被接收到”,丢失的数据会被重发,这样必然会承受效率上的损失。最高效的情况是发送方尽力按顺序发送数据,不管接收端是否收到,接收端自己重新排列收到数据的顺序,并要清楚的知道丢失的数据已经无法再获取到。接受端要能够对此进行相应的处理并保证程序的正确性。
在这两个极端之间,还可以有折中的方案,比如最后几个数据保证完全可靠,其它数据采用最高效的方式;或者说需要严格按照顺序接收,但是允许丢失部分数据。
截止时间(Deadline)
发送者承诺在一个Deadline 时间内发送数据,接收者希望在一个 Deadline 时间内获得数据,这个Deadline 应该大于等于发送者的 Deadline,否则会产生不匹配的错误。发送或接受超过了Deadline 时间,需要进行错误处理。
重试次数(RetryCount)
这是一个故障恢复的特性,当出现传输错误时,可以自动进行多次重试。但接收端需要处理收到重复数据的问题。 这只是最简单的几个 QoS特性,商业版的RTI DDS支持的QoS特性至少有40个以上。中间件对QoS的支持是很有挑战的工作。其难度一方面在于众多的QoS特性需要设计、开发、测试,工作量很大。另一方面在于不同的QoS其实差别非常大,涉及到通讯中可靠性、性能、安全、数据持久化等等各个方面,还会有新的QoS特性会被提出来,如何设计好一个合适的、能对多种多样QoS特性进行支持的软件架构就很有挑战。
2.9 多语言支持
多语言支持是中间件的一个关键特性。增加一个语言支持主要是两方面工作,一个是开发基于该语言的中间件Runtime 实现,一个是开发代码生成工具,根据IDL生该语言的代码桩。
中间件的功能越复杂,特性越多,Runtime实现的难度就越大。一般中间件都会先实现C/C++版本,其它语言可以只实现对C/C++版本的API封装,这样降低工作量,同时也能获得与C/C++版本接近的性能。
语言特定的代码生成工具也是多语言支持的重要部分。代码生成过程一般分为两大阶段,第一阶段是通常程序语言编译时都会有的词法分析、语法分析、语义分析过程,得到的结果是抽象语法树;第二阶段是根据得到的语义分析结果,生成目标语言代码。
当中间件要支持多语言时,第一阶段的工作对各语言而言是共用的,只是第二阶段要为各语言单独编写。
Thrift 就是基于 Lex/Yacc 库实现第一阶段[6]7.10,第二阶段提供一个模板代码,每个语言根据模板代码提供自己的代码文本输出。Franca 提供专用的语法解析库,第一阶段将Franca IDL转换成内存中的数据结构,第二阶段各语言的代码生成工具根据内存数据结构,输出语言特定的代码文本。还有一些特别的办法,使用 Java 或 C# 作为原生语言,将IDL规范定义作为原生语言的一个子集,这些原生语言有一个特点就是支持很好的反射能力,能在运行时获取被编译代码的详细类型信息。那么第一阶段就可以使用原生语言的编译器,第二阶段从编译结果中提取类型信息,根据类型信息生成目标代码。这些方式的IDL规范都接近一般的程序语言,方便人工阅读和编写。
也有的中间件使用XML 作为 IDL的表示方式,那么第一阶段就可以省略掉,因为XML标记直接就表达了接口语义。AutoSAR 使用的 ARXML 就是这种方式。但是人工阅读和编写就很不方便,需要工具支持。
总之,中间件的多语言支持需要慎重选择各语言Runtime的实现方式以及代码生成的实现方式。
|
|