协程(coroutine)顾名思义就是“协做的例程”(co-operative routines)。跟具备操做系统概念的线程不同,协程是在用户空间利用程序语言的语法语义就能实现逻辑上相似多任务的编程技巧。实际上协程的概念比线程还要早,按照 Knuth 的说法“子例程是协程的特例”,一个子例程就是一次子函数调用,那么实际上协程就是类函数同样的程序组件,你能够在一个线程里面轻松建立数十万个协程,就像数十万次函数调用同样。只不过子例程只有一个调用入口起始点,返回以后就结束了,而协程入口既能够是起始点,又能够从上一个返回点继续执行,也就是说协程之间能够经过 yield 方式转移执行权,对称(symmetric)、平级地调用对方,而不是像例程那样上下级调用关系。固然 Knuth 的“特例”指的是协程也能够模拟例程那样实现上下级调用关系,这就叫非对称协程(asymmetric coroutines)。javascript
咱们举一个例子来看看一种对称协程调用场景,你们最熟悉的“生产者-消费者”事件驱动模型,一个协程负责生产产品并将它们加入队列,另外一个负责从队列中取出产品并使用它。为了提升效率,你想一次增长或删除多个产品。伪代码能够是这样的:php
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# producer coroutine
loop
while
queue is not full
create some new items
add the items to queue
yield to consumer
# consumer coroutine
loop
while
queue is not empty
remove some items from queue
use the items
yield to producer
|
大多数教材上拿这种模型做为多线程的例子,实际上多线程在此的应用仍是显得有点“重量级”,因为缺少 yield 语义,线程之间不得不使用同步机制来避免产生全局资源的竟态,这就不可避免产生了休眠、调度、切换上下文一类的系统开销,并且线程调度还会产生时序上的不肯定性。而对于协程来讲,“挂起”的概念只不过是转让代码执行权并调用另外的协程,待到转让的协程告一段落后从新获得调用并从挂起点“唤醒”,这种协程间的调用是逻辑上可控的,时序上肯定的,可谓一切尽在掌握中。html
当今一些具有协程语义的语言,比较重量级的如C#、erlang、golang,以及轻量级的python、lua、javascript、ruby,还有函数式的scala、scheme等。相比之下,做为原生态语言的 C 反而处于尴尬的地位,缘由在于 C 依赖于一种叫作栈帧的例程调用,例程内部的状态量和返回值都保留在堆栈上,这意味着生产者和消费者相互之间没法实现平级调用,固然你能够改写成把生产者做为主例程而后将产品做为传递参数调用消费者例程,这样的代码写起来费力不讨好并且看起来会很难受,特别当协程数目达到十万数量级,这种写法就过于僵化了。java
这就引出了协程的概念,若是将每一个协程的上下文(好比程序计数器)保存在其它地方而不是堆栈上,协程之间相互调用时,被调用的协程只要从堆栈之外的地方恢复上次出让点以前的上下文便可,这有点相似于 CPU 的上下文切换,遗憾的是彷佛只有更底层的汇编语言才能作到这一点。python
难道 C 语言只能用多线程吗?幸运的是,C 标准库给咱们提供了两种协程调度原语:一种是setjmp/longjmp,另外一种是 ucontext 组件,它们内部(固然是用汇编语言)实现了协程的上下文切换,相较之下前者在应用上会产生至关的不肯定性(好比很差封装,具体说明参考联机文档),因此后者应用更普遍一些,网上绝大多数 C 协程库也是基于 ucontext 组件实现的。程序员
在此,我来介绍一种“蝇量级”的开源 C 协程库 protothreads。这是一个所有用 ANSI C 写成的库,之因此称为“蝇量级”的,就是说,实现已经不能再精简了,几乎就是原语级别。事实上 protothreads 整个库不须要连接加载,由于全部源码都是头文件,相似于 STL 这样不依赖任何第三方库,在任何平台上可移植;总共也就 5 个头文件,有效代码量不足 100 行;API 都是宏定义的,因此不存在调用开销;最后,每一个协程的空间开销是 2 个字节(是的,你没有看错,就是一个 short 单位的“栈”!)固然这种精简是要以使用上的局限为代价的,接下来的分析会说明这一点。golang
先来看看 protothreads 做者,Adam Dunkels,一位来自瑞典皇家理工学院的计算机天才帅哥。话说这哥们挺有意思的,写了好多轻量级的做品,都是 BSD 许可证。顺便说一句,轻量级开源软件全世界多如牛毛,可像这位哥们写得如此出名的并很少。好比嵌入式网络操做系统 Contiki,国人耳熟能详的 TCP/IP 协议栈uIP 和 lwIP 也是出自其手。上述这些软件都是通过数十年企业级应用的考验,质量之高可想而知。算法
不少人会好奇如此“蝇量级”的代码到底是怎么实现的呢?在分析 protothreads 源码以前,我先来给你们补一补 C 语言的基础课;-^)简而言之,这利用了 C 语言特性上的一个“奇技淫巧”,并且这种技巧恐怕连许多具有十年以上经验的 C 程序员老手都不见得知晓。固然这里先要声明我不是推荐你们都这么用,实际上这是以破坏语言的代码规范为代价,在一些严肃的项目工程中须要谨慎对待,除非你想被炒鱿鱼。shell
下面的教程来自于一位 ARM 工程师、天才黑客 Simon Tatham(开源 Telnet/SSH 客户端 PuTTY 和汇编器NASM 的做者,吐槽一句,PuTTY的源码号称是全部正式项目里最难 hack 的 C,你应该猜到做者是什么语言出身)的博文:Coroutines in C。中文译文在这里。编程
咱们知道 python 的 yield 语义功能相似于一种迭代生成器,函数会保留上次的调用状态,并在下次调用时会从上个返回点继续执行。用 C 语言来写就像这样:
1
2
3
4
5
|
int
function(
void
) {
int
i;
for
(i = 0; i < 10; i++)
return
i;
/* won't work, but wouldn't it be nice */
}
|
连续对它调用 10 次,它能分别返回 0 到 9。该怎样实现呢?能够利用 goto 语句,若是咱们在函数中加入一个状态变量,就能够这样实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
int
function(
void
) {
static
int
i, state = 0;
switch
(state) {
case
0:
goto
LABEL0;
case
1:
goto
LABEL1;
}
LABEL0:
/* start of function */
for
(i = 0; i < 10; i++) {
state = 1;
/* so we will come back to LABEL1 */
return
i;
LABEL1:;
/* resume control straight after the return */
}
}
|
这个方法是可行的。咱们在全部须要 yield 的位置都加上标签:起始位置加一个,还有全部 return 语句以后都加一个。每一个标签用数字编号,咱们在状态变量中保存这个编号,这样就能在咱们下次调用时告诉咱们应该跳到哪一个标签上。每次返回前,更新状态变量,指向到正确的标签;不论调用多少次,针对状态变量的 switch 语句都能找到咱们要跳转到的位置。
但这仍是难看得很。最糟糕的部分是全部的标签都须要手工维护,还必须保证函数中的标签和开头 switch 语句中的一致。每次新增一个 return 语句,就必须想一个新的标签名并将其加到 switch 语句中;每次删除 return 语句时,一样也必须删除对应的标签。这使得维护代码的工做量增长了一倍。
仔细想一想,其实咱们能够不用 switch 语句来决定要跳转到哪里去执行,而是直接利用 switch 语句自己来实现跳转:
1
2
3
4
5
6
7
8
9
10
11
|
int
function(
void
) {
static
int
i, state = 0;
switch
(state) {
case
0:
/* start of function */
for
(i = 0; i < 10; i++) {
state = 1;
/* so we will come back to "case 1" */
return
i;
case
1:;
/* resume control straight after the return */
}
}
}
|
酷!没想到 switch-case 语句能够这样用,其实说白了 C 语言就是脱胎于汇编语言的,switch-case 跟 if-else 同样,无非就是汇编的条件跳转指令的另类实现而已(这也间接解释了为什么汇编程序员常常揶揄 C 语言是“大便同样的代码”)。咱们还能够用 __LINE__ 宏使其更加通常化:
1
2
3
4
5
6
7
8
9
10
11
|
int
function(
void
) {
static
int
i, state = 0;
switch
(state) {
case
0:
/* start of function */
for
(i = 0; i < 10; i++) {
state = __LINE__ + 2;
/* so we will come back to "case __LINE__" */
return
i;
case
__LINE__:;
/* resume control straight after the return */
}
}
}
|
这样一来咱们能够用宏提炼出一种范式,封装成组件:
1
2
3
4
5
6
7
8
9
10
|
#define Begin() static int state=0; switch(state) { case 0:
#define Yield(x) do { state=__LINE__; return x; case __LINE__:; } while (0)
#define End() }
int
function(
void
) {
static
int
i;
Begin();
for
(i = 0; i < 10; i++)
Yield(i);
End();
}
|
怎么样,看起来像不像发明了一种全新的语言?实际上咱们利用了 switch-case 的分支跳转特性,以及预编译的 __LINE__ 宏,实现了一种隐式状态机,最终实现了“yield 语义”。
还有一个问题,当你欢天喜地地将这种不为人知的技巧运用到你的项目中,并成功地拿去向你的上司邀功问赏的时候,你的上司会怎样看待你的代码呢?你的宏定义中大括号没有匹配完整,在代码块中包含了未用到的 case,Begin 和 Yield 宏里面不完整的七拼八凑……你简直就是公司里不遵照编码规范的反面榜样!
别着急,在原文中 Simon Tatham 大牛帮你找到一个坚决的反驳理由,我以为对程序员来讲简直是金玉良言。
将编程规范用在这里是不对的。文章里给出的示例代码不是很长,也不很复杂,即使以状态机的方式改写仍是可以看懂的。可是随着代码愈来愈长,改写的难度将愈来愈大,改写对直观性形成的损失也变得至关至关大。
想想,一个函数若是包含这样的小代码块:
1
2
3
|
case
STATE1:
/* perform some activity */
if
(condition) state = STATE2;
else
state = STATE3;
|
对于看代码的人说,这和包含下面小代码块的函数没有多大区别:
1
2
3
|
LABEL1:
/* perform some activity */
if
(condition)
goto
LABEL2;
else
goto
LABEL3;
|
是的,这两个函数的结构在视觉上是同样的,而对于函数中实现的算法,两个函数都同样不利于查看。由于你使用协程的宏而炒你鱿鱼的人,同样会由于你写的函数是由小块的代码和 goto 语句组成而吼着炒了你。只是此次他们没有冤枉你,由于像那样设计的函数会严重扰乱算法的结构。
编程规范的目标就是为了代码清晰。若是将一些重要的东西,像 switch、return 以及 case 语句,隐藏到起“障眼”做用的宏中,从编程规范的角度讲,能够说你扰乱了程序的语法结构,而且违背了代码清晰这一要求。可是咱们这样作是为了突出程序的算法结构,而算法结构偏偏是看代码的人更想了解的。
任何编程规范,坚持牺牲算法清晰度来换取语法清晰度的,都应该重写。若是你的上司由于使用了这一技巧而解雇你,那么在保安把你往外拖的时候要不断告诉他这一点。
原文做者最后给出了一个 MIT 许可证的 coroutine.h 头文件。值得一提的是,正如文中所说,这种协程实现方法有个使用上的局限,就是协程调度状态的保存依赖于 static 变量,而不是堆栈上的局部变量,实际上也没法用局部变量(堆栈)来保存状态,这就使得代码不具有可重入性和多线程应用。后来做者补充了一种技巧,就是将局部变量包装成函数参数传入的一个虚构的上下文结构体指针,而后用动态分配的堆来“模拟”堆栈,解决了线程可重入问题。但这样一来反而有损代码清晰,好比全部局部变量都要写成对象成员的引用方式,特别是局部变量不少的时候很麻烦,再好比宏定义 malloc/free 的玩法过于托大,不易控制,搞很差还增长了被炒鱿鱼的风险(只不过此次是你活该)。
我我的认为,既然协程自己是一种单线程的方案,那么咱们应该假定应用环境是单线程的,不存在代码重入问题,因此咱们能够大胆地使用 static 变量,维持代码的简洁和可读性。事实上咱们也不该该在多线程环境下考虑使用这么简陋的协程,非要用的话,前面提到 glibc 的 ucontext 组件也是一种可行的替代方案,它提供了一种协程私有堆栈的上下文,固然这种用法在跨线程上也并不是没有限制,请仔细阅读联机文档。
感谢 Simon Tatham 的淳淳教诲,接下来咱们能够 hack 一下源码了。先来看看实现 protothreads 的数据结构, 实际上它就是协程的上下文结构体,用以保存状态变量,相信你很快就明白为什么它的“堆栈”只有 2 个字节:
1
2
3
|
struct
pt {
lc_t lc;
}
|
里面只有一个 short 类型的变量,实际上它是用来保存上一次出让点的程序计数器。这也映证了协程比线程的灵活之处,就是协程能够是 stackless 的,若是须要实现的功能很单一,好比像生产者-消费者模型那样用来作事件通知,那么实际上协程须要保存的状态变量仅仅是一个程序计数器便可。像 python generator 也是 stackless 的,固然实现一个迭代生成器可能还须要保留上一个迭代值,前面 C 的例子是用 static 变量保存,你也能够设置成员变量添加到上下文结构体里面。若是你真的不肯定用协程调度时须要保存多少状态变量,那仍是用 ucontext 好了,它的上下文提供了堆栈和信号,可是由用户负责分配资源,详细使用方法见联机文档。。
1
2
3
4
5
6
|
typedef
struct
ucontext {
struct
ucontext_t *uc_link;
sigset_t uc_sigmask;
stack_t uc_stack;
...
} ucontext_t;
|
有点扯远了,回到 protothreads,看看提供的协程“原语”。有两种实现方法,在 ANSI C 下,就是传统的 switch-case 语句:
1
2
3
4
|
#define LC_INIT(s) s = 0; // 源码中是有分号的,一个低级 bug,啊哈~
#define LC_RESUME(s) switch (s) { case 0:
#define LC_SET(s) s = __LINE__; case __LINE__:
#define LC_END(s) }
|
但这种“原语”有个难以察觉的缺陷:就是你没法在 LC_RESUME 和 LC_END (或者包含它们的组件)之间的代码中使用 switch-case语句,由于这会引发外围的 switch 跳转错误!为此,protothreads 又实现了基于 GNU C 的调度“原语”。在 GNU C 下还有一种语法糖叫作标签指针,就是在一个 label 前面加 &&(不是地址的地址,是 GNU 自定义的符号),能够用 void 指针类型保存,而后 goto 跳转:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
typedef
void
* lc_t;
#define LC_INIT(s) s = NULL
#define LC_RESUME(s) \
do
{ \
if
(s != NULL) { \
goto
*s; \
}
}
while
(0)
#define LC_CONCAT2(s1, s2) s1##s2
#define LC_CONCAT(s1, s2) LC_CONCAT2(s1, s2)
#define LC_SET(s) \
do
{ \
LC_CONCAT(LC_LABEL, __LINE__): \
(s) = &&LC_CONCAT(LC_LABEL, __LINE__); \
}
while
(0)
|
好了,有了前面的基础知识,理解这些“原语”就是小菜一叠,下面看看如何创建“组件”,同时也是 protothreads API,咱们先定义四个退出码做为协程的调度状态机:
1
2
3
4
|
#define PT_WAITING 0
#define PT_YIELDED 1
#define PT_EXITED 2
#define PT_ENDED 3
|
下面这些 API 可直接在应用程序中调用:
/* 初始化一个协程,也即初始化状态变量 */ #define PT_INIT(pt) LC_INIT((pt)->lc) /* 声明一个函数,返回值为 char 即退出码,表示函数体内使用了 proto thread,(我的以为有些画蛇添足) */ #define PT_THREAD(name_args) char name_args /* 协程入口点, PT_YIELD_FLAG=0表示出让,=1表示不出让,放在 switch 语句前面,下次调用的时候能够跳转到上次出让点继续执行 */ #define PT_BEGIN(pt) { char PT_YIELD_FLAG = 1; LC_RESUME((pt)->lc) /* 协程退出点,至此一个协程算是终止了,清空全部上下文和标志 */ #define PT_END(pt) LC_END((pt)->lc); PT_YIELD_FLAG = 0; \ PT_INIT(pt); return PT_ENDED; } /* 协程出让点,若是此时协程状态变量 lc 已经变为 __LINE__ 跳转过来的,那么 PT_YIELD_FLAG = 1,表示从出让点继续执行。 */ #define PT_YIELD(pt) \ do { \ PT_YIELD_FLAG = 0; \ LC_SET((pt)->lc); \ if(PT_YIELD_FLAG == 0) { \ return PT_YIELDED; \ } \ } while(0) /* 附加出让条件 */ #define PT_YIELD_UNTIL(pt, cond) \ do { \ PT_YIELD_FLAG = 0; \ LC_SET((pt)->lc); \ if((PT_YIELD_FLAG == 0) || !(cond)) { \ return PT_YIELDED; \ } \ } while(0) /* 协程阻塞点(blocking),本质上等同于 PT_YIELD_UNTIL,只不过退出码是 PT_WAITING,用来模拟信号量同步 */ #define PT_WAIT_UNTIL(pt, condition) \ do { \ LC_SET((pt)->lc); \ if(!(condition)) { \ return PT_WAITING; \ } \ } while(0) /* 同 PT_WAIT_UNTIL 条件反转 */ #define PT_WAIT_WHILE(pt, cond) PT_WAIT_UNTIL((pt), !(cond)) /* 协程调度,调用协程 f 并检查它的退出码,直到协程终止返回 0,不然返回 1。 */ #define PT_SCHEDULE(f) ((f) < PT_EXITED) /* 这用于非对称协程,调用者是主协程,pt 是和子协程 thread (能够是多个)关联的上下文句柄,主协程阻塞本身调度子协程,直到全部子协程终止 */ #define PT_WAIT_THREAD(pt, thread) PT_WAIT_WHILE((pt), PT_SCHEDULE(thread)) /* 用于协程嵌套调度,child 是子协程的上下文句柄 */ #define PT_SPAWN(pt, child, thread) \ do { \ PT_INIT((child)); \ PT_WAIT_THREAD((pt), (thread)); \ } while(0)
暂时介绍这么多,用户还能够根据本身的需求随意扩展组件,好比实现信号量,你会发现脱离了操做系统环境下的信号量竟是如此简单:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
struct
pt_sem {
unsigned
int
count;
};
#define PT_SEM_INIT(s, c) (s)->count = c
#define PT_SEM_WAIT(pt, s) \
do
{ \
PT_WAIT_UNTIL(pt, (s)->count > 0); \
--(s)->count; \
}
while
(0)
#define PT_SEM_SIGNAL(pt, s) ++(s)->count
|
这些应该不须要我多说了吧,呵呵,让咱们回到最初例举的生产者-消费者模型,看看protothreads表现怎样。
#include "pt-sem.h" #define NUM_ITEMS 32 #define BUFSIZE 8 static struct pt_sem mutex, full, empty; PT_THREAD(producer(struct pt *pt)) { static int produced; PT_BEGIN(pt); for (produced = 0; produced < NUM_ITEMS; ++produced) { PT_SEM_WAIT(pt, &full); PT_SEM_WAIT(pt, &mutex); add_to_buffer(produce_item()); PT_SEM_SIGNAL(pt, &mutex); PT_SEM_SIGNAL(pt, &empty); } PT_END(pt); } PT_THREAD(consumer(struct pt *pt)) { static int consumed; PT_BEGIN(pt); for (consumed = 0; consumed < NUM_ITEMS; ++consumed) { PT_SEM_WAIT(pt, &empty); PT_SEM_WAIT(pt, &mutex); consume_item(get_from_buffer()); PT_SEM_SIGNAL(pt, &mutex); PT_SEM_SIGNAL(pt, &full); } PT_END(pt); } PT_THREAD(driver_thread(struct pt *pt)) { static struct pt pt_producer, pt_consumer; PT_BEGIN(pt); PT_SEM_INIT(&empty, 0); PT_SEM_INIT(&full, BUFSIZE); PT_SEM_INIT(&mutex, 1); PT_INIT(&pt_producer); PT_INIT(&pt_consumer); PT_WAIT_THREAD(pt, producer(&pt_producer) & consumer(&pt_consumer)); PT_END(pt); }
源码包中的 example-buffer.c 包含了可运行的完整示例,我就不所有贴了。总体框架就是一个 asymmetric coroutines,包括一个主协程 driver_thread 和两个子协程 producer 和 consumer ,其实不用多说你们也懂的,代码很是清晰直观。咱们彻底能够经过单线程实现一个简单的事件处理需求,你能够任意添加数十万个协程,几乎不会引发任何额外的系统开销和资源占用。惟一须要留意的地方就是没有一个局部变量,由于 protothreads 是 stackless 的,但这不是问题,首先咱们已经假定运行环境是单线程的,其次在一个简化的需求下也用不了多少“局部变量”。若是在协程出让时须要保存一些额外的状态量,像迭代生成器,只要数目和大小都是肯定而且可控的话,自行扩展协程上下文结构体便可。
固然这不是说 protothreads 是万能的,它只是贡献了一种模型,你要使用它首先就得学会适应它。下面列举一些 protothreads 的使用限制:
官网上还例举了更多实例,都很是实用。另外,一个叫 Craig Graham 的工程师扩展了 pt.h,使得 protothreads 支持 sleep/wake/kill 等操做,文件在此 graham-pt.h。
看到这里,手养的你是否想火烧眉毛地 DIY 一个协程组件呢?哪怕不少动态语言自己已经支持了协程语义,不少 C 程序员仍然倾向于本身实现组件,网上不少开源代码底层用的主要仍是 glibc 的 ucontext 组件,毕竟提供堆栈的协程组件使用起来更加通用方便。你能够本身写一个调度器,而后模拟线程上下文,再而后……你就能搞出一个跨平台的COS了(笑)。GNU Pth 线程库就是这么实现的,其原做者德国人 Ralf S. Engelschall (又是个开源大牛,还写了 OpenSSL 等许多做品)就写了一篇论文教你们如何实现一个线程库。另外 protothreads 官网上也有一大堆推荐阅读。Have fun!
(全文完)转自:http://coolshell.cn/articles/10975.html