Cyber作为一个中间件,最基础的功能就是解决不同模块不同进程之间的通信问题,所以这篇文章主要带大家理清cyber中的通信方式。因为这部分内容较多,所以通信模块会分两篇文章来说明,本文为上篇,主要关注偏上层的部分(下图中除了Transport的部分)。
注:本文中部分图片来自@Kit Fung的Apollo分析博客
上图展示了通信功能封装最顶层的类Node
,每个Component
都有一个Node
,每个Node负责创建Reader, Writer, Service, Client
来帮该组件获取信息或传达信息。每个Node
保存了它的名字和名字空间,一个存有channel_name
对应的Reader
的map以及一个NodeChannelImpl
和一个NodeServiceImpl
(创建以上4种东西的创建器)。
NodeChannelImpl
是Node
用来创建Channel相关的Reader
和Writer
的类,在实际环境中,创建的为Reader
,Writer
,虚拟环境中创建的是IntraReader
,IntraWriter
。它会向通信拓扑注册当前节点。
和NodeChannelImpl
类似,只不过它创建的是Service
和Client
,还会注册service。
Reader
继承自ReaderBase
,它包含一个Blocker
和一个Receiver
。Reader
只在实际环境中会被NodeChannelImpl
创建,并主要做两个事:
- 每当该channel来了一个消息,就会调用其Enqueue
函数,该函数会调用Blocker::Publish
函数把该消息放入Reader
中的Blocker
的published_msg_queue
队列并调用Blocker
保存的相应的回调函数来处理(可是实际环境中Blocker
的回调函数为空,所以不会有用)。
- 如果一开始设置有一个回调函数,那么当message
到达时就会在调用Enqueue
函数后额外调用该回调函数来处理(可是实际环境中Reader
的回调函数都为空,所以这一步是不可能发生的)
向对应的channel
写数据,最后会调用到transport::Transmitter->Transmit
函数,每个Writer
只能写一个channel
但某个channel
可以有多个Writer
。
代码在cyber/bolcker/intra_reader.h
,它继承自Reader
,只在虚拟环境中会被NodeChannelImpl
创建。它会重载Reader::Init
函数,不再创建协程,而是直接把IntraReader::OnMessage
(记录时间并调用IntraReader
创建时传入的回调函数)注册为该Blocker
的回调函数。
同样是虚拟环境中用的,它的Write
函数不会调用到Transmitter
的方法,而是直接调用到Blocker::Publish
函数来放入数据。
Blocker
是Reader
的一个成员,BlockerManager
保存了全局的一张channel_name
对应Blocker
的map。Blocker
里面保存了一张callback_id
和回调函数的map,我的理解是Blocker
是一个消息的缓存类,同时也记录了该Reader
的一些回调函数。这里之所以用一些是因为Blocker
这个结构的主要功能应该是提供一个管理者获取数据的入口,方便debug、记录日志、运行虚拟环境和监控整个系统,所以在Blocker
里注册的回调函数应该都是管理员注册的监控函数,和系统主逻辑没关系。
cyber/data/data_visitor.h
,继承自DataVisitorBase
,DataVisitorBase
有一个指向DataNotifier
的指针,还有一个回调函数Notifier
,该回调函数在Component::Initialize
函数最后调用Scheduler::CreateTask
(cyber/scheduler/scheduler.cc
中作为匿名函数被visitor->RegisterNotifyCallback
注册)时被定义并赋值给DataVisitorBase
中的notifier_
(它的作用就是唤醒创建的协程,该协程做的事情就是调用dv->TryFetch
来拿数据,如果成功就调用组件的Proc
函数,具体在调度的博客里会解释)。该回调函数会在DataVisitor
初始化的时候注册到全局的DataNotifier
中的map中。DataVisitor
在Component::Initialize
和Reader::Init
中都会创建,是实际环境中获取数据的方式。它最终会调用到AllLatest::Fusion
函数。每个DataVisitor
的每个消息种类都会有一个缓存ChannelBuffer
,在初始化的时候这些ChannelBuffer
都会被加入到DataDispatcher
中的一张map中。而所有种类的消息还会有一个消息整合体的ChannelBuffer
,这条buffer不会放入全局map(因为不会有Dispatcher
会直接发这种数据),但之后取数据都是从这里取。取的时候按照计数顺序也就是index来取,所以我们可以看到这里没有实时性保证(除非buffer队列长度为1,但系统默认长度其实也就是1,位于cyber/node/reader.h:DEFAULT_PENDING_QUEUE_SIZE
)。DataVisitor
还是只有一个Notifier
对应M0,也就是说只有当M0消息来的时候协程会被唤醒并通过DataFusion
来获取整合所有种类的信息。DataNotifier
是一个全局的单例,保存有一个channel_id
对应vector
的map(即每个channel对应多个Notifier
,因为有多个订阅者),在DataVisitor
初始化的时候就会把自己的Notifier
注册到这个map中。
DataDispatcher
也是一个全局单例,记录了一个channel_id
对应vector>>>
的map(也就是说每个channel会有好几个订阅者,每个订阅者拥有一条CacheBuffer,每次收到该channel的消息会给每个buffer都放一份)DataDispatcher::Dispatch
函数用来向某个channel分发收到的数据,它会先从map中取出所有对应的buffer,然后调用CacheBuffer::Fill
函数来给buffer填数据(这里的Fill
函数还会去把所有种类的消息整合成一条新的整合数据,后面会介绍),之后再调用DataNotifier::Notify
函数来找出所有对应该channel_id
的Notifier
并调用它们唤醒一开始创建的协程来取数据并运行回调函数。代码位于cyber/data/fusion/all_latest.h
,DataFusion
是DataVisitor
中的一个成员,AllLatest
实现了这个基类,它提供了Fusion
函数用来拿所有种类数据的整合体。之所以会出现这样的一个方式是因为每次给最终的回调函数Proc
处理数据时需要给出所有数据种类的一个整合,但不同数据产生的快慢不同,可能M0产生了1个但M1已经产生了10个了,这时候就需要从M1中挑一个最新的,旧的就直接丢弃了,可见***M0的选取非常重要***。而实现这个机制的方式是通过在Dispatch
时实现的:
- 在初始化DataFusion
(AllLatest
)时会给M0的ChannelBuffer
设置一个回调函数,该回调函数会在每次有M0数据到来时(会调用CacheBuffer::Fill
函数)被调用。普通的Fill
函数会把收到的数据放入buffer,而有回调函数的CacheBuffer
则不会这么做,而是只运行回调函数。
- 回调函数做了什么事呢。它会去M1,M2…这些其他种类的buffer中获取它们队列中最新的那条数据,然后把当前的M0和拿到的buffer中的M1,M2…这些整合成一个整体再放入新的队列。所以我们可以推测,一般M0都是获取最慢的那个数据类型,这样就能保证整体数据都是比较新的。
- 所以当Dispatch
其他种类数据时,只是单纯往对应的buffer中填数据,当发送M0时会发生一次数据整合然后返回整合后的数据并唤醒Proc
回调函数。
实际上就是实现了一个队列,用来放置某个channel产生的数据。这里需要注意,每次有新数据产生,都会给每个等待着的队列复制一份并放入buffer(但内存是恒定的,因为buffer的长度一开始就定了)。
具体的队列实现在cyber/data/cache_buffer.h
,很简单,有兴趣可以直接读代码。
因为需要提供虚拟环境,所以数据流变得非常混乱。这一部分的代码比较复杂,接下来我会带大家好好理解一下到底整个项目为这两个不同环境添加了多少tricky的内容。
Component::Initialize
函数是组件初始化的过程,在cyber/component/component.h
中我们可以看到每个组件可以有0-4个消息分别对应4个不同的Initialize
函数。我们看每个初始化函数,对于实际环境来说,初始化工作非常的明确,就是为每个消息类别创建一个Reader
,然后创建回调函数(调用Process
)并让调度器来运行该Task。Proc
函数,它从DataVisitor
中得到所有消息并运行。Reader
时,Reader
会调用自己的初始化函数(在cyber/node/reader.h
),实际环境中的Reader
创建都是不指定回调函数的,所以Reader
创建的协程只会调用一下Reader::Enqueue
(Blocker::Subscribe
只会在IntraReader::Init
中用到,所以此处的Enqueue
只起到了缓存消息的作用,Blocker::Notify
函数等于没用)。Component
会创建一个协程来调用Proc
回调函数,每个Component
的每个Reader
也会创建一个协程来缓存消息数据。Proc
需要的所有消息都是通过DataVisitor
获取的,不会再经过Reader
。Reader
和Writer
都是IntraReader
和IntraWriter
,阅读器获取的数据不是其它组件的协程中生产的(Writer
不会调到transport::Transmitter->Transmit
),而是通过Blocker
的Publish
函数来直接将模拟或历史数据放入缓存队列(所以可以“伪造”)。IntraReader
,然后对于M0消息,它会创建一个特殊的回调函数,该回调函数在接收到消息msg0时触发,触发时会从另外n-1个消息的IntraReader
的Blocker
的缓存队列中拿出另外n-1个消息,然后把这些消息一起交给Process
即Proc
函数来执行。这个回调函数会在创建IntraReader
时当成参数传入并在IntraReader
初始化时被注册到该IntraReader
的Blocker
中的回调函数map中。Component
不会创建协程,回调函数会在收到M0消息时被调用并且自动从其他的IntraReader
中拿到数据,相比于实际环境中的数据获取更加简单,它不会用到DataVisitor
。可以看到,至此我们完成了通信模块偏上层部分的闭环。某数据从DataDispatcher::Dispatch
开始被发送,然后向某个channel的所有等待的buffer中放入新来的数据并且通知所有以该数据为M0的DataVisitor
,这些组件的协程被唤醒并被调度器调度运行,它尝试从缓存队列里取出M0以及其他种类数据的缓存数据,整合之后把整合后的数据放入回调函数运行。
可以看到,这个过程屏蔽了底层实现,那就是从Writer
把消息写入之后如何被DataDispatcher::Dispatch
所察觉的呢,在下一篇文章中我们会从底层入手来深入理解cyber的通信方式,填上这个缺口。
Apollo 3.5 Cyber Blocker模塊簡單記錄
Apollo 3.5 Cyber data_visitor 分析