上一篇文章介绍了下图中的除Transport部分,本文就来深入解析Transport即cyber通信模块的底层机制。
我们从cyber/node/writer.h
中的Writer
写数据切入底层。
Transport
也是一个全局单例,Writer
中真正负责写数据的是成员transmitter
(由Transport
创建),代码位于cyber/transport/transport.h
。下面看一下它几个重要的成员。
- xxxDispatcherPtr
,可以看到总共有Intra, Shm, Rtps
这三种dispatcher,也就是说总共有三种通信方式。在Transport
被创建时会把这三个Dispatcher
的单例也一并创建出来。这里特别要注意的是RTPS
模式会一并创建一个Participant
,具体在RTPS模式会介绍。
- 通信方式是在Transmitter
和Receiver
被创建时指定的(分别在Writer
,Reader
,Client
和Service
的Init()
中),但实际上Reader
和Writer
在创建它们的时候没有指定模式(只在测试用例中有指定,Client
和Service
用RTPS模式),所以它们实际上都是用的默认Hybrid模式,Hybrid其实是把消息用多种通信模式各发一遍,后面会具体介绍。
- notifier_
成员只有shm模式会用到(注意不要和上层的Notifier搞混),participant_成员只有rtps模式会用到,只不过所有这些成员都会在初始化时一并初始化。
- Transport
主要提供了两个函数:Transport::CreateTransmitter & Transport::CreateReceiver
,前一个提供写数据的类,后者提供读数据的类,这一部分我们主要关注前者。
Transmitter & Receiver
的基类,可谓是万物的起始点了,它里面有三个成员,一个是bool enabled_
用来标记是否被启用;一个是Identity id_
这个就和身份证一样,每个Endpoint
的Identity
都不同,后面的Receiver
等也是用这个来进行标识;最后一个RoleAttributes attr_
用来记录从配置文件(不同组件Component
的配置文件里,具体见模块加载介绍博客)里读取的配置,全局变量里的HostName
和ProcessId
以及Identity
的HashValue
。
真正的数据写者的基类,它继承了Endpoint
类(主要负责最最基础的通信消息格式记录,记录的配置在cyber/proto/role_attributes.proto
),继承它的四个子类主要实现了它的Transmit(const MessagePtr& msg, const MessageInfo& msg_info)
函数,这也是Writer
直接调用的函数(Writer
调用的是Transmitter::Transmit(const MessagePrt& msg)
函数,它会设置一下msg_info
的seq_num
并把这个transport事件加入event,然后就调用子类实现的Transmit
函数),通过传入一条消息即可以完成数据写入任务。我们接下来就通过看这四个子类的不同实现来理解底层的通信机制。
DataDispatcher
联系的类,同样继承了Endpoint
类。继承它的四个子类和Transmitter
的四个分别对应,它们主要的工作就是向各自的分发器XxxDispatcher
添加监听器(通过Enable
函数实现,主要是绑定回调函数,将自己的OnNewMessage
函数注册到XxxDispatcher
)。Receiver
的回调函数OnNewMessage
就是去调用一下自己的函数指针成员msg_listener
,这个函数指针在一开始创建Receiver
的时候由参数指定,Receiver
真正创建的地方是cyber/node/reader_base.h
中ReceiverManager::GetReceiver
函数进而调用的Transport::CreateReceiver
(会在Reader::Init
的时候被调用,Reader
中没有指定模式,所以是默认HYBRID
模式,在Service
和Client
初始化时也会以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
中加入这个DISPATCH
的TransportEvent
,然后调用上层的DataDispatcher::Dispatch
函数(参数是reader_attr.channel_id
和msg
),再加入这个NOTIFY
的TransportEvent
。中间调用DataDispatcher::Dispatch
是个非常非常关键的地方,在这里上层和底层最终完成了闭环。也就是说当底层Receiver
的回调函数msg_listener
收到消息被调用时,上层的分发器DataDispatcher
会把收到的来自底层的消息发到等待的消息缓存里然后调用上层的通知器DataNotifier
去唤醒实际Component
的Proc
协程来处理这些消息(DataDispatcher::Dispatch
函数相关内容请看通信上层博客)。Receiver
提供了Enable
和Disable
函数用来开关,开的时候就往自己的Dispatcher
中AddListener
,关则是RemoveListener
。消息分发器,三种通信模式都有自己的Dispatcher
单例。每个模式的每个XxxReceiver
中都有一个XxxDispatcherPtr
指向这个单例。
Dispatcher
主要是记录了一个channel_id
和对应ListenerHandlerBasePtr
的map,提供了AddListener
和RemoveListener
函数来向这个全局map中该channel对应的回调函数的管理结构ListenerHandlerBase
注册或删除回调函数。需要注意,因为Dispatcher
是每个模式的全局单例,所以里面的map和函数都有线程安全的保障。
AddListener
各个通信模式会有一些差别,但做的工作比较类似(Intra很不一样),都是先把Receiver::OnNewMessage
进行一个封装,因为不同通信模式的数据格式不一样(比如shm模式下是Block),所以会在封装的函数里把这些数据重新组织成Receiver::OnNewMessage
接收的MessagePtr
和MessageInfo
,然后调用基类Dispatcher::AddListener
函数(Intra模式例外,它不会去调用)。基类中的AddListener
函数会去mapmsg_listeners_
中找对应该channel_id
的ListenerHandlerBasePtr
,如果找不到就创建一个新的并加入map。最后调用一下该ListenerHandler
的Connect
函数来将刚刚封装的函数注册进去。
我们知道Receiver
会在其对应的msg_listener
被调用时通知上层完成消息的传递,我们也知道msg_listener
是在Dispatcher::AddListener
中被真正加入到各自的全局线程安全map中对应的ListenerHandler
的,但msg_listener
是怎么被调用到的呢?这个会涉及通信的最底层回调机制,会在下面介绍。
在某个Receiver
被创建时(cyber/transport/transport.h
中Transport::CreateReceiver
),只要不是HYBRID
模式都会立马调用Receiver::Enable
函数,Receiver::Enable
函数会调用其对应的XxxDispatcher::AddListener
函数,除了Intra模式外最后都会调用到Dispatcher::AddListener
函数然后调用找到的ListenerHandler::Connect
函数,Intra模式不会去调用基类的AddListener
但还是最后会调用到ListenerHandler::Connect
函数。现在我们就以这个函数为入口看一下底层的底层是什么样的。
信号与槽机制是Qt编程的基础,原本是用在GUI编程中的一种通信方式,信号是在特定情况下被发射的事件,槽就是对信号响应的函数。信号与槽的关联就是通过Connect函数。
需要注意的地方就是一个信号可以连接多个槽,多个信号可以连接同一个槽。
位于cyber/base/signal.h
,有一个SlotList
成员,记录了关联在该信号下的所有槽,Signal
中的函数都是线程安全的。
Connect
函数,为某个回调函数创建一个Slot
共享指针,然后加入到自己的槽列表并返回一个Connection
关联实例。提供Disconnect
函数,接收一个Connection
参数,从槽列表中找到该槽,然后将槽的标记置为false并从列表中删除。()
操作符,也就是说当像这样调用时signal(msg, msg_info)
,就会对该信号对应的所有槽(所有关联的回调函数)进行一次调用,这其实就是通知所有监听该信号的回调函数。位于cyber/base/signal.h
,它其实就保存了一个回调函数std::function
和一个标记bool connected_
,提供一个Disconnect
函数用来将标记置为false。它也和信号一样重载了()
操作符,当被调用时就会去运行cb_
函数。
保存了一个信号的指针一个槽的指针,一个Connection
实例就代表了一条关联关系。通过Slot
的标记位显示是否处于关联状态。
位于cyber/transport/message/listener_handler.h
,继承自ListenerHandlerBase
,是Dispatcher
中的map保存的回调函数的管理结构(就是保存最底层信号与槽的管理结构)。通过Dispatcher
保存的msg_listeners_
用channel_id
索引。它的成员有一个base::Signal
和三张map。signal_
对应一种消息类型。三张map其实对应着不同的索引方式,首先了解两种ListenerHandler
用的key,第一个self_id
,指的是自身的Endpoint
中的Identity
的Hash_Value
,记录在每个Endpoint
的RoleAttributes attr_
中;第二个是oppo_id
指的是对方的Endpoint
里的id。为什么会有这个区别呢,主要是Transmitter
和Receiver
在Enable
的时候,有时候会指定读者或写者,也就是说不光要写或读这个channel的消息,还要求写给特定的读者或读特定的写者,所以这时候就会有oppo_id
这个值来指出对方。特别需要注意的是一般都是会用带oppo的Enable
函数,因为在cyber的服务发现机制中Reader::&Writer::JoinTheTopology
(cyber/node/reader.h&writer.h
,会在cyber服务发现博客中具体介绍)会给每个Transmitter
和Receiver
指定对应的channel中所有的Reader
和Writer
为oppo,可以说不带参数的Enable
函数几乎只是在测试系统时和RTPS
模式中会被用到。当然还是要狠狠吐槽一下这样的实现,容易让人摸不着头脑,下层实现影响上层封装,正式功能和测试都混杂在一起。
回过头看这三张map,第一张std::unordered_map
通过self_id
来查找一条关联关系Connection
;第二张std::unordered_map
,通过oppo_id
来找相应的SignalPtr
;第三张std::unordered_map
,通过两次索引,先oppo_id
找到一张map,再通过self_id
索引一条关联关系,该map实际上记录所有该channel相关的关联关系。
signal_
和第一张map作为一个整体,一般是在测试用例和RTPS
模式下使用(调用无参数的Enable
),只有一种信号,通过self_id
索引关系。后两张map作为另一个整体,在剩下的其他情况下使用(调用有参数的Enable
),一个channel_id
对应了一个ListenerHandler
,后面2张map记录了所有该channel的信号以及相应的关联关系,通过oppo_id
和self_id
一并索引关系。需要注意因为AddListener
是在Receiver
里调用的,所以self_id
是receiver
也就是读者的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模式和上层的IntraReader, IntraWriter
没有任何关系,上层的Intra+是为虚拟模式服务的,根本就不会调用到这里。Intra模式和其名字一样是用来单进程内部进行通信的,所以它的Transmitter
和Receiver
里都是简单的函数调用。它和多线程的通信有什么区别呢?我们可以看到其他两个模式一个是通过共享内存来作为数据的中间站,一个是通过rtps协议来互相通信,而Intra模式是直接通过函数调用来传递参数进行信息传递。具体表现在只有IntraTransmitter
会有一个IntraDispatcher
的指针,其他两个模式的Transmitter
写消息都是往共享内存或是通过发布者来写,而Intra则是直接调用分发器的OnMessage
函数(其他模式这个函数都是在Receiver
侧被通知器调用的)。
channel_id_, IntraDispatcherPtr
,分别记录了channel的id以及一个发送器。Transmit
函数也非常简单,就是调用了发送器的IntraDispatcher::OnMessage
函数。所以一旦有消息被写入,就会直接调用相应的回调函数。非常简单,没有特别需要说的,就是装模作样地实现了Receiver
的各个函数。
cyber/transport/dispatcher/intra_dispatcher.h
,继承自Dispatcher
。它也是一个单例,在Dispatcher
中记录了channel_id
和其对应的处理函数的map(AtomicHashMap msg_listeners_
),另外有一个独有的类ChannelChain
。为何Intra模式要用这个独特的ChannelChain
来额外多绕一圈,目前我的理解是ChannelChain
提供了根据消息类型来索引回调函数的特殊的map,这个可能在某些用到Intra通信方式的地方会需要,另外因为Intra模式的特殊性,它是线程内部的通信所以通用的msg_listener
因为需要self_id
和oppo_id
这样一对拓扑结构来索引的方式对Intra不适用,所以Intra使用了ChannelChain
来管理。IntraDispatcher::AddListener
函数和shm模式不同,IntraDispatcher::AddListener
不会去调用基类Dispatcher::AddListener
,但实际上只是比Dispatcher::AddListener
多调用了一下IntraDispatcher
独有的ChannelChain
成员的AddListener
函数,也就是说回调函数会在ChannelChain
和msg_listener
中都会记录一遍,对于这样的实现应该是为了提供一个统一的查询接口。IntraDispatcher::OnMessage
函数根据channel_id
取得负责处理的函数(ListenerHandler
),然后运行,只不过运行的函数实际上在IntraDispatcher::AddListener
时已经经过了一层封装,运行的时候会根据当前消息类型在ChannelChain
中的Run
函数中判断是否要对消息进行处理,如果消息可以识别成MessageT
,则直接调用,如果不行,则会将原始数据序列化成string然后再调用。共享内存模式。大部分成员和函数都能在cyber/transport/shm/
找到。共享内存基础建议看一下另一篇博客,更有助于理解。
segment_, channel_id_, host_id_, notifier_
,记录了channel的id和host的id,并且多了SegmentPtr
和NotifierPtr
。一个ShmTransmitter
对应一个channel_id
,在被Enable
时会通过SegmentFactory
和NotiferFactory
创建Segment
和Notifier
。ShmTransmitter::Transmit
函数)时,会先向segment_
申请可写的Block(WritableBlock
即Block
),申请主要就是返回下一个可用Block
的索引,这个过程是线程安全的(cyber中的协程),即每个线程都会拿到一个不同的索引号,所以写数据不会冲突。拿到Block
后会调用message::SerializeToArray
写入该msg
,紧接着msg
的内存位置会调用message::SerialTo
写入对应的msg_info
(这个是msg
的一些信息,在cyber/transport/message/message_info.h
,记录了该msg
的发送者sender_id
、channel_id
等相关信息),然后在对应的Block
中记录一下刚刚写入的msg
和msg_info
的size。最后会创建一个该条消息的可读信息ReadableInfo
(记录了host_id_
,Block的索引,channel_id
),调用notifer_->Notify()
函数来通知。具体信息请见下文。同样没什么好说,就是普通Receiver
的功能。
除了提供AddListener
的入口与Dispatcher
基类记录的channel_id
和ListenerHandlerBasePtr
的map之外,主要功能就是记录了一个channel_id
对应SegmentPtr
的map,它保存了所有Shm模式的channel
的信息,每当一个ShmTransmitter
被Enable
时就会调用ShmDispatcher
的AddSegment
在这个map中创建并记录一个Segment
。`
SegmentFactory
创建,负责管理一段共享内存,可以在cyber.pb.conf
中指定类型,总共两种类型xsi
和posix
(分别对应Linux两种共享内存机制System V和POSIX),默认为xsi
。Block
(在cyber/transport/shm/block.h
)对应一个block_buf
,State
的大小是conf_.ceiling_msg_size
,n = conf_.block_num
。内存的粒度是Block
,Block
是一个用来保存每条msg
的大小并提供线程安全的类,真正的msg
和msg_info
的内容都在对应的block_buf中。一个Block
对应一个buf
,buf
真正记录了一条msg
和其对应的msg_info
。
shm_name_
(万物皆文件)即channel_id
,每个PosixSegment
对应了一个channel_id
,拥有相同的channel_id
的PosixSegment
会映射到同一块共享内存,在打开共享内存时会把Segment
中的几个指针成员都指向对应的共享内存。PosixSegment::OpenOrCreate
中,通过给shm_open
函数指定O_CREAT | O_EXCL
参数,如果不存在则创建,存在则报错然后直接调用PosixSegment::OpenOnly
函数打开。PosixSegment
提供的方法类似,只不过成员由shm_name_
变为了key_
,实际上也是channel_id
,只不过XsiSegment
中的创建共享内存的方法shmget
接受的参数是key_t
而已,其内存管理和创建打开流程和PosixSegment
几乎一样,这是另一种Linux中共享内存的管理方式,具体区别可见前文提到的文章。ShmTransmitter
中另一个成员,它的两个子类ConditionNotifier
和MulticastNotifier
实现了它的函数,它们都是系统中的单例,在Enable
时由NotifierFactory
创建或返回,至于创建的Notifier的类型由全局变量中的CyberConfig
决定(cyber/conf/cyber.pb.conf
中的transport_conf/shm_conf/notifier_type
),默认为ConditionNotifier
。XxxNotifier::Listen
函数负责监听,在ShmDispatcher::ThreadFunc
(cyber/transport/dispatcher/shm_dispatcher.cc
)中会被循环调用,ThreadFunc
函数在ShmDispatcher::Init
时会被创建一个单独的线程。当Listen
函数返回true,则表示收到了通知,Listen
函数返回true的通知会把收到的消息写入一个ReadableInfo
中,然后ThreadFunc
就会去解析这个ReadableInfo
,拿到里面的channel_id
和block_index
,用这些信息调用ShmDispatcher::ReadMessage
。ReadMessage
函数会去ShmDispatcher
的全局map中取出对应的Segment
的对应Block
,然后解码里面的信息并交给ShmDispatcher::OnMessage
函数来处理。OnMessage
函数则是去记录channel_id
和ListenerHandlerBasePtr
的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::ThreadFunc
(cyber/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协议是针对视频流新推出的网络协议,增加了控制信息。相比于shm模式,它多了qos控制,并且在读者和写者里都有一段历史消息的缓存。cyber使用了eprosima-fast-rtps的rtps实现。
Domain
(域)定义了一个独立的通信平面,多个域是同时独立存在的。一个域包含多个Participant
(参与者),每个参与者包含多个Publisher
和Subscriber
,也包含多个Reader
和Writer
的Endpoint
(发布订阅者和读写者是上层与下层的关系,你可以直接操作读写者,也可以使用封装它们后的发布订阅者,见下图),参与者用这些Endpoint
来收发消息。Domain
主要是用于创建、管理、销毁高层的Participant
。Topic
(主题)进行的,Topic
定义了要通信的数据内容,Topic
不属于任何Participant
,所有关注该Topic
的Participant
都监测其数据变化,并保持最新。Change
,表示Topic
的一次更新。Endpoint
会把近期的Change
缓存在各自的缓存结构History
中。Participant, Publisher, Subscriber
都可以监听变化并调用相应的回调函数。一般来说,对参与者变化的监听可以用来全局管理整个拓扑结构,当发现节点变化时通知剩余节点(比如cyber的服务发现机制最顶层结构TopologyManager
的工作),对订阅者变化的监听可用来接收消息然后通知(比如cyber服务发现机制第二层结构Manager
的工作)。Publisher
和订阅者Subscriber
,它们是比Writer
和Reader
(上图中的RTPSWriter
和RTPSReader
)更高层的结构,其实cyber的整个通信架构就和rtps的模式差不多,发布者和订阅者都可以指定各自的回调函数(即发送消息的时候调用发布者的回调函数,接受消息的时候调用订阅者的回调函数)。cyber的rtps不会涉及RTPSWriter
和RTPSReader
的调用,主要是使用Publisher
和Subscriber
来完成功能,后面的介绍可以具体见到方法。Participant
通过Writer
发布一个Change
时,会经历以下过程:
Change
添加到WriterHistory
Writer
通知所有它知道的Reader
Reader
都请求该Change
Reader
接收Change
并添加到ReaderHistory
它可以视为RTPS协议中的Writer。特有成员有一个ParticipantPtr participant_
和一个eprosima::fastrtps::Publisher* publisher_
。这里的participant_
是在RtpsTransmitter
被实例化时传入的,在Transport
单例被创建的时候创建。
participant_
的创建,在Transport::CreateParticipant
被创建,名字由全局变量中的HostName
加上ProcessId
组成,发送端口为11512。Enable
函数,主要是创建另外一个成员publisher_
,读入配置文件中的channel_name
和qos_profile
然后在participant_
里创建发布者(注意创建的时候没有指定发布者的回调函数)。Transmit
函数,比较简单,主要就是把参数msg, msg_info
读入并且封装成rtps协议具体实现中的消息格式UnderlayMessage
和eprosima::fastrtps::rtps::WriteParams
,最后调用publisher_
的write
函数发送封装后的消息。可以看到相比于shm和intra模式,rtps模式因为直接调用了底层的库,所以屏蔽了很多细节,我们调用发布者发送消息后完全不用管怎么通知订阅者了(shm需要常驻线程NotifierBase::Listen
去监视,intra是直接用分发器去调用),直接默认能够按照qos的要求收到消息即可。和其他模式类似,Enable
也是通过RtpsReceiver::AddListener
注册回调。这里需要注意一下RTPS模式的通信会在Service
和Client
中创建Receiver
时被创建,或者在普通Writer
和Reader
的默认HYBRID
模式中被用到。
这个也是在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*
和该回调函数SubListenerPtr
的Subscriber
结构。OnMessage
函数和其他的也一样,就是调用一下msg_listeners
中对应channel_id
的ListenerHandler::Run
。AddSubscriber
时创建),那个回调函数实际上运行的就是OnMessage
,然后就能调用到msg_listener
中的又一个回调函数了。另外我们也可以看到,通过rtps协议,我们能给cyber赋能qos保障。cyber中的rtps配置最终都会转变为fastrtps
中的设置,所以具体的含义请翻阅fastrtps
文档。
cyber默认的配置位于cyber/transport/qos/qos_profile_conf.cc
和cyber/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实际上最终转换成了fastrtps
的times.heartbeatPeriod.seconds&fraction
即心跳周期,用seconds和fraction字段设置秒和毫秒,默认3s。用在Writer
上,用来周期性的检查对方有没有收到数据。大量的数据分片会降低传输速度,降低心跳周期会增加网络中消息的数量,但是同时,当数据分片丢失的时候,会加速系统响应。具体如何配置请见专栏中cyber实操的博客。
配置位于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
,这里其实配置的是Hybrid
的Trainsmitter&Receiver
里的history大小,当然使用的前提是rtps的qos配置必须QosDurabilityPolicy::DURABILITY_TRANSIENT_LOCAL
才行(默认不是,所以一般直接忽略就行)。
这个其实不是单纯的一种通信方式,通过前文的学习读者应该也知道,Hybrid是实际情况下通信的一个默认模式,而且因为Reader
和Writer
在创建Transmitter
和Receiver
时没有指定模式,所以Hybrid才是实际环境使用最多的模式。现在我们就来看一下Hybrid模式到底是怎么通信的。建议学习这部分之前再温习一下rtps的实现,cyber的Hybrid模式很多地方几乎就是照着rtps模式做了一遍。
InitMode
,给成员mapping_table_
map设置对应的通信模式,mapping_table_
是一个配置表,里面指定了在不同情况下用什么方式通信(本意可能是让这个可配置),通信方式的指定在cyber/proto/transport_conf.proto
中的CommunicationMode
,就是INTRA,SHM,RTPS三种。它们对应三种情况:
SAME_PROC
:同一进程,默认使用INTRA模式通信,这种情况发生在同一个module
内的通信,比如同一个module
里不同的component
相互通信,或是同一个component
里自我通信(几乎没有人会蠢到这样做吧)DIFF_PROC
:不同进程但在同一host,默认使用SHM,这种情况基本上就是单机上不同module
互相通信时会用到,也是用得最多的模式DIFF_HOST
:不同host上的不同进程,默认使用RTPS,这只有在不同host上通信时才用到ObtainConfig
,如果有的话读取全局变量里配置的通信模式(位于cyber/conf/cyber.pb.conf
),其实也是在设置mapping_table_
,这就意味着你可以通过运行时读入配置来人为指定通信方式(覆盖InitMode
设置的那些),主要为了以后的扩展。InitHistory
,根据配置里的qos,设置缓存history_
长度,缓存结构History
在cyber/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等性质来选择的(这个部分会在服务发现的博客中介绍)。Node
都是这么做的,它们会在Hybrid
模式中挑选合适的(或指定的)传输方式,需要注意的是,XxxTransmitter
只有有对等的XxxReceiver
加入并开始接受消息了才会被Enable
。这个的实现和HybridTransmitter
几乎是对称的,只不过它保存的是不同模式的Receiver
,在HybridReceiver::Enable
的时候会将这些Receiver
都Enable
起来(AddListener
注册回调,注意Hybrid本身没有对应的Dispatcher
,所以不同模式还是会注册到自己对应的XxxDispatcher
中)。不同的模式的Receiver
各自有相应的消息通知机制和自己的回调函数,它们收到以后会自行调用。它和HybridTransmitter
一个是被动接收,一个是主动发送,但结构方式等都是类似的。同样的,HybridReceiver
也有一条缓存history队列,用法和HybridTransmitter
类似。
至此我们介绍完了cyber的通信底层,可以看到整个底层的设计和RTPS很像,但因为实际通信方式的多样性和复杂性导致代码层次不是很清晰,也有很多不必要的冗余。在实现中也用到了很多C++11中的新特性和方法,所以在对照代码阅读时需要较高的编程基础。
本想总结一下完整的通信流程,但发现内容实在太多太乱,并且因为大量的非线性调用方式没法清晰地画出流程,所以还是请读者尽量能对照着源码来看,遇到问题可以查阅博客。
在这里我们来通过两个问题作为这部分的总结。
Writer
是如何写消息的?
XxxTransmitter::Transmit
来往相应通信方式的合适位置写入数据。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_id
的ListenerHandler
并且调用它的Run
函数,Run
函数首先从接受到的消息信息msg_info
中找到发送者的id的HashValue()
,这个就是oppo_id
,用它从ListenerHandler
的成员signals_
map中找到对应的信号,然后使用该信号来通知所有监听它的槽并调用它们。XxxReceiver::OnNewMessage()
,它们是在XxxReceiver::Enable(const RoleAttributes& opposite_attr)
的时候(Receiver
和Transmitter
的Enable
都是在服务发现博客中介绍的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()
(在Reader
和Writer
初始化的时候都会调用到)。这其实牵扯到cyber中的服务发现机制,因为Service & Client
和它关系紧密所以将它们放在一起。具体有关服务发现机制的介绍,请关注专栏里的相关博客。
Apollo 3.5 Cyber 多進程通訊模塊 - Transport (Shared Memory篇)
Apollo 3.5 Cyber 多進程通訊模塊 - Transport (Intra 和 rtps 篇)
【FastRTPS】RTPS协议简介、创建第一个应用
【FastRTPS】对象和数据结构