百度Apollo系统学习-Cyber RT 通信-底层

百度Apollo系统学习-Cyber RT 通信-底层

  • 简介
  • 管理者单例:Transport
  • 几个基类
    • Endpoint
    • Transmitter
    • Receiver
    • Dispatcher
  • 最底层回调机制
    • SIGNAL\SLOT机制
    • cyber中的信号与槽
      • Signal
      • Slot
      • Connection
    • ListenerHandler
  • 各个模式详解
    • Intra模式
      • IntraTransmitter
      • IntraReceiver
      • IntraDispatcher
    • Shm模式
      • ShmTransmitter
      • ShmReceiver
      • ShmDispatcher
        • Segment
        • NotifierBase
    • RTPS模式
      • RTPS协议简介
      • RtpsTransmitter
      • RtpsReceiver
      • RtpsDispatcher
      • Cyber中rtps的配置
        • QoS
        • Participant
        • 遗漏的地方
  • 番外篇Hybrid
    • HybridTransmitter
    • HybridReceiver
  • 通信部分底层总结
  • 通信部分整体总结
  • 少了些什么
  • 参考链接:

简介

上一篇文章介绍了下图中的除Transport部分,本文就来深入解析Transport即cyber通信模块的底层机制。

我们从cyber/node/writer.h中的Writer写数据切入底层。

管理者单例:Transport

Transport也是一个全局单例,Writer中真正负责写数据的是成员transmitter(由Transport创建),代码位于cyber/transport/transport.h。下面看一下它几个重要的成员。
- xxxDispatcherPtr,可以看到总共有Intra, Shm, Rtps这三种dispatcher,也就是说总共有三种通信方式。在Transport被创建时会把这三个Dispatcher的单例也一并创建出来。这里特别要注意的是RTPS模式会一并创建一个Participant,具体在RTPS模式会介绍。
- 通信方式是在TransmitterReceiver被创建时指定的(分别在WriterReaderClientServiceInit()中),但实际上ReaderWriter在创建它们的时候没有指定模式(只在测试用例中有指定,ClientService用RTPS模式),所以它们实际上都是用的默认Hybrid模式,Hybrid其实是把消息用多种通信模式各发一遍,后面会具体介绍。
- notifier_成员只有shm模式会用到(注意不要和上层的Notifier搞混),participant_成员只有rtps模式会用到,只不过所有这些成员都会在初始化时一并初始化。
- Transport主要提供了两个函数:Transport::CreateTransmitter & Transport::CreateReceiver,前一个提供写数据的类,后者提供读数据的类,这一部分我们主要关注前者。

几个基类

Endpoint

Transmitter & Receiver的基类,可谓是万物的起始点了,它里面有三个成员,一个是bool enabled_用来标记是否被启用;一个是Identity id_这个就和身份证一样,每个EndpointIdentity都不同,后面的Receiver等也是用这个来进行标识;最后一个RoleAttributes attr_用来记录从配置文件(不同组件Component的配置文件里,具体见模块加载介绍博客)里读取的配置,全局变量里的HostNameProcessId以及IdentityHashValue

Transmitter

真正的数据写者的基类,它继承了Endpoint类(主要负责最最基础的通信消息格式记录,记录的配置在cyber/proto/role_attributes.proto),继承它的四个子类主要实现了它的Transmit(const MessagePtr& msg, const MessageInfo& msg_info)函数,这也是Writer直接调用的函数(Writer调用的是Transmitter::Transmit(const MessagePrt& msg)函数,它会设置一下msg_infoseq_num并把这个transport事件加入event,然后就调用子类实现的Transmit函数),通过传入一条消息即可以完成数据写入任务。我们接下来就通过看这四个子类的不同实现来理解底层的通信机制。

Receiver

  • 和上层分发器的DataDispatcher联系的类,同样继承了Endpoint类。继承它的四个子类和Transmitter的四个分别对应,它们主要的工作就是向各自的分发器XxxDispatcher添加监听器(通过Enable函数实现,主要是绑定回调函数,将自己的OnNewMessage函数注册到XxxDispatcher)。
  • Receiver的回调函数OnNewMessage就是去调用一下自己的函数指针成员msg_listener,这个函数指针在一开始创建Receiver的时候由参数指定,Receiver真正创建的地方是cyber/node/reader_base.hReceiverManager::GetReceiver函数进而调用的Transport::CreateReceiver(会在Reader::Init的时候被调用,Reader中没有指定模式,所以是默认HYBRID模式,在ServiceClient初始化时也会以RTPS模式调用,目前似乎只发现有HYBRID & RTPS两种模式创建),Receiver::GetReceiver函数接收一个role_attr参数然后从全局的std::unordered_map>>receiver_map_中根据channel_name返回一个Receiver,如果不存在则创建一个,创建的时候则会指定一个匿名函数即后来的msg_listener。这个msg_listener函数接受msg, msg_info, read_attr参数,先往PerfEventCache中加入这个DISPATCHTransportEvent,然后调用上层的DataDispatcher::Dispatch函数(参数是reader_attr.channel_idmsg),再加入这个NOTIFYTransportEvent。中间调用DataDispatcher::Dispatch是个非常非常关键的地方,在这里上层和底层最终完成了闭环。也就是说当底层Receiver的回调函数msg_listener收到消息被调用时,上层的分发器DataDispatcher会把收到的来自底层的消息发到等待的消息缓存里然后调用上层的通知器DataNotifier去唤醒实际ComponentProc协程来处理这些消息(DataDispatcher::Dispatch函数相关内容请看通信上层博客)。
  • Receiver提供了EnableDisable函数用来开关,开的时候就往自己的DispatcherAddListener,关则是RemoveListener

Dispatcher

消息分发器,三种通信模式都有自己的Dispatcher单例。每个模式的每个XxxReceiver中都有一个XxxDispatcherPtr指向这个单例。
Dispatcher主要是记录了一个channel_id和对应ListenerHandlerBasePtr的map,提供了AddListenerRemoveListener函数来向这个全局map中该channel对应的回调函数的管理结构ListenerHandlerBase注册或删除回调函数。需要注意,因为Dispatcher是每个模式的全局单例,所以里面的map和函数都有线程安全的保障。
AddListener各个通信模式会有一些差别,但做的工作比较类似(Intra很不一样),都是先把Receiver::OnNewMessage进行一个封装,因为不同通信模式的数据格式不一样(比如shm模式下是Block),所以会在封装的函数里把这些数据重新组织成Receiver::OnNewMessage接收的MessagePtrMessageInfo,然后调用基类Dispatcher::AddListener函数(Intra模式例外,它不会去调用)。基类中的AddListener函数会去mapmsg_listeners_中找对应该channel_idListenerHandlerBasePtr,如果找不到就创建一个新的并加入map。最后调用一下该ListenerHandlerConnect函数来将刚刚封装的函数注册进去。
我们知道Receiver会在其对应的msg_listener被调用时通知上层完成消息的传递,我们也知道msg_listener是在Dispatcher::AddListener中被真正加入到各自的全局线程安全map中对应的ListenerHandler的,但msg_listener是怎么被调用到的呢?这个会涉及通信的最底层回调机制,会在下面介绍。

最底层回调机制

在某个Receiver被创建时(cyber/transport/transport.hTransport::CreateReceiver),只要不是HYBRID模式都会立马调用Receiver::Enable函数,Receiver::Enable函数会调用其对应的XxxDispatcher::AddListener函数,除了Intra模式外最后都会调用到Dispatcher::AddListener函数然后调用找到的ListenerHandler::Connect函数,Intra模式不会去调用基类的AddListener但还是最后会调用到ListenerHandler::Connect函数。现在我们就以这个函数为入口看一下底层的底层是什么样的。

SIGNAL\SLOT机制

信号与槽机制是Qt编程的基础,原本是用在GUI编程中的一种通信方式,信号是在特定情况下被发射的事件,槽就是对信号响应的函数。信号与槽的关联就是通过Connect函数。
需要注意的地方就是一个信号可以连接多个槽,多个信号可以连接同一个槽。

cyber中的信号与槽

Signal

位于cyber/base/signal.h,有一个SlotList成员,记录了关联在该信号下的所有槽,Signal中的函数都是线程安全的。

  • 提供了一个Connect函数,为某个回调函数创建一个Slot共享指针,然后加入到自己的槽列表并返回一个Connection关联实例。提供Disconnect函数,接收一个Connection参数,从槽列表中找到该槽,然后将槽的标记置为false并从列表中删除。
  • 重载了()操作符,也就是说当像这样调用时signal(msg, msg_info),就会对该信号对应的所有槽(所有关联的回调函数)进行一次调用,这其实就是通知所有监听该信号的回调函数。

Slot

位于cyber/base/signal.h,它其实就保存了一个回调函数std::function cb_和一个标记bool connected_,提供一个Disconnect函数用来将标记置为false。它也和信号一样重载了()操作符,当被调用时就会去运行cb_函数。

Connection

保存了一个信号的指针一个槽的指针,一个Connection实例就代表了一条关联关系。通过Slot的标记位显示是否处于关联状态。

ListenerHandler

位于cyber/transport/message/listener_handler.h,继承自ListenerHandlerBase,是Dispatcher中的map保存的回调函数的管理结构(就是保存最底层信号与槽的管理结构)。通过Dispatcher保存的msg_listeners_channel_id索引。它的成员有一个base::Signal signal_;和三张map。signal_对应一种消息类型。三张map其实对应着不同的索引方式,首先了解两种ListenerHandler用的key,第一个self_id,指的是自身的Endpoint中的IdentityHash_Value,记录在每个EndpointRoleAttributes attr_中;第二个是oppo_id指的是对方的Endpoint里的id。为什么会有这个区别呢,主要是TransmitterReceiverEnable的时候,有时候会指定读者或写者,也就是说不光要写或读这个channel的消息,还要求写给特定的读者或读特定的写者,所以这时候就会有oppo_id这个值来指出对方。特别需要注意的是一般都是会用带oppo的Enable函数,因为在cyber的服务发现机制中Reader::&Writer::JoinTheTopologycyber/node/reader.h&writer.h,会在cyber服务发现博客中具体介绍)会给每个TransmitterReceiver指定对应的channel中所有的ReaderWriter为oppo,可以说不带参数的Enable函数几乎只是在测试系统时和RTPS模式中会被用到。当然还是要狠狠吐槽一下这样的实现,容易让人摸不着头脑,下层实现影响上层封装,正式功能和测试都混杂在一起。
回过头看这三张map,第一张std::unordered_map> signal_conns_通过self_id来查找一条关联关系Connection;第二张std::unordered_map signals_,通过oppo_id来找相应的SignalPtr;第三张std::unordered_map>> signals_conns_,通过两次索引,先oppo_id找到一张map,再通过self_id索引一条关联关系,该map实际上记录所有该channel相关的关联关系。
signal_和第一张map作为一个整体,一般是在测试用例和RTPS模式下使用(调用无参数的Enable),只有一种信号,通过self_id索引关系。后两张map作为另一个整体,在剩下的其他情况下使用(调用有参数的Enable),一个channel_id对应了一个ListenerHandler,后面2张map记录了所有该channel的信号以及相应的关联关系,通过oppo_idself_id一并索引关系。需要注意因为AddListener是在Receiver里调用的,所以self_idreceiver也就是读者的Endpoint的id,oppo_id一般也就是写者的id,所以会用oppo_id来索引信号(一个写者对应了一个信号,一个读者可以监听某个channel里多个读者的信号),一个ListenerHandler记录了某个channel里所有的读写者及其关联关系。
下面我们通过看ListenerHandler的几个成员函数理解。
- Connect(uint64_t self_id, const Listener& listener),接收self_id和一个回调函数为参数,会先调用成员signal::Connect函数在该信号的槽列表中加入该回调函数并得到一个关联实例Connection,然后在signal_conns_中加入这条实例。
- Connect(uint64_t self_id, uint64_t oppo_id, const Listener& listener),多了一个oppo_id参数,首先去signals_里找是否有该oppo_id对应的信号,如果没有则创建该信号并加入,然后调用该信号的Connect函数创建该回调函数的一个关联。接下去在signals_conns_里查找是否存在该oppo_id对应的std::unordered_map,如果没有则创建,然后向对应的signals_conns_[oppo_id][self_id]处放入刚刚信号的Connect函数创建并返回的关联关系。
- 信号是什么时候被调用(重载的()操作)的呢?是ListenerHandler::Run函数。Run函数会在不同模式的XxxDispatcher::OnMessage函数中被调用(OnMessage会先查找出相应的ListenerHandler后再调用它的Run)。Run函数首先从接受到的消息信息msg_info中找到发送者的id的HashValue(),这个就是oppo_id,用它从signals_map中找到对应的信号,然后使用该信号来通知所有监听它的槽。

各个模式详解

Intra模式

首先注意一个很恶心的东西,那就是这里的Intra模式和上层的IntraReader, IntraWriter没有任何关系,上层的Intra+是为虚拟模式服务的,根本就不会调用到这里。Intra模式和其名字一样是用来单进程内部进行通信的,所以它的TransmitterReceiver里都是简单的函数调用。它和多线程的通信有什么区别呢?我们可以看到其他两个模式一个是通过共享内存来作为数据的中间站,一个是通过rtps协议来互相通信,而Intra模式是直接通过函数调用来传递参数进行信息传递。具体表现在只有IntraTransmitter会有一个IntraDispatcher的指针,其他两个模式的Transmitter写消息都是往共享内存或是通过发布者来写,而Intra则是直接调用分发器的OnMessage函数(其他模式这个函数都是在Receiver侧被通知器调用的)。

IntraTransmitter

  • 成员有channel_id_, IntraDispatcherPtr,分别记录了channel的id以及一个发送器。
  • Transmit函数也非常简单,就是调用了发送器的IntraDispatcher::OnMessage函数。所以一旦有消息被写入,就会直接调用相应的回调函数。

IntraReceiver

非常简单,没有特别需要说的,就是装模作样地实现了Receiver的各个函数。

IntraDispatcher

  • 代码位于cyber/transport/dispatcher/intra_dispatcher.h,继承自Dispatcher。它也是一个单例,在Dispatcher中记录了channel_id和其对应的处理函数的map(AtomicHashMap msg_listeners_),另外有一个独有的类ChannelChain。为何Intra模式要用这个独特的ChannelChain来额外多绕一圈,目前我的理解是ChannelChain提供了根据消息类型来索引回调函数的特殊的map,这个可能在某些用到Intra通信方式的地方会需要,另外因为Intra模式的特殊性,它是线程内部的通信所以通用的msg_listener因为需要self_idoppo_id这样一对拓扑结构来索引的方式对Intra不适用,所以Intra使用了ChannelChain来管理。
  • IntraDispatcher::AddListener函数和shm模式不同,IntraDispatcher::AddListener不会去调用基类Dispatcher::AddListener,但实际上只是比Dispatcher::AddListener多调用了一下IntraDispatcher独有的ChannelChain成员的AddListener函数,也就是说回调函数会在ChannelChainmsg_listener中都会记录一遍,对于这样的实现应该是为了提供一个统一的查询接口。
  • IntraDispatcher::OnMessage函数根据channel_id取得负责处理的函数(ListenerHandler),然后运行,只不过运行的函数实际上在IntraDispatcher::AddListener时已经经过了一层封装,运行的时候会根据当前消息类型在ChannelChain中的Run函数中判断是否要对消息进行处理,如果消息可以识别成MessageT,则直接调用,如果不行,则会将原始数据序列化成string然后再调用。

Shm模式

共享内存模式。大部分成员和函数都能在cyber/transport/shm/找到。共享内存基础建议看一下另一篇博客,更有助于理解。

ShmTransmitter

  • 成员有segment_, channel_id_, host_id_, notifier_,记录了channel的id和host的id,并且多了SegmentPtrNotifierPtr。一个ShmTransmitter对应一个channel_id,在被Enable时会通过SegmentFactoryNotiferFactory创建SegmentNotifier
  • 每次需要写数据(调用ShmTransmitter::Transmit函数)时,会先向segment_申请可写的Block(WritableBlockBlock),申请主要就是返回下一个可用Block的索引,这个过程是线程安全的(cyber中的协程),即每个线程都会拿到一个不同的索引号,所以写数据不会冲突。拿到Block后会调用message::SerializeToArray写入该msg,紧接着msg的内存位置会调用message::SerialTo写入对应的msg_info(这个是msg的一些信息,在cyber/transport/message/message_info.h,记录了该msg的发送者sender_idchannel_id等相关信息),然后在对应的Block中记录一下刚刚写入的msgmsg_info的size。最后会创建一个该条消息的可读信息ReadableInfo(记录了host_id_,Block的索引,channel_id),调用notifer_->Notify()函数来通知。具体信息请见下文。

ShmReceiver

同样没什么好说,就是普通Receiver的功能。

ShmDispatcher

除了提供AddListener的入口与Dispatcher基类记录的channel_idListenerHandlerBasePtr的map之外,主要功能就是记录了一个channel_id对应SegmentPtr的map,它保存了所有Shm模式的channel的信息,每当一个ShmTransmitterEnable时就会调用ShmDispatcherAddSegment在这个map中创建并记录一个Segment。`

Segment

  • SegmentFactory创建,负责管理一段共享内存,可以在cyber.pb.conf中指定类型,总共两种类型xsiposix(分别对应Linux两种共享内存机制System V和POSIX),默认为xsi
  • Segment中内存组织方式:
    | State | Block1 | Block2 | … | Blockn | block_buf1 | block_buf2 | … | block_bufn |。其中一个Block(在cyber/transport/shm/block.h)对应一个block_bufState的大小是conf_.ceiling_msg_size,n = conf_.block_num。内存的粒度是BlockBlock是一个用来保存每条msg的大小并提供线程安全的类,真正的msgmsg_info的内容都在对应的block_buf中。一个Block对应一个bufbuf真正记录了一条msg和其对应的msg_info
    1. PosixSegment
      • 共享内存的文件名shm_name_(万物皆文件)即channel_id,每个PosixSegment对应了一个channel_id,拥有相同的channel_idPosixSegment会映射到同一块共享内存,在打开共享内存时会把Segment中的几个指针成员都指向对应的共享内存。
      • PosixSegment::OpenOrCreate中,通过给shm_open函数指定O_CREAT | O_EXCL参数,如果不存在则创建,存在则报错然后直接调用PosixSegment::OpenOnly函数打开。
    2. XsiSegment
      • PosixSegment提供的方法类似,只不过成员由shm_name_变为了key_,实际上也是channel_id,只不过XsiSegment中的创建共享内存的方法shmget接受的参数是key_t而已,其内存管理和创建打开流程和PosixSegment几乎一样,这是另一种Linux中共享内存的管理方式,具体区别可见前文提到的文章。

NotifierBase

ShmTransmitter中另一个成员,它的两个子类ConditionNotifierMulticastNotifier实现了它的函数,它们都是系统中的单例,在Enable时由NotifierFactory创建或返回,至于创建的Notifier的类型由全局变量中的CyberConfig决定(cyber/conf/cyber.pb.conf中的transport_conf/shm_conf/notifier_type),默认为ConditionNotifierXxxNotifier::Listen函数负责监听,在ShmDispatcher::ThreadFunccyber/transport/dispatcher/shm_dispatcher.cc)中会被循环调用,ThreadFunc函数在ShmDispatcher::Init时会被创建一个单独的线程。当Listen函数返回true,则表示收到了通知,Listen函数返回true的通知会把收到的消息写入一个ReadableInfo中,然后ThreadFunc就会去解析这个ReadableInfo,拿到里面的channel_idblock_index,用这些信息调用ShmDispatcher::ReadMessageReadMessage函数会去ShmDispatcher的全局map中取出对应的Segment的对应Block,然后解码里面的信息并交给ShmDispatcher::OnMessage函数来处理。OnMessage函数则是去记录channel_idListenerHandlerBasePtr的mapmsg_listeners_中获取对应的ListenerHandler然后调用它的Run函数。前面介绍过Run函数会去通知监听对应信号的槽然后运行回调函数,回调函数最终调用到的就是在创建Receiver时传入的匿名函数,这些回调函数会通过上层的DataDispatcher发送消息。
1. ConditionNotifier
- 它会创建并管理一段共享内存(以"/apollo/cyber/transport/shm/notifier"的hash值为key_),里面保存了一个Indicator结构(包含ReadableInfo数组,一个seqs数组以及一个线程安全的next_seq值)。它相当于又用了一段共享内存来达到通知的目的。
- ConditionNotifier提供的Notify函数就是在Indicator即管理的内存中的infos数组的next_seq位置写入新收到的ReadableInfo并在seqs数组中记录当前的seq值,这样子设置完后必然需要一个周期性运行的协程来去查看这个队列是否有新的消息,这个函数就是ConditionNotifier::Listen函数。
- ConditionNotifier::Listen函数在ShmDispatcher::ThreadFunccyber/transport/dispatcher/shm_dispatcher.cc)中会被循环调用,ThreadFunc函数在ShmDispatcher::Init时会被创建一个单独的线程。Listen函数就是周期性地区检查Indicator结构里(其实就是这块共享内存里)是否有新的ReadableInfo,如果有的话就返回true告诉ThreadFunc。剩下的就是上述Notifier通用的过程。
2. MulticastNotifier
- MulticastNotifier使用了广播模式,可以在cyber/conf/cyber.pb.conf配置transport_conf/shm_conf中设置shm模式使用multicast并设置相应的shm_locator中的ip和port。默认是"239.255.0.100::8888",这个被硬编码在MulticastNotifier::Init中,当然你也可以通过配置文件修改它。
- 它和ConditionNotifier通过新建共享内存来通知不同的就是通过socket发送消息的方式来实现通知MulticastNotifier::Notify(),监听者监听配置文件里的ip和端口,一旦有消息传到一开始定义的地址,则MulticastNotifier::Listen返回true并写入ReadableInfo,剩下的也是通用过程。

RTPS模式

RTPS协议是针对视频流新推出的网络协议,增加了控制信息。相比于shm模式,它多了qos控制,并且在读者和写者里都有一段历史消息的缓存。cyber使用了eprosima-fast-rtps的rtps实现。

RTPS协议简介

百度Apollo系统学习-Cyber RT 通信-底层_第1张图片

  • Domain(域)定义了一个独立的通信平面,多个域是同时独立存在的。一个域包含多个Participant(参与者),每个参与者包含多个PublisherSubscriber,也包含多个ReaderWriterEndpoint(发布订阅者和读写者是上层与下层的关系,你可以直接操作读写者,也可以使用封装它们后的发布订阅者,见下图),参与者用这些Endpoint来收发消息。Domain主要是用于创建、管理、销毁高层的Participant
  • 通信是围绕着Topic(主题)进行的,Topic定义了要通信的数据内容,Topic不属于任何Participant,所有关注该TopicParticipant都监测其数据变化,并保持最新。
  • 通信的单元叫做Change,表示Topic的一次更新。Endpoint会把近期的Change缓存在各自的缓存结构History中。
  • RTPS中,Participant, Publisher, Subscriber都可以监听变化并调用相应的回调函数。一般来说,对参与者变化的监听可以用来全局管理整个拓扑结构,当发现节点变化时通知剩余节点(比如cyber的服务发现机制最顶层结构TopologyManager的工作),对订阅者变化的监听可用来接收消息然后通知(比如cyber服务发现机制第二层结构Manager的工作)。
    百度Apollo系统学习-Cyber RT 通信-底层_第2张图片
  • 特别需要注意的是RTPS结构里的发布者Publisher和订阅者Subscriber,它们是比WriterReader(上图中的RTPSWriterRTPSReader)更高层的结构,其实cyber的整个通信架构就和rtps的模式差不多,发布者和订阅者都可以指定各自的回调函数(即发送消息的时候调用发布者的回调函数,接受消息的时候调用订阅者的回调函数)。cyber的rtps不会涉及RTPSWriterRTPSReader的调用,主要是使用PublisherSubscriber来完成功能,后面的介绍可以具体见到方法。
  • 当某个Participant通过Writer发布一个Change时,会经历以下过程:
    1. Change添加到WriterHistory
    2. Writer通知所有它知道的Reader
    3. 所有感兴趣(subscribed订阅的)的Reader都请求该Change
    4. Reader接收Change并添加到ReaderHistory

RtpsTransmitter

它可以视为RTPS协议中的Writer。特有成员有一个ParticipantPtr participant_和一个eprosima::fastrtps::Publisher* publisher_。这里的participant_是在RtpsTransmitter被实例化时传入的,在Transport单例被创建的时候创建。

  • participant_的创建,在Transport::CreateParticipant被创建,名字由全局变量中的HostName加上ProcessId组成,发送端口为11512。
  • Enable函数,主要是创建另外一个成员publisher_,读入配置文件中的channel_nameqos_profile然后在participant_里创建发布者(注意创建的时候没有指定发布者的回调函数)。
  • Transmit函数,比较简单,主要就是把参数msg, msg_info读入并且封装成rtps协议具体实现中的消息格式UnderlayMessageeprosima::fastrtps::rtps::WriteParams,最后调用publisher_write函数发送封装后的消息。可以看到相比于shm和intra模式,rtps模式因为直接调用了底层的库,所以屏蔽了很多细节,我们调用发布者发送消息后完全不用管怎么通知订阅者了(shm需要常驻线程NotifierBase::Listen去监视,intra是直接用分发器去调用),直接默认能够按照qos的要求收到消息即可。

RtpsReceiver

和其他模式类似,Enable也是通过RtpsReceiver::AddListener注册回调。这里需要注意一下RTPS模式的通信会在ServiceClient中创建Receiver时被创建,或者在普通WriterReader的默认HYBRID模式中被用到。

RtpsDispatcher

这个也是在Transport创建时创建并且设置了ParticipantPtr。它有一个channel_id对应Subscriber的map,此处的Subscriber包含了RTPS协议中的订阅者eprosima::fastrtps::Subscriber*和一个回调函数SubListenerPtr(可见发布时不回调,收到了消息回调)

  • AddListener和shm模式类似,也是先调用基类的Dispatcher::AddListener注册回调函数(信号和槽等),然后调用AddSubscriber来向成员map里加入新的Subscriber(shm调用的则是AddSegment,可见全局的msg_listener都需要注册,然后就会调用不同模式个性化的内容)。
  • AddSubscriber函数会先检查map中是否已经有了该channel_id,如果没有的话根据配置(主要时qos等rtps特有的)构造一个RTPS的订阅者,并绑定回调函数为Rtps::OnMessage,然后向当前participant_里加入这个订阅者。最后向map中注册这个包含了RTPS协议中的订阅者eprosima::fastrtps::Subscriber*和该回调函数SubListenerPtrSubscriber结构。
  • OnMessage函数和其他的也一样,就是调用一下msg_listeners中对应channel_idListenerHandler::Run
  • 总觉得少了什么,对,因为我们这次不用管怎么去通知到回调函数了。实际上当底层的订阅者收到消息后它会自动去调用它关联的那个回调函数(在AddSubscriber时创建),那个回调函数实际上运行的就是OnMessage,然后就能调用到msg_listener中的又一个回调函数了。另外我们也可以看到,通过rtps协议,我们能给cyber赋能qos保障。

Cyber中rtps的配置

QoS

cyber中的rtps配置最终都会转变为fastrtps中的设置,所以具体的含义请翻阅fastrtps文档。
cyber默认的配置位于cyber/transport/qos/qos_profile_conf.cccyber/proto/qos_profile.proto,为

 QosHistoryPolicy::HISTORY_KEEP_LAST,
 1,  //depth
 QOS_MPS_SYSTEM_DEFAULT,//0
 QosReliabilityPolicy::RELIABILITY_RELIABLE,
 QosDurabilityPolicy::DURABILITY_VOLATILE

目前cyber中的Writer在创建时都是只传递channel_name参数,也就是说writer都是采用了这套默认的配置。其中除了第三条以外其他的都和fastrtps有着明显的对应,而第三条mps实际上最终转换成了fastrtpstimes.heartbeatPeriod.seconds&fraction即心跳周期,用seconds和fraction字段设置秒和毫秒,默认3s。用在Writer上,用来周期性的检查对方有没有收到数据。大量的数据分片会降低传输速度,降低心跳周期会增加网络中消息的数量,但是同时,当数据分片丢失的时候,会加速系统响应。具体如何配置请见专栏中cyber实操的博客。

Participant

配置位于cyber.pb.conf::transport_conf.participant_attr,里面主要包含了:

lease_duration: 12 # 多长时间/s没收到writer的消息就认为已经不alive了
announcement_period: 3 # 从publisher发送存活信号的间隔周期,cyber建议这个值和lease_duration差至少30%以免造成错误
domain_id_gain: 200 # fastrtps中的domainId,只有同一domainId才能互相通信
port_base: 10000 # 端口配置

遗漏的地方

实际上除了这些,cyber中还有一个需要配置的地方,那就是cyber/conf/cyber.pb.conf::transportconf.resourcelimit,这里其实配置的是HybridTrainsmitter&Receiver里的history大小,当然使用的前提是rtps的qos配置必须QosDurabilityPolicy::DURABILITY_TRANSIENT_LOCAL才行(默认不是,所以一般直接忽略就行)。

番外篇Hybrid

这个其实不是单纯的一种通信方式,通过前文的学习读者应该也知道,Hybrid是实际情况下通信的一个默认模式,而且因为ReaderWriter在创建TransmitterReceiver时没有指定模式,所以Hybrid才是实际环境使用最多的模式。现在我们就来看一下Hybrid模式到底是怎么通信的。建议学习这部分之前再温习一下rtps的实现,cyber的Hybrid模式很多地方几乎就是照着rtps模式做了一遍。

HybridTransmitter

  • 我们通过它的构造函数来了解它的结构。
    • InitMode,给成员mapping_table_map设置对应的通信模式,mapping_table_是一个配置表,里面指定了在不同情况下用什么方式通信(本意可能是让这个可配置),通信方式的指定在cyber/proto/transport_conf.proto中的CommunicationMode,就是INTRA,SHM,RTPS三种。它们对应三种情况:
      1. SAME_PROC:同一进程,默认使用INTRA模式通信,这种情况发生在同一个module内的通信,比如同一个module里不同的component相互通信,或是同一个component里自我通信(几乎没有人会蠢到这样做吧)
      2. DIFF_PROC:不同进程但在同一host,默认使用SHM,这种情况基本上就是单机上不同module互相通信时会用到,也是用得最多的模式
      3. DIFF_HOST:不同host上的不同进程,默认使用RTPS,这只有在不同host上通信时才用到
    • ObtainConfig,如果有的话读取全局变量里配置的通信模式(位于cyber/conf/cyber.pb.conf),其实也是在设置mapping_table_,这就意味着你可以通过运行时读入配置来人为指定通信方式(覆盖InitMode设置的那些),主要为了以后的扩展。
    • InitHistory,根据配置里的qos,设置缓存history_长度,缓存结构Historycyber/transport/message/history.h。可见HybridTransmitter学RTPS也设置了写侧的消息缓存。需要注意这个缓存只是为RTPS模式设置的,所以只有当这个消息有qos本地缓存要求时才真正有意义。
    • InitTransmitters,关键的函数,它给CommunicationMode中指定的通信模式(目前其实就是INTRA,SHM,RTPS三种)都创建了一个Transmitter,并存放在transmitters_,每种模式对应一个Transmitter
    • InitReceivers,为transmitters_里所有的发送者创建一个空的set,目的是存储对应的读者Endpoint的id,后面Enable的时候会再去填满它。
  • Enable(const RoleAttributes& opposite_attr)函数,先调用GetRelation来判断这个opposite节点(对Transmitter来说就是Receiver)和自身节点属于哪种关系(如果和自己channel_name都不同那就是NO_RELATION,如果和自己的host_ip不一样那就是DIFF_HOST,如果和自己只是process_id不一样那就是DIFF_PROC,都一样那就是SAME_PROC),这个关系很明显就是用来选择Transmitter的。然后取出opposite节点的id,并根据得到的关系向receivers_map中相应的Transmitter的set中加入该id,并调用XxxTransmitter::Enable()将该Transmitter打开,最后调用TransmitHistoryMsg,这一步的目的是每次开启的时候都把之前没发出去的并且有qos本地缓存要求的消息发送掉,TransmitHistoryMsg函数调用RtpsTransmiter::Transmit函数把这些数据发掉。
  • Transmit,先把消息放入缓存,然后用transmitter_中每一个之前被Enable的发送器发一遍,可以看到一条消息最后到底选择哪一种发送方式是在HybridTransmitter::Enable(opposite_attr)中根据读者opposite的host、process等性质来选择的(这个部分会在服务发现的博客中介绍)。
  • 所有cyber管理的Node都是这么做的,它们会在Hybrid模式中挑选合适的(或指定的)传输方式,需要注意的是,XxxTransmitter只有有对等的XxxReceiver加入并开始接受消息了才会被Enable

HybridReceiver

这个的实现和HybridTransmitter几乎是对称的,只不过它保存的是不同模式的Receiver,在HybridReceiver::Enable的时候会将这些ReceiverEnable起来(AddListener注册回调,注意Hybrid本身没有对应的Dispatcher,所以不同模式还是会注册到自己对应的XxxDispatcher中)。不同的模式的Receiver各自有相应的消息通知机制和自己的回调函数,它们收到以后会自行调用。它和HybridTransmitter一个是被动接收,一个是主动发送,但结构方式等都是类似的。同样的,HybridReceiver也有一条缓存history队列,用法和HybridTransmitter类似。

通信部分底层总结

至此我们介绍完了cyber的通信底层,可以看到整个底层的设计和RTPS很像,但因为实际通信方式的多样性和复杂性导致代码层次不是很清晰,也有很多不必要的冗余。在实现中也用到了很多C++11中的新特性和方法,所以在对照代码阅读时需要较高的编程基础。
本想总结一下完整的通信流程,但发现内容实在太多太乱,并且因为大量的非线性调用方式没法清晰地画出流程,所以还是请读者尽量能对照着源码来看,遇到问题可以查阅博客。

通信部分整体总结

在这里我们来通过两个问题作为这部分的总结。

  1. Writer是如何写消息的?
    • 会调用不同通信方式的XxxTransmitter::Transmit来往相应通信方式的合适位置写入数据。
  2. Transmitter或者说Writer写入的数据是如何通知Receiver或者说Reader的?
    • Transmit函数最后,不同通信模式都会去调用提醒函数并最终调用到XxxDispatcher::OnMessage()。Intra是直接调用IntraDispatcher::OnMessage();Shm是调用特有的NotifierBase来通知,最后还是调用到ShmDispatcher::OnMessage;RTPS则是通过库内部的实现通知到subscriber,然后subscriber会去调用RtpsDispatcher::OnMessage()
    • XxxDispatcher::OnMessage()函数实际上就是去各自通信方式的XxxDispatcher全局单例中的msg_listeners_成员中查找一个对应channel_idListenerHandler并且调用它的Run函数,Run函数首先从接受到的消息信息msg_info中找到发送者的id的HashValue(),这个就是oppo_id,用它从ListenerHandler的成员signals_map中找到对应的信号,然后使用该信号来通知所有监听它的槽并调用它们。
    • 这些槽又是什么呢,它们是XxxReceiver::OnNewMessage(),它们是在XxxReceiver::Enable(const RoleAttributes& opposite_attr)的时候(ReceiverTransmitterEnable都是在服务发现博客中介绍的JoinTopology()函数中被调用的,也就是说cyber的服务发现机制决定了它们的开关)被XxxDispatcher::AddListener(const RoleAttributes& opposite_attr)加入到相应的ListenerHandler中的,它会和对应的信号进行绑定以便之后能被调用到。至此,Receiver收到了消息并可以调用自己的一个回调函数。
      • 补充:XxxReceiver::OnNewMessage()是什么?它是每个XxxReceiver对应的一个回调函数msg_listener,它在cyber/node/reader_base.h中的ReceiverMessage::GetReceiver()中会随同Receiver被创建出来(Receiver是在每个Reader::Init()中被创建的),它主要的任务就是调用DataDispatcher::Dispatch()
    • Receiver自己的回调函数会调用到DataDispatcher::Dispatch()DataDispatcher::Dispatch()会向该channel所有等待的buffer中放入新来的数据并且通知所有以该数据为M0的DataVisitor里的通知器,这些组件的协程(在Component::Initialize()最后的Scheduler::CreateTask中创建)被唤醒并被调度器调度运行,该协程就是调用DataVisitor::TryFetch()从buffer里取出M0以及其他种类数据的缓存数据,整合之后把整合后的数据放入回调函数(每个组件的Proc函数)运行。

少了些什么

我们在通信章节主要介绍的是Reader & Writer,但实际上cyber中还有一类重要的通信方式Service & Client,并且在分析代码的时候也特意忽略了一个重要的函数JoinTheTopology()(在ReaderWriter初始化的时候都会调用到)。这其实牵扯到cyber中的服务发现机制,因为Service & Client和它关系紧密所以将它们放在一起。具体有关服务发现机制的介绍,请关注专栏里的相关博客。

参考链接:

Apollo 3.5 Cyber 多進程通訊模塊 - Transport (Shared Memory篇)
Apollo 3.5 Cyber 多進程通訊模塊 - Transport (Intra 和 rtps 篇)
【FastRTPS】RTPS协议简介、创建第一个应用
【FastRTPS】对象和数据结构

你可能感兴趣的:(百度Apollo学习,自动驾驶,c++)