技术文档丨Cyber RT 协程技术解读
经过十多年的发展,ROS已建立起强大生态并在机器人社区广受欢迎,但其多用于学术界实验室验证机器人算法,并非为高可靠工业界产品而设计。
自动驾驶相较于其它高性能系统而言,最重要的需求之一就是实时性。一旦车辆无法在指定时间内作出反应,车/人即将陷入危险的境地。
众所周知,实时性并不是追求绝对的高性能,而是关注任务执行时间的确定性(有时甚至是以吞吐率为代价的)。这也是Apollo将基础框架切为自研Cyber RT的重要原因之一。
协程(coroutine)是一种程序组件,其应用在业内已经有很长的历史了,最早于1963年被提出。
作为一种高并发编程方法,业界已经有很多优秀的、实现的应用。比如Boost库中的Coroutine,微信中的Libco,Go语言中的Goroutine等。
如果对这些应用在特点上进行分类的话可以有几个维度:
共享栈 vs 非共享栈:
共享栈方式中每个协程有自己的栈,因此它可以在深层调用中被挂起。但当协程数很大时栈可能会占用比较多内存;而Stackless方式中所有协程都是共享一个栈,因此只能在顶层函数中被挂起。
对称 vs 非对称:
对称方式中协程之间地位是对等的,协程之间可以相互移交控制;而非对称方式中,如果协程A将控制移交给协程B,它们二者间不是对等的。B交出控制权时只能交还给A。二者相当于函数调用者和被调用者的关系。
基于Ucontext vs 写汇编 vs 其它:
上下文切换有多种方式,比较主流的有基于Ucontext实现和自己写汇编两种方式,其它的还有比如用Setjmp/Longjmp的实现。Ucontext是libc提供的一族函数。通过它们,可以很方便地实现协程。而且不用写汇编,这意味着良好的可移植性。但看其Swapcontext()函数源码可以发现,它会有系统调用。本来协程的优点之一是调试不需要切到Kernel space,结果这里还是要切,性能就会打些折扣。因此可以看到不少库会选择自己写汇编实现上下文切换,尽管这样基本需要为每一个平台都写一份。
以下,ENJOY
协程,也被叫做纤程,可以理解为一种用户态的轻量级线程。通常每个协程都有自己的寄存器上下文和栈,通过对寄存器上下文和栈的保存与恢复,即可以实现不同协程之间的切换。
根据协程实现的特点,一般可以通过以下几个维度对其进行分类:
有栈协程保存有自身的栈信息,支持在多层嵌套调用的过程中实现挂起,恢复时从挂起点继续往下执行。无栈协程在嵌套调用时,共享一段栈空间,只能在顶层函数执行过程中实现挂起。相较而言,有栈协程具有更大的灵活性,而无栈协程占用更小的内存间。
·
对称协程与非对称协程的区别主要体现在控制传递机制上,对称协程间可以对等地互相传递控制权,而非对称协程与函数相似,只能将控制权返回给调用者。相较而言,非对称协程的实现更加为了实现对任务的更灵活的管理与调度,Cyber RT中使用的是非对称的有栈协程。
自动驾驶系统中,所有的任务根据其数据流向可以形成一个有向无环图,任务间存在着上下游的关系;同时传感器会周期性的产出大量数据,所以任务间还存在着周期性的关系。
在运行时,只有根据任务间的关系,设置不同的优先级,使其按照一个合理的顺序来执行,才能使用系统运行在一个高效、稳定的状态。
在经典的线程池任务模型下,我们可以通过对任务进行排序,然后分发给不同的线程来执行,从而实现对任务执行顺序的控制;然而,由于线程的调度是由系统内核来管理的,经过内核调度之后,任务最终真正执行的顺序与我们所预期的可能并不一致。
为了在用户空间能够实现对任务更强的调度管理,Cyber RT选择了使用基于协程的任务模型。相比较于线程有很多优点,如:
协程的切换是在用户空间进行的。因此在调度器中可以通过一定的调度策略,来严格控制系统中不同协程任务的执行顺序,使用系统运行在一个高效有序的状态;
协程的切换速度快。目前主流的实现方案切换速度能达到线程的数百倍;
一个线程可以对应多个协程。通过协程的快速切换,可以避免线程的频繁调度;
通过上文对于协程的定义我们知道,要使用协程,必须实现三个部分和功能:协程栈,挂起协程和恢复协程;
在Cyber RT中其代码实现位于/apollo/cyber/croutine/目录。
context_是协程上下文RoutineContext对象指针,对象中包含了一个2M大小的栈空间,用于协程挂起时保存其执行上下文,其内容通过MakeContent()来构造:
构造完成后,其结构示意图如下:
Yield()负责把当前寄存器中的信息即协程执行上下文保存至context_中,然后把main_stack_中线程主体的上下文恢复到寄存器中,将控制权返回给主体调度线程。
Resume()负责把当前寄存器中的信息即主体调试线程执行上下文保存至main_stack_中,然后再把CRoutine对象context_中保存的协程上下文恢复到寄存器中,将控制权交给协程。
SwapContext()/ctx_swap()上下文切换的核心接口,使用汇编对能用寄存器进行操作,以实现快速切换。
上述接口中,各寄存器的说明如下:
rdi目的变址寄存器,这里有存放ctx_swap函数的第一个参数
r12-r15数据寄存器,被调用者保存
rbx基址寄存器,被调用者保存
rbp基址指针寄存器,被调用者保存
rsp栈顶指针寄存器,提供堆栈栈顶单元的偏移地址,控制数据进栈和出栈
rsi源变址寄存器,这里存放ctx_swap函数的第二个参数
通过入栈保存→切换系统栈指针→出栈恢复一系列操作,ctx_swap实现了协程与主体调度线程之间的切换。
这里先简单介绍了协程的基本概念与优势,以及Cyber RT中是如何通过汇编来实现协程上下文的快速切换,使大家对协程有了基本的了解。下一篇我们将继续介绍Cyber RT中如何将数据驱动型任务封装为协程,并实现任务的循环执行。