2019 CES上百度发布了Apollo 3.5,其中的亮点之一就是其高性能计算框架Cyber RT。我们知道,Apollo在3.0及之前是基于ROS并作了一些改进。经过十多年的发展,ROS已建立起强大生态,在机器人社区广受欢迎,但其多用于学术界实验室验证机器人算法,并不是为了高可靠工业界产品设计的。虽然社区已经看到这一矛盾,并已开整ROS 2.0,但现在还处于开发阶段。自动驾驶相较于其它高性能系统而言,最重要的需求之一就是实时性。因为对于自动驾驶车来说,一旦车辆无法在指定时间内作出反应,可能车里的人就凉了。众所周知,实时性并不是追求绝对的高性能,而是关注任务执行时间的确定性(有时甚至是以吞吐率为代价的)。相信这也是Apollo将基础框架切为自研Cyber RT的重要原因之一。自动驾驶系统是由很多模块组成的复杂系统,模块之间又有着复杂的依赖关系。常见方法一般会将各模块放在线程或进程中,然后交由OS来调度。这样的方式可能会有几个潜在问题:1) 在Linux中线程/进程调度会有user space到kernel space的切换开销;2) 如果没有设置affinity的话可能会被调度到其它CPU上,导致cache使用效率低; 3) Kernel中的scheduler是通用目的的,虽然可以通过priority加以一定的控制,但如果要结合业务逻辑进行更复杂的调度就没办法。为了进一步减少调度开销,以及提高系统确定性,Cyber RT中引入了自己的user space的scheduler,这也是其一大特点。关于这个scheduler网上已经有比较具体的分析,这里就不重复了。本文来聊一聊scheduler的调度单元-协程(coroutine)。
协程是一个非常古老的概念了。作为一种高并发编程方法,业界已经有很多优秀的实现的应用,比如boost库中的Coroutine,微信中的libco,Go语言中的goroutine等等。如果对这些实现在特点上进行分类的话可以有几个维度:
swapcontext()
函数源码可以发现,它会有系统调用。本来协程的优点之一是调试不需要切到kernel space,结果这里还是要切,性能就会打些折扣。因此可以看到不少库会选择自己写汇编实现上下文切换,尽管这样基本需要为每一个平台都写一份。总得来说,Cyber RT中的协程是一种非共享栈、非对称,写汇编进行上下文切换的方式。下面我们来简单看下它的实现:
协程实现位于apollo/cyber/croutine/
目录。主要实现类为CRoutine
。先画个简单的示意图:
CRoutine
中几个关键成员变量如:
func_
就是该协程要执行的函数体。context_
指向相应的RoutineContext
对象。该对象存放的就是对应协程的上下文。对于一个执行体来说,最主要的上下文就是栈和寄存器了,因此RoutineContext
中也非常简单,就是一块空间作为栈(大小为8M),加一个栈指针。进程中所有的RoutineContext
对象会有一个全局的对象池来管理,其指针保存在context_pool
变量中。这个对象池CCObjectPool
的实现位于concurrent_object_pool.h
。它会在初始化时将指定个数的对象全分配好。前面提到过对于自动驾驶系统实时性很重要,内存动态分配也是实时性一大杀手。另外,为了高效它使用的是lock-free的实现。证明lock-free算法的正确性太烧脑不细究了。有兴趣的可以翻阅《C++ Concurrency in Action Practical Multithreading》,更学究的可以看下《The Art of Multiprocessor Programming》。state_
为协程的状态。状态间切换图大致如下:CRoutine
中有两个thread local的变量:current_routine_
指向当前线程正在执行的协程对应的CRoutine
对象。main_stack_
保存主执行体的栈,也就是系统栈。CRoutine
的构造函数会通过MakeContext()
函数和生成该协程的上下文:
void MakeContext(const func &f1, const void *arg, RoutineContext *ctx) {
ctx->sp = ctx->stack + STACK_SIZE - 2 * sizeof(void *) - REGISTERS_SIZE;
std::memset(ctx->sp, 0, REGISTERS_SIZE);
char *sp = ctx->stack + STACK_SIZE - 2 * sizeof(void *);
*reinterpret_cast<void **>(sp) = reinterpret_cast<void *>(f1);
sp -= sizeof(void *);
*reinterpret_cast<void **>(sp) = const_cast<void *>(arg);
}
代码就几行,画个图就比较清晰了。执行完MakeContext()
函数后RoutineContext
中的结构如下:
为啥要整成这副样子,这要结合下面的上下文切换来讲。
协程(非对称)中最核心需要实现resume和yield两个操作。前者让该协程继续执行,后者让协程交出控制权。这里分别是Resume()
和Yield()
函数。前者最核心做的事就是将上下文从当前切换到目标协程;后者反之。它们都是通过SwapContext()
函数实现的上下文切换。我们来细看一这个函数:
inline void SwapContext(char** src_sp, char** dest_sp) {
ctx_swap(reinterpret_cast<void**>(src_sp), reinterpret_cast<void**>(dest_sp));
}
其中的ctx_swap()
函数是用汇编实现的,在swap.S:
.globl ctx_swap
.type ctx_swap, @function
ctx_swap:
pushq %rdi
pushq %r12
pushq %r13
pushq %r14
pushq %r15
pushq %rbx
pushq %rbp
movq %rsp, (%rdi)
movq (%rsi), %rsp
popq %rbp
popq %rbx
popq %r15
popq %r14
popq %r13
popq %r12
popq %rdi
ret
代码很简单,我们以scheduler中的调度循环Processor::Run()
函数通过CRoutine::Resume()
函数切到目标协程为例理解一下。其中最核心的上下文切换是这条语句:
SwapContext(GetMainStack(), GetStack());
下图为调用SwapContext()
函数前后上下文(栈以及寄存器)的变化。
调用前目标协程的栈信息就如前面MakeContext()
函数中设置好的。RoutineContext
中的成员stack
就是协程的栈,成员sp
对应寄存器rsp
。成员sp
指向的成员stack
中的位置从低往高依次为7个寄存器大小的空间及CRoutineEntry
函数指针。7个寄存器分别对应rdi, rbx, rbp, r12-r15
。根据x86_64平台的ABI calling convention,rbx, rbp, r12-r15
是callee-saved registers。也就是说被调用者有责任保存它们,以保证它们在函数过程(对调用者来说,SwapContext()
的过程就像一个普通函数调用)中值不变。为什么对应rdi
位置放的是CRoutine
的指针,因为calling convention中规定rdi
放被调用函数的第一个参数。而CRoutineEntry()
第一个参数正是CRoutine
指针。
调用SwapContext()
函数前物理的rsp
寄存器指向的是系统栈,然后经过上面汇编ctx_swap
函数一进来一顿pushq
,会将当前的这些callee-saved registers全都压到栈中保存起来,然后movq %rsp, (%rdi)
语句将rsp
存到main_stack_
变量中。这样后面切回来时才能把这些上下文恢复回来。就像前面说的,calling convention规定,寄存器rdi
放的第一个参数。对于Resume()
函数中调用的ctx_swap
函数来说,第一个参数实际为main_stack_
。同理,由于寄存器rsi
放的是第二个参数,这里为&(context_->sp)
。因此,下一条语句movq (%rsi), %rsp
是将之前设好的RoutineContext
中的sp
设到寄存器rsp
上,这样栈就已经切过来了。然后是一坨popq
,将协程栈上对应寄存器的内容搬到物理寄存器上。最后一条ret
语句,就是把CRoutineEntry()
函数的地址弹出来,然后跳过去执行。看CRoutineEntry()
的实现知道它会调用当时构建CRoutine
时用户传入的函数。这样就开始执行用户逻辑了。
与另一种常见并发编程手段-线程不同,协程是由用户自己来做任务的切换(故又称“用户级线程”)。因此可以在单线程下实现高并发,是一种更细粒度的并发。它有自己的优缺点: