1、一个“蝇量级”C语言协程库 http:/ 邮件群发 协程(coroutine)顾名思义就是 “协作的例程”(co-operative routines)。跟具有操作系统概念的线程不一样, 协程是在用 户空间利用程序语言的语法语义就能实现逻辑上类似多任务的编程技巧。实际上协程的概念比线程还要早,按照 Knuth 的说法“子例程是协程的特例”,一个子例程就是一次子函数调用,那么实际上协程就是类 函数一样的程序组件,你可以在一个线程里面轻松创建数十万个协程,就像数十万次函数调用一样。只不过子例程只有一个调用入口起始点,返回之后就结束了,而 协程入口既可以是起始点,又可以从上一个返回点继续执行,也就是
2、说协程之间可以通过 yield 方式转移执行权,对称(symmetric)、平级地调用对方,而不是像例程那样上下级调用关系。当然 Knuth 的“特例”指的是协程也可以模 拟例程那样实现上下 级调用关系,这就叫非对称协程(asymmetric coroutines)。基于事件驱动模型我们举一个例子来看看一种对称协程调用场景,大家最熟悉的“ 生产者-消费者”事件 驱动模型,一个协程负责生产产品并将它 们加入队列,另一个负责从队列中取出产品并使用它。为了提高效率,你想一次增加或删除多个产品。 伪代码可以是这样的:1. # producer coroutine 2.loop 3.while queu
3、e is not full 4. create some new items 5. add the items to queue 6.yield to consumer 7. 8.# consumer coroutine 9.loop 10. while queue is not empty 11. remove some items from queue 12. use the items 13. yield to producer 大多数教材上拿这种模型作为多线程的例子,实际上多线程在此的应用还是显得有点“重量级”,由于缺乏 yield 语义,线程之间不得不使用同步机制来避免产生全局资源的
4、竟态,这就不可避免产生了休眠、调度、切换上下文一类的系统开销,而且线程调度还会产生时序上的不 确定性。而对于协程来说,“挂起” 的概念只不过是转让代码执行权并调用另外的协程,待到转让的协程告一段落后重新得到调用并从挂起点“ 唤醒”,这种协程间 的调用是逻辑上可控的,时序上确定的,可 谓一切尽在掌握中。当今一些具备协程语义的语言,比较重量级的如C#、erlang 、golang,以及轻量级的python、lua、javascript、 ruby,还有函数式的scala、scheme等。相比之下,作为原生态语言的 C 反而处于尴尬的地位,原因在于 C 依赖于一种叫做 栈帧的 例程调用,例程内部的状
5、态量和返回值都保留在堆栈上,这意味着生产者和消费者相互之间无法实现平级调用,当然你可以改写成把生产者作为主例程然后将产品作 为传递参数调用消费者例程,这样的代码写起来费力不讨好而且看起来会很难受,特别当协程数目达到十万数量级,这种写法就过于僵化了。这就引出了协程的概念,如果将每个协程的上下文(比如程序计数器)保存在其它地方而不是堆栈上,协程之间相互调用时,被 调用的协程只要从堆栈以外的地方恢复上次出让点之前的上下文即可,这有点类似于 CPU 的上下文切换,遗憾的是似乎只有更底层的汇编语言才能做到这一点。难道 C 语言只能用多线程吗?幸运的是, C 标准库给我们提供了两种协程调度原语:一种是se
6、tjmp/longjmp ,另一种是ucontext 组件,它们内部(当然是用汇编语言)实现了协程的上下文切换,相较之下前者在应用上会产生相当的不确定性(比如不好封装,具体说明参考联机文档),所以后者应用更广泛一些,网上绝大多数 C 协程库也是基于 ucontext 组件实现的。“蝇量级”的协程库在此,我来介绍一种“蝇量级”的开源 C 协程库 protothreads。 这是一个全部用 ANSI C 写成的库,之所以称为“蝇量级”的,就是说, 实现已 经不能再精简了,几乎就是原 语级别。事实上 protothreads 整个库不需要链接加载,因为所有源码都是头文件,类似于 STL 这样不依赖任
7、何第三方库,在任何平台上可移植;总共也就 5 个头文件,有效代码量不足 100 行;API 都是宏定义的,所以不存在调用开销;最后,每个 协程的空间开销是 2 个字节(是的,你没有看错,就是一个 short 单位的“栈”!)当然这种精 简是要以使用上的局限 为代价的,接下来的分析会说明这一点。先来看看 protothreads 作者,Adam Dunkels,一位来自瑞典皇家理工学院的计算机天才帅哥。话说这哥们挺有意思的,写了好多轻量级的作品,都是 BSD 许可证。顺便说一句,轻量级开源软件全世界多如牛毛,可像这位哥们写得如此出名的并不多。比如嵌入式网络操作系统 Contiki ,国人耳熟能详
8、的 TCP/IP 协议栈 uIP 和 lwIP 也是出自其手。上述这些软件都是经过数十年企业级应用的考验,质量之高可想而知。很多人会好奇如此“ 蝇量级 ”的代码究竟是怎么实现 的呢?在分析 protothreads 源码之前,我先来给大家补一补 C 语言的基础课;-)简而言之,这利用了 C 语言特性上的一个“ 奇技淫巧 ”,而且 这种技巧恐怕连许多具备十年以上经验的 C 程序员老手都不见得知晓。当然这里先要声明我不是推荐大家都这么用,实际上这是以破坏语言的代码规范为代价,在一些严肃的项目工程中需要谨慎对待,除非你 想被炒鱿鱼。C 语言的“yield 语义”下面的教程来自于一位 ARM 工程师、
9、天才黑客 Simon Tatham(开源 Telnet/SSH 客户端 PuTTY 和汇编器 NASM 的作者,吐槽一句,PuTTY的源码号称是所有正式项目里最难 hack 的 C,你应该猜到作者是什么 语言出身)的博文:Coroutines in C。中文译文在这里。我们知道 python 的 yield 语义功能类似于一种迭代生成器,函数会保留上次的调用状态,并在下次调用时会从上个返回点继续执行。用 C 语言来写就像这样:1. int function(void) 2. int i; 3. for (i = 0; i lc) 3. 4./* 声明一个函数,返回值为 char 即退出码,表示
10、函数体内使用了 proto thread,(个人觉得有些多此一举) */ 5.#define PT_THREAD(name_args) char name_args 6. 7./* 协程入口点, PT_YIELD_FLAG=0表示出 让,=1表示不出让,放在 switch 语句前面,下次调用的时候可以跳转到上次出让点继续执行 */ 8.#define PT_BEGIN(pt) char PT_YIELD_FLAG = 1; LC_RESUME(pt)-lc) 9. 10. /* 协程退出点,至此一个协程算是终止了,清空所有上下文和标志*/ 11. #define PT_END(pt) LC_E
11、ND(pt)-lc); PT_YIELD_FLAG = 0; 12. PT_INIT(pt); return PT_ENDED; 13. 14. /* 协程出让点,如果此时协程状态变量 lc 已经变为 _LINE_跳转过来的,那么 PT_YIELD_FLAG = 1,表示从出 让点继续执行。 */ 15. #define PT_YIELD(pt) 16. do 17. PT_YIELD_FLAG = 0; 18. LC_SET(pt)-lc); 19. if(PT_YIELD_FLAG = 0) 20. return PT_YIELDED; 21. 22. while(0) 23. 24. /
12、* 附加出让条件 */ 25. #define PT_YIELD_UNTIL(pt, cond) 26. do 27. PT_YIELD_FLAG = 0; 28. LC_SET(pt)-lc); 29. if(PT_YIELD_FLAG = 0) | !(cond) 30. return PT_YIELDED; 31. 32. while(0) 33. 34. /* 协程阻塞点(blocking),本质上等同于 PT_YIELD_UNTIL,只不过退出码是 PT_WAITING,用来模拟信号量同步 */ 35. #define PT_WAIT_UNTIL(pt, condition) 36.
13、 do 37. LC_SET(pt)-lc); 38. if(!(condition) 39. return PT_WAITING; 40. 41. while(0) 42. 43. /* 同 PT_WAIT_UNTIL 条件反转 */ 44. #define PT_WAIT_WHILE(pt, cond) PT_WAIT_UNTIL(pt), !(cond) 45. 46. /* 协程调度,调用协程 f 并检查它的退出码,直到协程终止返回0,否则返回 1。 */ 47. #define PT_SCHEDULE(f) (f) count = c 6. 7.#define PT_SEM_WAIT
14、(pt, s) 8. do 9. PT_WAIT_UNTIL(pt, (s)-count 0); 10. -(s)-count; 11. while(0) 12. 13. #define PT_SEM_SIGNAL(pt, s) +(s)-count 这些应该不需要我多说了吧,呵呵,让我们回到最初例举的生产者-消费者模型,看看protothreads表现怎样。Protothreads实战1. #include “pt-sem.h“ 2. 3.#define NUM_ITEMS 32 4.#define BUFSIZE 8 5. 6.static struct pt_sem mutex, ful
15、l, empty; 7. 8.PT_THREAD(producer(struct pt *pt) 9. 10. static int produced; 11. 12. PT_BEGIN(pt); 13. for (produced = 0; produced NUM_ITEMS; +produced) 14. PT_SEM_WAIT(pt, 15. PT_SEM_WAIT(pt, 16. add_to_buffer(produce_item(); 17. PT_SEM_SIGNAL(pt, 18. PT_SEM_SIGNAL(pt, 19. 20. PT_END(pt); 21. 22. 2
16、3. PT_THREAD(consumer(struct pt *pt) 24. 25. static int consumed; 26. 27. PT_BEGIN(pt); 28. for (consumed = 0; consumed NUM_ITEMS; +consumed) 29. PT_SEM_WAIT(pt, 30. PT_SEM_WAIT(pt, 31. consume_item(get_from_buffer(); 32. PT_SEM_SIGNAL(pt, 33. PT_SEM_SIGNAL(pt, 34. 35. PT_END(pt); 36. 37. 38. PT_THR
17、EAD(driver_thread(struct pt *pt) 39. 40. static struct pt pt_producer, pt_consumer; 41. 42. PT_BEGIN(pt); 43. PT_SEM_INIT( 44. PT_SEM_INIT( 45. PT_SEM_INIT( 46. PT_INIT( 47. PT_INIT( 48. PT_WAIT_THREAD(pt, producer( 49. PT_END(pt); 50. 源码包中的 example-buffer.c 包含了可运行的完整示例,我就不全部贴了。整体框架就是一个 asymmetric c
18、oroutines,包括一个主协程 driver_thread 和两个子协程 producer 和 consumer ,其实不用多说大家也懂的,代码非常清晰直观。我们完全可以通过单线程实现一个简单的事件处理需求,你可以任意添加数十万个协程,几乎不会引起任何额外的 系统开销和资源占用。唯一需要留意的地方就是没有一个局部变量,因为 protothreads 是 stackless 的,但这不是问题,首先我们已经假定运行环境是单线程的,其次在一个 简化的需求下也用不了多少“ 局部变量 ”。如果在协程出让时需要保存一些额外的状态量, 像迭代生成器,只要数目和大小都是确定并且可控的话,自行扩展协程上下文
19、结构体即可。当然这不是说 protothreads 是万能的,它只是贡献了一种模型,你要使用它首先就得学会适应它。下面列 举一些 protothreads 的使用限制: 由于协程是stackless的,尽量不要使用局部变量,除非该变量对于协程状态是无关紧要的,同理可推,协程所在的代码是不可重入的。 如果协程使用 switch-case 原语封装的组件,那么禁止在实际应用中使用 switch-case 语句,除非用 GNU C 语法中的标签指针 替代。 一个协程内部可以调用其它例程,比如库函数或系统调用,但必须保证该例程是非阻塞的,否则所在线程内的所有协程都将被阻塞。毕竟线程才是执行的最小单位,
20、协程不过是按“时间片轮度”的例程而已。官网上还例举了更多实例,都非常实用。另外,一个叫 Craig Graham 的工程师扩展了 pt.h,使得 protothreads 支持 sleep/wake/kill 等操作,文件在此 graham-pt.h。协程库 DIY 攻略看到这里,手养的你是否想迫不及待地 DIY 一个协程组件呢?哪怕很多动态语言本身已经支持了协程语义,很多 C 程序员仍然倾向于自己实现组件,网上很多开源代码底层用的主要还是 glibc 的 ucontext 组件,毕竟提供堆栈的协程组件使用起来更加通用方便。你可以自己写一个调度器,然后模拟线程上下文,再然后你就能搞出一个跨平台的COS了(笑)。 GNU Pth 线程库就是这么实现的,其原作者德国人 Ralf S. Engelschall (又是个开源大牛,还写了 OpenSSL 等许多作品)就写了一篇论文教大家如何实现一个线程库。另外 protothreads 官网上也有一大堆推荐阅读。Have fun!原文链接:http:/