一个“蝇量级” C 语言协程库
2015-09-30 20:52
435 查看
一个“蝇量级” C 语言协程库
http://coolshell.cn/articles/10975.html协程(coroutine)顾名思义就是“协作的例程”(co-operative routines)。跟具有操作系统概念的线程不一样,协程是在用户空间利用程序语言的语法语义就能实现逻辑上类似多任务的编程技巧。实际上协程的概念比线程还要早,按照 Knuth 的说法“子例程是协程的特例”,一个子例程就是一次子函数调用,那么实际上协程就是类函数一样的程序组件,你可以在一个线程里面轻松创建数十万个协程,就像数十万次函数调用一样。只不过子例程只有一个调用入口起始点,返回之后就结束了,而协程入口既可以是起始点,又可以从上一个返回点继续执行,也就是说协程之间可以通过
yield 方式转移执行权,对称(symmetric)、平级地调用对方,而不是像例程那样上下级调用关系。当然 Knuth 的“特例”指的是协程也可以模拟例程那样实现上下级调用关系,这就叫非对称协程(asymmetric coroutines)。
基于事件驱动模型
我们举一个例子来看看一种对称协程调用场景,大家最熟悉的“生产者-消费者”事件驱动模型,一个协程负责生产产品并将它们加入队列,另一个负责从队列中取出产品并使用它。为了提高效率,你想一次增加或删除多个产品。伪代码可以是这样的:当今一些具备协程语义的语言,比较重量级的如C#、erlang、golang,以及轻量级的python、lua、javascript、ruby,还有函数式的scala、scheme等。相比之下,作为原生态语言的 C 反而处于尴尬的地位,原因在于 C 依赖于一种叫做栈帧的例程调用,例程内部的状态量和返回值都保留在堆栈上,这意味着生产者和消费者相互之间无法实现平级调用,当然你可以改写成把生产者作为主例程然后将产品作为传递参数调用消费者例程,这样的代码写起来费力不讨好而且看起来会很难受,特别当协程数目达到十万数量级,这种写法就过于僵化了。
这就引出了协程的概念,如果将每个协程的上下文(比如程序计数器)保存在其它地方而不是堆栈上,协程之间相互调用时,被调用的协程只要从堆栈以外的地方恢复上次出让点之前的上下文即可,这有点类似于 CPU 的上下文切换,遗憾的是似乎只有更底层的汇编语言才能做到这一点。
难道 C 语言只能用多线程吗?幸运的是,C 标准库给我们提供了两种协程调度原语:一种是setjmp/longjmp,另一种是 ucontext
组件,它们内部(当然是用汇编语言)实现了协程的上下文切换,相较之下前者在应用上会产生相当的不确定性(比如不好封装,具体说明参考联机文档),所以后者应用更广泛一些,网上绝大多数 C 协程库也是基于 ucontext 组件实现的。
“蝇量级”的协程库
在此,我来介绍一种“蝇量级”的开源 C 协程库 protothreads。这是一个全部用 ANSI C 写成的库,之所以称为“蝇量级”的,就是说,实现已经不能再精简了,几乎就是原语级别。事实上protothreads 整个库不需要链接加载,因为所有源码都是头文件,类似于 STL 这样不依赖任何第三方库,在任何平台上可移植;总共也就 5 个头文件,有效代码量不足 100 行;API 都是宏定义的,所以不存在调用开销;最后,每个协程的空间开销是 2 个字节(是的,你没有看错,就是一个 short 单位的“栈”!)当然这种精简是要以使用上的局限为代价的,接下来的分析会说明这一点。
先来看看 protothreads 作者,Adam Dunkels,一位来自瑞典皇家理工学院的计算机天才帅哥。话说这哥们挺有意思的,写了好多轻量级的作品,都是 BSD 许可证。顺便说一句,轻量级开源软件全世界多如牛毛,可像这位哥们写得如此出名的并不多。比如嵌入式网络操作系统 Contiki,国人耳熟能详的
TCP/IP 协议栈 uIP和 lwIP 也是出自其手。上述这些软件都是经过数十年企业级应用的考验,质量之高可想而知。
很多人会好奇如此“蝇量级”的代码究竟是怎么实现的呢?在分析 protothreads 源码之前,我先来给大家补一补 C 语言的基础课;-^)简而言之,这利用了 C 语言特性上的一个“奇技淫巧”,而且这种技巧恐怕连许多具备十年以上经验的 C 程序员老手都不见得知晓。当然这里先要声明我不是推荐大家都这么用,实际上这是以破坏语言的代码规范为代价,在一些严肃的项目工程中需要谨慎对待,除非你想被炒鱿鱼。
C 语言的“yield 语义”
下面的教程来自于一位 ARM 工程师、天才黑客 Simon Tatham(开源 Telnet/SSH 客户端 PuTTY 和汇编器NASM 的作者,吐槽一句,PuTTY的源码号称是所有正式项目里最难hack 的 C,你应该猜到作者是什么语言出身)的博文:Coroutines
in C。中文译文在这里。
我们知道 python 的 yield 语义功能类似于一种迭代生成器,函数会保留上次的调用状态,并在下次调用时会从上个返回点继续执行。用 C 语言来写就像这样:
但这还是难看得很。最糟糕的部分是所有的标签都需要手工维护,还必须保证函数中的标签和开头 switch 语句中的一致。每次新增一个 return 语句,就必须想一个新的标签名并将其加到 switch 语句中;每次删除 return 语句时,同样也必须删除对应的标签。这使得维护代码的工作量增加了一倍。
仔细想想,其实我们可以不用 switch 语句来决定要跳转到哪里去执行,而是直接利用 switch 语句本身来实现跳转:
还有一个问题,当你欢天喜地地将这种鲜为人知的技巧运用到你的项目中,并成功地拿去向你的上司邀功问赏的时候,你的上司会怎样看待你的代码呢?你的宏定义中大括号没有匹配完整,在代码块中包含了未用到的 case,Begin 和 Yield 宏里面不完整的七拼八凑……你简直就是公司里不遵守编码规范的反面榜样!
别着急,在原文中 Simon Tatham 大牛帮你找到一个坚定的反驳理由,我觉得对程序员来说简直是金玉良言。
将编程规范用在这里是不对的。文章里给出的示例代码不是很长,也不很复杂,即便以状态机的方式改写还是能够看懂的。但是随着代码越来越长,改写的难度将越来越大,改写对直观性造成的损失也变得相当相当大。
想一想,一个函数如果包含这样的小代码块:
编程规范的目标就是为了代码清晰。如果将一些重要的东西,像 switch、return 以及 case 语句,隐藏到起“障眼”作用的宏中,从编程规范的角度讲,可以说你扰乱了程序的语法结构,并且违背了代码清晰这一要求。但是我们这样做是为了突出程序的算法结构,而算法结构恰恰是看代码的人更想了解的。
任何编程规范,坚持牺牲算法清晰度来换取语法清晰度的,都应该重写。如果你的上司因为使用了这一技巧而解雇你,那么在保安把你往外拖的时候要不断告诉他这一点。
原文作者最后给出了一个 MIT 许可证的 coroutine.h 头文件。值得一提的是,正如文中所说,这种协程实现方法有个使用上的局限,就是协程调度状态的保存依赖于
static 变量,而不是堆栈上的局部变量,实际上也无法用局部变量(堆栈)来保存状态,这就使得代码不具备可重入性和多线程应用。后来作者补充了一种技巧,就是将局部变量包装成函数参数传入的一个虚构的上下文结构体指针,然后用动态分配的堆来“模拟”堆栈,解决了线程可重入问题。但这样一来反而有损代码清晰,比如所有局部变量都要写成对象成员的引用方式,特别是局部变量很多的时候很麻烦,再比如宏定义 malloc/free 的玩法过于托大,不易控制,搞不好还增加了被炒鱿鱼的风险(只不过这次是你活该)。
我个人认为,既然协程本身是一种单线程的方案,那么我们应该假定应用环境是单线程的,不存在代码重入问题,所以我们可以大胆地使用 static 变量,维持代码的简洁和可读性。事实上我们也不应该在多线程环境下考虑使用这么简陋的协程,非要用的话,前面提到 glibc 的 ucontext 组件也是一种可行的替代方案,它提供了一种协程私有堆栈的上下文,当然这种用法在跨线程上也并非没有限制,请仔细阅读联机文档。
Protothreads的上下文
感谢 Simon Tatham 的淳淳教诲,接下来我们可以 hack 一下源码了。先来看看实现 protothreads 的数据结构, 实际上它就是协程的上下文结构体,用以保存状态变量,相信你很快就明白为何它的“堆栈”只有 2 个字节:ucontext 好了,它的上下文提供了堆栈和信号,但是由用户负责分配资源,详细使用方法见联机文档。。
Protothreads的原语和组件
有点扯远了,回到 protothreads,看看提供的协程“原语”。有两种实现方法,在 ANSI C 下,就是传统的 switch-case 语句:
Protothreads实战
当然这不是说 protothreads 是万能的,它只是贡献了一种模型,你要使用它首先就得学会适应它。下面列举一些 protothreads 的使用限制:
由于协程是stackless的,尽量不要使用局部变量,除非该变量对于协程状态是无关紧要的,同理可推,协程所在的代码是不可重入的。
如果协程使用 switch-case 原语封装的组件,那么禁止在实际应用中使用 switch-case 语句,除非用 GNU C 语法中的标签指针替代。
一个协程内部可以调用其它例程,比如库函数或系统调用,但必须保证该例程是非阻塞的,否则所在线程内的所有协程都将被阻塞。毕竟线程才是执行的最小单位,协程不过是按“时间片轮度”的例程而已。
官网上还例举了更多实例,都非常实用。另外,一个叫 Craig Graham 的工程师扩展了 pt.h,使得 protothreads 支持
sleep/wake/kill 等操作,文件在此 graham-pt.h。
协程库 DIY 攻略
看到这里,手养的你是否想迫不及待地 DIY 一个协程组件呢?哪怕很多动态语言本身已经支持了协程语义,很多 C 程序员仍然倾向于自己实现组件,网上很多开源代码底层用的主要还是 glibc 的 ucontext 组件,毕竟提供堆栈的协程组件使用起来更加通用方便。你可以自己写一个调度器,然后模拟线程上下文,再然后……你就能搞出一个跨平台的COS了(笑)。GNU Pth 线程库就是这么实现的,其原作者德国人 RalfS. Engelschall (又是个开源大牛,还写了 OpenSSL 等许多作品)就写了一篇论文教大家如何实现一个线程库。另外
protothreads 官网上也有一大堆推荐阅读。Have fun!
(全文完)
相关文章推荐
- 位记录——Windows 7已安装Sublime Text 3、cynwin、SublimeClang
- 单链表
- ocp-19
- 【Linux命令-文件管理类】cmp命令
- ocp-18
- ocp-17
- ocp-16
- ocp-15
- linux Gtk基本内容学习
- 2015年9月之 远方之远
- 2015年9月之 远方之远
- OL2中的多地图联动展示
- python实现读取命令行参数
- zigbee学习之ADC采集芯片内部的温度
- centos 6.7 GRUB配置
- 应用spring mvc 在controller间跳转 重定向
- 程序员不得不说的开源库
- OL2中的多地图联动展示
- 当我爱上了英语
- MyEclipse 安装JQuery自动提示功能