C++ 开源协程库 libco——原理及应用
平台技术部·王亮 2016 年 11 月 26 日
1 导论
使用 C++ 来编写高性能的网络服务器程序,从来都不是件很容易的事情。在没有
应用任何网络框架,从 epoll/kqueue 直接码起的时候尤其如此。即便使用 libevent, libev
这样事件驱动的网络框架去构建你的服务,程序结构依然不会很简单。为何会这样?因
为这类框架提供的都是非阻塞式的、异步的编程接口,异步的编程方式,这需要思维方
式的转变。为什么 golang 近几年能够大规模流行起来呢?因为简单。这方面最突出的
一点便是它的网络编程 API,完全同步阻塞式的接口。要并发?go 出一个协程就好了。
相信对于很多人来说,最开始接触这种编程方式,是有点困惑的。程序中到处都是同步
阻塞式的调用,这程序性能能好吗?答案是,好,而且非常好。那么 golang 是如何做
到的呢?秘诀就在它这个协程机制里。
在 go 语言的 API 里,你找不到像 epoll/kqueue 之类的 I/O 多路复用(I/O multiplexing)
接口,那它是怎么做到轻松支持数万乃至十多万高并发的网络 IO 的呢?在 Linux 或
其他类 Unix 系统里,支持 I/O 多路复用事件通知的系统调用(System Call)不外乎
epoll/kqueue,它难道可以离开这些系统接口另起炉灶?这个自然是不可能的。聪明的
读者,应该大致想到了这背后是怎么个原理了。
语言内置的协程并发模式,同步阻塞式的 IO 接口,使得 golang 网络编程十分容
易。那么 C++ 可不可以做到这样呢?
本文要介绍的开源协程库 libco,就是这样神奇的一个开源库,让你的高性能网络
服务器编程不再困难。
Libco 是微信后台大规模使用的 C++ 协程库,在 2013 年的时候作为腾讯六大开
源项目首次开源。据说 2013 年至今稳定运行在微信后台的数万台机器上。从本届
ArchSummit 北京峰会来自腾讯内部的分享经验来看,它在腾讯内部使用确实是比较广
泛的。同 go 语言一样,libco 也是提供了同步风格编程模式,同时还能保证系统的高并
发能力。
1
2 准备知识
2.1 协程(Coroutine)是什么?
协程这个概念,最近这几年可是相当地流行了。尤其 go 语言问世之后,内置的协
程特性,完全屏蔽了操作系统线程的复杂细节;甚至使 go 开发者“只知有协程,不知
有线程”了。当然 C++, Java 也不甘落后,如果你有关注过 C++ 语言的最新动态,可能
也会注意到近几年不断有人在给 C++ 标准委员会提协程的支持方案;Java 也同样有一
些试验性的解决方案在提出来。
在 go 语言大行其道的今天,没听说过协程这个词的程序员应该很少了,甚至直接接
触过协程编程的(golang, lua, python 等)也不在少数。你可能以为这是个比较新的东西,
但其实协程这个概念在计算机领域已经相当地古老了。早在七十年代,Donald Knuth 在
他的神作 The Art of Computer Programming 中将 Coroutine 的提出者归于 Conway Melvin。
同时,Knuth 还提到,coroutines 不过是一种特殊的 subroutines(Subroutine 即过程调用,
在很多高级语言中也叫函数,为了方便起见,下文我们将它称为“函数”)。当调用一
个函数时,程序从函数的头部开始执行,当函数退出时,这个函数的声明周期也就结
束了。一个函数在它的生命周期中,只可能返回一次。而协程则不同,协程在执行过程
中,可以调用别的协程自己则中途退出执行,之后又从调用别的协程的地方恢复执行。
这有点像操作系统的线程,执行过程中可能被挂起,让位于别的线程执行,稍后又从
挂起的地方恢复执行。在这个过程中,协程与协程之间实际上不是普通“调用者与被调
者”的关系,他们之间的关系是对称的(symmetric)。实际上,协程不一定都是这种对
称的关系,还存在着一种非对称的协程模式(asymmetric coroutines)。非对称协程其实
也比较常见,本文要介绍的 libco 其实就是一种非对称协程,Boost C++ 库也提供了非
对称协程。
具体来讲,非对称协程(asymmetric coroutines)是跟一个特定的调用者绑定的,协
程让出 CPU 时,只能让回给原调用者。那到底是什么东西“不对称”呢?其实,非对称
在于程序控制流转移到被调协程时使用的是 call/resume 操作,而当被调协程让出 CPU
时使用的却是 return/yield 操作。此外,协程间的地位也不对等,caller 与 callee 关系是
确定的,不可更改的,非对称协程只能返回最初调用它的协程。
对称协程(symmetric coroutines)则不一样,启动之后就跟启动之前的协程没有任
何关系了。协程的切换操作,一般而言只有一个操作,yield,用于将程序控制流转移给
另外的协程。对称协程机制一般需要一个调度器的支持,按一定调度算法去选择 yield
的目标协程。
Go 语言提供的协程,其实就是典型的对称协程。不但对称,goroutines 还可以在
多个线程上迁移。这种协程跟操作系统中的线程非常相似,甚至可以叫做“用户级线
程”了。而 libco 提供的协程,虽然编程接口跟 pthread 有点类似,“类 pthread 的接口设
计”,“如线程库一样轻松”,本质上却是一种非对称协程。这一点不要被表象蒙蔽了。
事实上,libco 内部还为保存协程的调用链留了一个 stack 结构,而这个 stack 大小只有
固定的 128。使用 libco,如果不断地在一个协程运行过程中启动另一个协程,随着嵌套
深度增加就可能会造成这个栈空间溢出。
2
2.2 栈的概念回顾
3 Libco 使用简介
3.1 一个简单的例子
在多线程编程教程中,有一个经典的例子:生产者消费者问题。事实上,生产者消
费者问题也是最适合协程的应用场景。那么我们就从这个简单的例子入手,来看一看
使用 libco 编写的生产者消费者程序(例程代码来自于 libco 源码包)。
stTask_t* task = (stTask_t*)calloc(1, sizeof(stTask_t));
task>id = id++;
env>task_queue.push(task);
printf("%s:%d produce task %d\n", __func__, __LINE__, task>id);
co_cond_signal(env>cond);
poll(NULL, 0, 1000);
int id;
1 struct stTask_t {
2
3 };
4
5 struct stEnv_t {
6
stCoCond_t* cond;
queue task_queue;
7
8 };
9
10 void* Producer(void* args) {
11
co_enable_hook_sys();
stEnv_t* env = (stEnv_t*)args;
int id = 0;
while (true) {
}
return NULL;
22
23 }
24
25 void* Consumer(void* args) {
26
co_enable_hook_sys();
stEnv_t* env = (stEnv_t*)args;
while (true) {
12
13
14
15
16
17
18
19
20
21
27
28
29
30
31
32
33
34
35
36
37
38
39 }
if (env>task_queue.empty()) {
co_cond_timedwait(env>cond, 1);
continue;
}
stTask_t* task = env>task_queue.front();
env>task_queue.pop();
printf("%s:%d consume task %d\n", __func__, __LINE__, task>id);
free(task);
}
return NULL;
生产者和消费者协程
3
该文档是极速PDF编辑器生成,如果想去掉该提示,请访问并下载:http://www.jisupdfeditor.com/
在上面的代码中,Producer 与 Consumer 函数分别实现了生产者与消费者的逻辑,
函数的原型跟 pthread 线程函数原型也是一样的。不同的是,在函数第一行还调用了一
个 co_enable_hook_sys(),此外,不是用 sleep() 去等待,而是 poll()。这些原因后文会详
细解释,暂且不管。接下来我们看怎样创建和启动生产者和消费者协程。
1 int main() {
2
stEnv_t* env = new stEnv_t;
env>cond = co_cond_alloc();
3
4
5
6
7
8
9
10
11
12
13
14
15 }
stCoRoutine_t* consumer_routine;
co_create(&consumer_routine, NULL, Consumer, env);
co_resume(consumer_routine);
stCoRoutine_t* producer_routine;
co_create(&producer_routine, NULL, Producer, env);
co_resume(producer_routine);
co_eventloop(co_get_epoll_ct(), NULL, NULL);
return 0;
创建和启动生产者消费者协程
初次接触 libco 的读者,应该下载源码编译,亲自运行一下这个例子看看输出结
果是什么。实际上,这个例子的输出结果跟多线程实现方案是相似的,Producer 与
Consumer 交替打印生产和消费信息。
再来看代码,在 main() 函数中,我们看到代表一个协程的结构叫做 stCoRoutine_t,
创建一个协程使用 co_create() 函数。我们注意到,这里的 co_create() 的接口设计跟
pthread 的 pthread_create() 是非常相似的。跟 pthread 不太一样是,创建出一个协程之后,
并没有立即启动起来;这里要启动协程,还需调用 co_resume() 函数。最后,pthread 创建
线程之后主线程往往会 pthread_join() 等等子线程退出,而这里的例子没有“co_join()”
或类似的函数,而是调用了一个 co_eventloop() 函数,这些差异的原因我们后文会详细
解析。
然后再看 Producer 和 Consumer 的实现,细心的读者可能会发现,无论是 Producer
还是 Consumer,它们在操作共享的队列时都没有加锁,没有互斥保护。那么这样做是否
安全呢?其实是安全的。在运行这个程序时,我们用 ps 命令会看到这个它实际上只有一
个线程。因此在任何时刻处理器上只会有一个协程在运行,所以不存在 race conditions,
不需要任何互斥保护。
还有一个问题。这个程序既然只有一个线程,那么 Producer 与 Consumer 这两个协
程函数是怎样做到交替执行的呢?如果你熟悉 pthread 和操作系统多线程的原理,应该
很快能发现程序里 co_cond_signal()、poll() 和 co_cond_timedwait() 这几个关键点。换作
是一个 pthread 编写的生产者消费者程序,在只有单核 CPU 的机器上执行,结果是不是
一样的?
总之,这个例子跟 pthread 实现的生产者消费者程序是非常相似的。通过这个例子,
我们也大致对 libco 的协程接口有了初步的了解。为了能看懂本文接下来的内容,建议
把其他几个例子的代码也都浏览一下。下文我们将不再直接列出 libco 例子中的代码,
如果有引用到,请自行参看相关代码。
4
4
libco 的协程
通过上一节的例子,我们已经对 libco 中的协程有了初步的印象。我们完全可以把
它当做一种用户态线程来看待,接下来我们就从线程的角度来开始探究和理解它的实
现机制。
以 Linux 为例,在操作系统提供的线程机制中,一个线程一般具备下列要素:
(1) 有一段程序供其执行,这个是显然是必须的。另外,不同线程可以共用同一段
程序。这个也是显然的,想想我们程序设计里经常用到的线程池、工作线程,不同的工
作线程可能执行完全一样的代码。
(2) 有起码的“私有财产”,即线程专属的系统堆栈空间。
(3) 有“户口”,操作系统教科书里叫做“进(线)程控制块”,英文缩写叫 PCB。在
Linux 内核里,则为 task_struct 的一个结构体。有了这个数据结构,线程才能成为内核
调度的一个基本单位接受内核调度。这个结构也记录着线程占有的各项资源。
此外,值得一提的是,操作系统的进程还有自己专属的内存空间(用户态内存空
间),不同进程间的内存空间是相互独立,互不干扰的。而同属一个进程的各线程,则
是共享内存空间的。显然,协程也是共享内存空间的。
我们可以借鉴操作系统线程的实现思想,在 OS 之上实现用户级线程(协程)。跟
OS 线程一样,用户级线程也应该具备这三个要素。所不同的只是第二点,用户级线程
(协程)没有自己专属的堆空间,只有栈空间。首先,我们得准备一段程序供协程执行,
这即是 co_create() 函数在创建协程的时候传入的第三个参数——形参为 void*,返回值
为 void 的一个函数。
其次,需要为创建的协程准备一段栈内存空间。栈内存用于保存调用函数过程中
的临时变量,以及函数调用链(栈帧)。在 Intel 的 x86 以及 x64 体系结构中,栈顶由
ESP(RSP)寄存器确定。所以一个创建一个协程,启动的时候还要将 ESP(RSP)切到
分配的栈内存上,后文将对此做详细分析。
co_create() 调用成功后,将返回一个 stCoRoutine_t 的结构指针(第一个参数)。从
命名上也可以看出来,该结构即代表了 libco 的协程,记录着一个协程拥有的各种资源,
我们不妨称之为“协程控制块”。这样,构成一个协程三要素——执行的函数,栈内存,
协程控制块,在 co_create() 调用完成后便都准备就绪了。
5 关键数据结构及其关系
1 struct stCoRoutine_t {
2
stCoRoutineEnv_t *env;
pfn_co_routine_t pfn;
void *arg;
coctx_t ctx;
3
4
5
6
7
8
9
10
11
char cStart;
char cEnd;
char cIsMain;
char cEnableSysHook;
char cIsShareStack;
5
12
13
14
15
16
17
18
19
20
21
22
23
24 };
void *pvEnv;
//char sRunStack[ 1024 * 128 ];
stStackMem_t* stack_mem;
//save satck buffer while confilct on same stack_buffer;
char* stack_sp;
unsigned int save_size;
char* save_buffer;
stCoSpec_t aSpec[1024];
libco 的协程控制块 stCoRoutine_t
接下来我们逐个来看一下 stCoRoutine_t 结构中的各项成员。首先看第 2 行的 env,
协程执行的环境。这里提一下,不同于 go 语言,libco 的协程一旦创建之后便跟创建时
的那个线程绑定了的,是不支持在不同线程间迁移(migrate)的。这个 env,即同属于
一个线程所有协程的执行环境,包括了当前运行协程、上次切换挂起的协程、嵌套调用
的协程栈,和一个 epoll 的封装结构(TBD)。第 3、4 行分别为实际待执行的协程函数
以及参数。第 5 行,ctx 是一个 coctx_t 类型的结构,用于协程切换时保存 CPU 上下文
(context)的;所谓的上下文,即esp、ebp、eip和其他通用寄存器的值。第 7 至 11 行是
一些状态和标志变量,意义也很明了。第 13 行 pvEnv,名字看起来有点费解,我们暂
且知道这是一个用于保存程序系统环境变量的指针就好了。16 行这个 stack_mem,协
程运行时的栈内存。通过注释我们知道这个栈内存是固定的 128KB 的大小。我们可以
计算一下,每个协程 128K 内存,那么一个进程启 100 万个协程则需要占用高达 122GB
的内存。读者大概会怀疑,不是常听说协程很轻量级吗,怎么会占用这么多的内存?答
案就在接下来 19 至 21 行的几个成员变量中。这里要提到实现 stackful 协程(与之相
对的还有一种 stackless 协程)的两种技术:Separate coroutine stacks 和 Copying the stack
(又叫共享栈)。实现细节上,前者为每一个协程分配一个单独的、固定大小的栈;而后
者则仅为正在运行的协程分配栈内存,当协程被调度切换出去时,就把它实际占用的
栈内存 copy 保存到一个单独分配的缓冲区;当被切出去的协程再次调度执行时,再一
次 copy 将原来保存的栈内存恢复到那个共享的、固定大小的栈内存空间。通常情况下,
一个协程实际占用的(从 esp 到栈底)栈空间,相比预分配的这个栈大小(比如 libco
的 128KB)会小得多;这样一来,copying stack 的实现方案所占用的内存便会少很多。
当然,协程切换时拷贝内存的开销有些场景下也是很大的。因此两种方案各有利弊,而
libco 则同时实现了两种方案,默认使用前者,也允许用户在创建协程时指定使用共享
栈。
void *regs[8];
1 struct coctx_t {
2 #if defined(__i386__)
3
4 #else
5
6 #endif
7
void *regs[14];
size_t ss_size;
char *ss_sp;
8
6
9 };
用于保存协程执行上下文的 coctx_t 结构
前文还提到,协程控制块 stCoRoutine_t 结构里第一个字段 env,用于保存协程的运
行“环境”。前文也指出,这个结构是跟运行的线程绑定了的,运行在同一个线程上的
各协程是共享该结构的,是个全局性的资源。那么这个 stCoRoutineEnv_t 到底包含什么
重要信息呢?请看代码:
1 struct stCoRoutineEnv_t {
2
stCoRoutine_t *pCallStack[128];
int iCallStackSize;
stCoEpoll_t *pEpoll;
// for copy stack log lastco and nextco
stCoRoutine_t* pending_co;
stCoRoutine_t* ocupy_co;
3
4
5
6
7
8
9 };
协程的 stCoRoutineEnv_t 结构
我们看到 stCoRoutineEnv_t 内部有一个叫做 CallStack 的“栈”,还有个 stCoPoll_t 结构
指针。此外,还有两个 stCoRoutine_t 指针用于记录协程切换时占有共享栈的和将要切
换运行的协程。在不使用共享栈模式时 pending_co 和 ocupy_co 都是空指针,我们暂且
忽略它们,等到分析共享栈的时候再说。
stCoRoutineEnv_t 结构里的 pCallStack 不是普通意义上我们讲的那个程序运行栈,
那个 ESP(RSP)寄存器指向的栈,是用来保留程序运行过程中局部变量以及函数调用
关系的。但是,这个 pCallStack 又跟 ESP(RSP)指向的栈有相似之处。如果将协程看
成一种特殊的函数,那么这个 pCallStack 就时保存这些函数的调用链的栈。我们已经讲
过,非对称协程最大特点就是协程间存在明确的调用关系;甚至在有些文献中,启动协
程被称作 call,挂起协程叫 return。非对称协程机制下的被调协程只能返回到调用者协
程,这种调用关系不能乱,因此必须将调用链保存下来。这即是 pCallStack 的作用,将
它命名为“调用栈”实在是恰如其分。
每当启动(resume)一个协程时,就将它的协程控制块 stCoRoutine_t 结构指针保存
在 pCallStack 的“栈顶”,然后“栈指针”iCallStackSize 加 1,最后切换 context 到待启
动协程运行。当协程要让出(yield)CPU 时,就将它的 stCoRoutine_t 从 pCallStack 弹
出,“栈指针”iCallStackSize 减 1,然后切换 context 到当前栈顶的协程(原来被挂起的
调用者)恢复执行。这个“压栈”和“弹栈”的过程我们在 co_resume() 和 co_yield() 函
数中将会再次讲到。
那么这里有一个问题,libco 程序的第一个协程呢,假如第一个协程 yield 时,CPU
控制权让给谁呢?关于这个问题,我们首先要明白这“第一个”协程是什么。实际上,
libco 的第一个协程,即执行 main 函数的协程,是一个特殊的协程。这个协程又可以称
作主协程,它负责协调其他协程的调度执行(后文我们会看到,还有网络 I/O 以及定时
事件的驱动),它自己则永远不会 yield,不会主动让出 CPU。不让出(yield)CPU,不
等于说它一直霸占着 CPU。我们知道 CPU 执行权有两种转移途径,一是通过 yield 让给
调用者,其二则是 resume 启动其他协程运行。后文我们可以清楚地看到,co_resume()
7
与 co_yield() 都伴随着上下文切换,即 CPU 控制流的转移。当你在程序中第一次调用
co_resume() 时,CPU 执行权就从主协程转移到了 resume 目标协程上了。
提到主协程,那么另外一个问题又来了,主协程是在什么时候创建出来的呢?什么
时候 resume 的呢?事实上,主协程是跟 stCoRoutineEnv_t 一起创建的。主协程也无需
调用 resume 来启动,它就是程序本身,就是 main 函数。主协程是一个特殊的存在,可
以认为它只是一个结构体而已。在程序首次调用 co_create() 时,此函数内部会判断当前
进程(线程)的 stCoRoutineEnv_t 结构是否已分配,如果未分配则分配一个,同时分配
一个 stCoRoutine_t 结构,并将 pCallStack[0] 指向主协程。此后如果用 co_resume() 启动
协程,又会将 resume 的协程压入 pCallStack 栈。以上整个过程可以用图1来表示。
图 1: stCoRoutineEnv_t 结构的 pCallStack 示意图
在图1中,coroutine2 整处于栈顶,也即是说,当前正在 CPU 上 running 的协程是
coroutine2。而 coroutine2 的调用者是谁呢?是谁 resume 了 coroutine2 呢?是 coroutine1。
coroutine1 则是主协程启动的,即在 main 函数里 resume 的。当 coroutine2 让出 CPU 时,
只能让给 coroutine1;如果 coroutine1 再让出 CPU,那么又回到了主协程的控制流上了。
当控制流回到主协程上时,主协程在干些什么呢?回过头来看生产者消费者那个
例子。那个例子中,main 函数中程序最终调用了 co_eventloop()。该函数是一个基于
epoll/kqueue 的事件循环,负责调度其他协程运行,具体细节暂时略去。这里我们只需
知道,stCoRoutineEnv_t 结构中的 pEpoll 即使在这里用的就够了。
至此,我们已经基本理解了 stCoRoutineEnv_t 结构的作用。待补充。
6 Libco 协程的生命周期
6.1 创建协程(Creating coroutines)
前文已提到,libco 中创建协程是 co_create() 函数。函数声明如下:
1 int co_create(stCoRoutine_t** co, const stCoRoutineAttr_t* attr, void* (*routine)(
void*), void* arg);
同 pthread_create 一样,该函数有四个参数:
@co: stCoRoutine_t** 类型的指针。输出参数,co_create 内部会为新协程分配⼀个
“协程控制块”,*co 将指向这个分配的协程控制块。
@attr: stCoRoutineAttr_t* 类型的指针。输⼊参数,用于指定要创建协程的属性,可
为 NULL。实际上仅有两个属性:栈⼤小、指向共享栈的指针(使用共享栈模式)。
@routine: void* (*)(void *) 类型的函数指针,指向协程的任务函数,即启动这个协
程后要完成什么样的任务。routine 类型为函数指针。
8