linux的进程/线程/协程系列5:协程的发展复兴与实现现状

协程的发展复兴与实现现状

  • 前言
  • 本篇摘要:
  • 1. 协同制的发展史
    • 1.1 协同工作制的提出
    • 1.2 自顶向下,无需协同
    • 1.3 协同式思想的应用
  • 2. 协程的复兴
    • 2.1 高并发带来的问题
    • 2.2 制衡之道——协程
  • 3. 协程的优劣势分析
    • 3.1 优势
    • 3.2 劣势
  • 4. 协程的两个特性
    • 4.1 有栈/无栈
    • 4.2 对称/非对称
  • 5. 协程的实现
    • 5.1 实现方式
    • 5.2 Python的迭代器和生成器
    • 5.3 当前协程库现状简述
  • 参考文献

前言

最近学习自动驾驶系统时,碰到协程的概念。进程和线程已经迷了,又来个协程,看了很多资料后决定作总结,概括三者联系和区别,最后归结到协程在自动驾驶中的应用。初级程序员目标是搞清三者概念并应用到实际中,而资深工程师则需要在系统层面考虑三者的性能及实现代价,因为直到如今三者仍是Linux内核和各类编程语言持续更新完善的模块之一,所以理清三者的关系、编程应用和考量性能是进阶程序员的必修课。行文目的,是全面讲解进程/线程/协程这一系列繁复的概念和知识点,尽量做到知识点讲精讲细讲全,甄别模糊概念,同时兼顾源码及编程实现,最后归结到Apollo中的协程实现。

本系列文章分九篇讲解:

  1. 《进程到协程的演化》:涉及进程发展的历史和计算机系统结构知识;
  2. 《进程/线程的系统命令》:总结进程/线程有关的系统命令,让大家有一个初步感性认识,而不只是生涩的文字;
  3. 《查看linux内核源码——vim+ctags/find+grep》:如何查看linux系统源码,源码第一手资料,重要性不言而喻;
  4. 《进程/线程相关知识总结》:进程/线程知识串讲,进程、线程和协程一脉相承,对进程理解透彻,线程和协程的难点也会迎刃而解。
  5. 《协程的发展复兴与实现现状》:看Conway Melvin如何总结出协同工作机制,协程蛰伏原因及发展复兴,详解协程的两个特性:有栈/无栈、对称/非对称,最后引出当前协程库现状。
  6. 《各协程库对比分析及libgo/tbox》:分析当前各协程库的优劣势,并给出我的推荐:libgo,并分析其源码目录,最后提引性能神器tbox。
  7. 《进程/线程/协程的性质辨析和实现对比》:列表分析三者的性质,同时根据源码,挑重点总结实现区别。
  8. 《全面弄懂进程/线程/协程的内存调度》:三者在内存中的调度,,带读者领略内存调度的魅力。
  9. 《libgo功能及源码详解》:分析libgo/tbox的原理和集成功能,给出源码简读和样例。
  10. 《Apollo中的协程概述》:协程在Apollo中的应用,展示及分析Apollo协程的源码及优缺点。

本篇摘要:

本篇从协程机制的起源、发展史及当前现状介绍协程。第一章介绍协同工作制的起源及发展,协程蛰伏的原因及协同式思想的实际应用;第二章描述协程复兴,通过高并发带来的,引出协程风行的历史机遇;第三章详解协程的两个特点:有栈/无栈、对称/非对称;第四章讲解协程的实现方式,引出Python生成器中的协程思想,最后简述当前协程库现状。

1. 协同制的发展史

1.1 协同工作制的提出

协程这个概念,最近这几年可是相当地流行。然而协程并不是最近才出现的新技术,恰恰相反,协程是一项古老的技术。七十年代,Donald Knuth在他的神作《The Art of Computer Programming》中将Coroutine的提出归于Conway Melvin(Conway’s Law的提出者)。早在1958年,梅尔文·康威(Conway Melvin)在用COBOL编译器做词法分析和语法分析时,便提出了基于让出(yield)/恢复(resume)方法的协同工作机制,并于1963年,发表了一篇论文来说明自己的这种思想,虽然半个多世纪过去了,还是有人有幸找到了这篇论文:《Design of a Separable Transition-Diagram Compiler*》。

以现代眼光来看,高级语言编译实际由多个步骤组合而成:词法解析、语法解析、语法树构建,以及优化和目标代码生成等。编译实质上就是从源程序出发,依次将这些步骤的输出作为下一步的输入,最终输出目标代码。在现代计算机上实现这种管道式的架构毫无困难:只需要依次运行,中间结果存为中间文件或放入内存即可。GCC和Clang编译器,以及ANTLR构建的编译器,都遵循这种设计。

在Conway的设计里,词法和语法解析不再是独立运行的步骤,而是交织在一起。梅尔文·康威提出的编译协同工作机制,其核心思想在于:编译器的控制流在词法和语法解析之间来回切换。当词法模块读入足够多的token时,控制流交给语法分析;当语法分析消化完所有token后,控制流交给词法分析。词法和语法分别独立维护自身的运行状态,并且具备主动让出和恢复的能力。Conway构建的这种协同工作机制,需要参与者“让出(yield)”控制流时,记住自身状态,以便在控制流返回时能从上次让出的位置恢复(resume)执行,而编译器的控制流在词法分析和语法分析之间来回切换。简言之,协程的全部精神就在于控制流的主动让出和恢复。我们熟悉的Subroutines(即子过程调用,在很多高级语言中也称其为函数)可以看作在返回时让出控制流的一种特殊协程,其内部状态在返回时被丢弃了,因此不存在“恢复”这个操作。coroutine就是可以中断并恢复执行的subroutine。在当时条件的限制下,由梅尔文·康威提出的这种让出/恢复模式的协作程序被认为是最早的协程概念,并且基于这种思想可以打造新的COBOL编译器1

以现在眼光来看,编译器的实现并非必需协程。然而,Conway用协程实现COBOL编译器在当时绝不是舍近求远。首先,从原理上,因为COBOL并不是LL(1)型语法,无法简单构建一个以词法分析为子过程的自动机。其次,当年计算机依赖于磁带存储设备,只支持顺序存储。也就是说,依次执行编译步骤并依靠中间文件通信的设计是不现实的,各步骤必须同步前进。正是这样的现实局限和设计需要,催生了协程的概念。

而线程是源于Windows系统的概念,早期版本的Linux并不支持线程,而协程正好代替,因此协程又被称为“轻量级线程”或“用户态线程”。比较有名的有:GNU Pth和libtask(Go语言的作者之一Russ Cox的作品),但它们并没有得到广泛关注和应用。后来随着Linux线程的出现,协程的工作可以由线程代替完成,协程也蛰伏起来,等待着时机。

1.2 自顶向下,无需协同

虽然协程伴随着高级语言诞生,却没有能像子过程那样成为通用编程语言的基本元素。从1963年首次提出到上世纪九十年代,我们在ALOGL、Pascal、C、FORTRAN等主流的命令式编程语言中都没有看到原生的协程支持。协程只稀疏地出现在Simula、Modular-2(Pascal升级版)和Smalltalk等相对小众的语言中。作为一个比子进程更加通用的概念,在实际编程中却没有取代子进程,不得不说出乎意外。但如果结合当时的程序设计思想看,又在意料之中:协程不符合那个时代所崇尚的“自顶向下”的程序设计思想,自然也就不会成为当时主流的命令式编程语言的一部分。

正如面向对象的语言是围绕面向对象的开发理念设计一样,命令式编程语言是围绕自顶向下的开发理念设计的。在这种理念的指导下,程序被切分为一个主程序和大大小小的子模块,每个子模块又可能调用更多子模块。C家族语言的main()函数就是这种自顶向下思想的体现。在这种理念指导下,各模块形成层次调用关系,而程序设计就是制作这些子过程。在“自顶向下”这种层次化的理念下,具有鲜明层次的子过程调用成为软件系统最自然的组织方式,也是理所当然。相较之下,具有执行中让出和恢复功能的协程在这种架构下无用武之地。可以说,自顶向下的设计思想从一开始就排除了对协程的需求。其后的结构化编程思想,更进一步强化了“子过程调用作为唯一控制结构”的基本假设。在这样的指导思想下,协程没有成为当时编程语言的一等公民。

但作为一种易于理解的控制结构,协程的概念渗入到软件设计的许多方面。在结构化编程思想一统天下之时,Knuth曾专门写过一篇《Structured Programming with GOTO》来为GOTO语句辩护。在他列出的几条GOTO可以方便编程且不破坏程序结构的例子中,有一个(例子7b)就是用GOTO实现协程控制结构。相较之下,不用GOTO的“结构化”代码反而失去了良好的结构。当然,追求实际结果的工业界对于学界这场要不要剔除GOTO的争论并不感冒。当时许多语言都附带了不建议使用的GOTO语句,显得左右逢源。2这方面一个最明显的例子就是Java——语言本身预留了goto关键字,而编译器却没提供任何支持,在这场争论中做足了中间派。

总之,协程的思想和当时的主流不符合,抢占式的线程可以解决大部分的问题,所以协程就这样怀才不遇了。

1.3 协同式思想的应用

不过在实践中,协程的思想频繁应用于任务调度和流处理。例如,Unix管道就可以看成是众多命令间的协同操作。当然,管道的现代实现都以pipe()系统调用和进程间的通信为基础,而非简单遵循协程的yield/resume语法。

许多协同式多任务操作系统,也可以看成协程运行系统。说到协同式多任务系统,一个常见的误区是认为协同式调度比抢占式调度“低级”,因为我们所熟悉的桌面操作系统,都是从协同式调度(如Windows 3.2、Mac OS 9等)过渡到抢占式多任务系统的。实际上,调度方式并无高下,完全取决于应用场景。抢占式系统允许操作系统剥夺进程执行权限,抢占控制流,因而天然适合服务器和图形操作系统,因为调度器可以优先保证对用户交互和网络事件的快速响应。当年Windows 95刚推出时,抢占式多任务就被作为一大卖点大加宣传。协同式调度则等到进程时间片用完或系统调用时转移执行权限,因此适合实时或分时等对运行时间有保障的系统

另外,抢占式系统依赖于CPU的硬件支持。因为调度器需要“剥夺”进程的执行权,就意味着调度器需要运行在比普通进程高的权限上,否则任何“流氓(rogue)”进程都可以去剥夺其他进程了。只有CPU支持了执行权限后,抢占式调度才成为可能。x86系统从80386处理器开始引入Ring机制支持执行权限,这也是为何Windows 95和Linux其实只能运行在80386之后的x86处理器上的原因。而协同式多任务适用于那些没有处理器权限支持的场景,这些场景包括资源受限的嵌入式系统和实时系统。在这些系统中,程序均以协程的方式运行。调度器负责控制流的让出和恢复。通过协程的模型,无需硬件支持,我们就可以在一个“简陋”的处理器上实现多任务系统。许多常见的智能设备,如运动手环,受硬件所限,都采用协同调度架构。此外,自动驾驶平台的多数据流处理融合,以及实时处理需求,天然适合采用协同调度架构。

2. 协程的复兴

2.1 高并发带来的问题

随着近年来软硬件技术的升级,服务端需要处理的数据流越来越多,这对高并发要求越来越高,而传统的服务端并发以C++异步回调模型为主流,异步回调模型的特性使得业务流程中每一个需要等待IO处理的节点都需要切断业务处理流程、保存当前处理的上下文、设置回调函数,等IO处理完成后再恢复上下文、接续业务处理流程。在一个典型的互联网业务处理流程中,这样的行为节点多达十几个甚至数十个(微服务间的rpc请求、与redis之类的高速缓存的交互、与mysql\mongodb之类的DB交互、调用第三方HttpServer的接口等等);被切割的支离破碎的业务处理流程带来了几个常见的难题:

  • 每个流程都要定义一个上下文struct,并手动保存与恢复;
  • 每次回调都会切断栈上变量的生命周期,导致需要延续使用的变量必须申请到堆上或存入上下文结构中;
  • 由于C++是无GC(Garbage Collection)的语言,碎片化的逻辑给内存管理也带来了更多挑战;
  • 回调式的逻辑是“不知何时会被触发”的,用户状态管理也会有更多挑战;

这些具体的难题综合起来,在工程化角度呈现出的效果就是:代码编写复杂,开发周期长,维护困难,BUG多且防不胜防。而当前火热的自动驾驶领域,主机端需要实时处理来自雷达、摄像头、车辆传感器、用户指令、通信网络和卫星信号等数据流IO,这也与异步回调模型格格不入。

2.2 制衡之道——协程

另外,随着网络技术的发展,让抢占式调度对IO型任务处理的低效逐渐受到重视,协程的机会终于来了。早在2007年,Go语言问世之时,内置的协程特性完全屏蔽了操作系统线程的复杂细节,在语言层面原生支持协程,可以说是彻底拥抱协程,这也造就了Go的高并发能力,甚至使Go开发者"只知有协程,不知有线程”。在Galang的带动下,像Java、C/C++、Python这些老牌语言也陆续开始借助于第三方包来支持协程,来解决自身语言的不足。2014年腾讯的微信团队开源了一个C风格的协程框架libco,并在次年的架构师峰会上做了宣讲,使业内都认识到异步回调模式升级为协程模式的必要性,从此开启了C++互联网服务端开发的协程时代。双重作用下,BAT三家旗下的各个小部门、业内很多与时俱进的互联网公司都纷纷自研协程框架,一时呈百花齐放之态。

虽然协程库种类繁多,但是万变不离其宗,协程最根本的特征还是:用户态、轻量级、非抢占。协程调度由用户自己实现,并且同一时间只能有一个协程在执行。协程没有增加线程的数量,只是在线程的基础上通过分时复用的方式运行多个协程。协程拥有自己的寄存器上下文和栈,协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来时,恢复先前保存的寄存器上下文和栈,直接操作栈且基本没有内核切换的开销,而且没有系统调用,所以上下文的切换非常快。另外,协程的非抢占特性使它可以不加锁的访问全局变量。下面我们详细分析协程的优缺点。

3. 协程的优劣势分析

协程,作为一种服务器组件,在多种高级语言中存在。相比起线程和进程而言,它的切换速度非常快,很适合在海量服务中使用。另外,协程极大的优化了程序员的编程体验,同步编程风格能快速构建模块,并易于复用,而且有异步的性能(这个看具体库的实现),也不用陷入层层callback的深坑。但协程并不是万能的,也有局限性,还不能从根本上取代进程和线程。本章分开剖析协程的优缺点,帮助大家更加全面的了解协程。

3.1 优势

本节我们从原理上浅析协程为什么具有这些优点。协程的优势如下:

  • 同步编程风格实现异步性能。协程的动作集中在应用层,把线程在内核上的开销转到了应用层,从而把复杂的线程操作屏蔽在下层框架上,从而大幅降低了编程的难度,在使用同步模式的编程方式时,但却拥有了线程快速异步调用的效率。
  • 用户态切换上下文。当在内核里实行上下文切换的时候,其实是将当前所有寄存器保存到内存中,然后从另一块内存中载入另一组已经被保存的寄存器。对于图灵机来说,当前状态寄存器意味着机器状态——也就是整个上下文。其余内容,包括栈上栈帧,堆上对象,都是直接或者间接的通过寄存器来访问的。 而协程切换时仅需要更换寄存器的值,不需要内存参与,因此可以在用户态完成。而线程的切换会涉及到用户模式到内核模式的切换,每次切换都涉及到中断,而int指令是一个复杂的多步指令,很耗时。
  • 开销轻量级。线程的数据在堆和栈上,通常有1M以上,而协程数据主要在栈帧上,一般只有几kb到几十kb,所以在创建、销毁和切换时,寄存器需要保存和加载的数据量更小。
  • 非抢占式调度。协程的非抢占式调度更有效率,因为协程是非抢占式的,前一个协程执行完毕或者堵塞,才会让出CPU,而线程则一般使用了时间片算法,会进行很多没有必要的切换,此外线程切换虽然不会切换内存,不会导致高速缓存命中率下降,但会切换堆,可能会导致某些指针或地址失效。

3.2 劣势

然而协程并不是银弹,它也没有在与进程/线程的竞争中占得绝对上风,让我们看看它有哪些问题:

  • 协程无法利用多核,需要配合进程来使用才可以在多CPU上发挥作用
  • 线程的回调机制仍然有巨大生命力,协程无法全部替代
  • 控制权需要转移可能造成某些协程的饥饿,抢占式更加公平
  • 虽然协同调度适合实时或分时等对运行时间有保障的系统,但非抢占式无法满足某些实时性非常强的任务处理,还是需要抢占式的进程/线程
  • 协程的控制权由用户态决定可能转移给某些恶意的代码,抢占式由操作系统来调度更加安全

综上来说,协程和线程并非矛盾,协程的威力在于IO的处理,恰好这部分是线程的软肋,由对立转换为合作才能开辟新局面。在自动驾驶领域,由于目前主机都有多处理机,所以可以在某处理机使用进程/线程处理突发情况,而其它处理机则使用协程进行IO,充分利用进程和协程结合的优势。

4. 协程的两个特性

协程运行于用户态,轻量化,非抢占式等性质,相信大家都已了解,这里解释下容易混淆的两个特性:有栈/无栈与对称/非对称。

4.1 有栈/无栈

操作系统为方便内存资源的管理,把内存分为堆和栈。两者最典型的区别在于,堆需要使用者主动管理而栈不需要,栈则由负责执行程序的进程/线程/协程管理,所以对栈的处理是进程/线程/协程必不可少的环节。栈分为执行栈和回调栈,无论进程、线程还是协程,运行时都需要执行栈保存函数参数、局部变量和返回地址等信息。但区分协程的栈指的是回调栈(Callback Stack,有的文章翻译为调用栈,调用堆栈,为避免歧义,作者称其为回调栈),就是函数调用过程中对局部变量、回调地址和返回值等的一种保存机制。所谓的有栈/无栈并不是说这个协程运行的时候有没有运行栈,而是指协程之间切换时,是否使用回调栈。而根据协程切换时是否使用回调栈,可以将协程分为两类:有栈协程和无栈协程。有栈协程有自己的回调栈,用来保存切换回来时需要恢复的环境变量,而无栈协程本身不使用回调栈而共用线程执行栈或进程执行栈,下边详细解释。

有栈协程:有栈协程需要操作回调栈,通常包括回调栈的保存和恢复,目前实现方式包括:纯手写汇编实现、glibc库的ucontext.h的swapcontext、C接口的setjmp/longjmp、采用C++标准扩展库Boost.Context的接口jump_fcontext,它们一般会在yield操作时保存回调栈,而在resume操作时恢复回调栈内容,常见的stackfull协程库有ntyco、coroutine、goroutine、libtask、libmill、boost、libco、libgo、tbox等。有栈协程的回调机制,更类似线程的调度,只是调度一个发生在用户态可以由用户控制,一个发生在内核态由系统控制。它们都需要一个压栈和出栈的操作,用来保存和恢复相关上下文(其实主要就是寄存器状态),这样就可以保证整个运行期间变量的有效性和安全性。

无栈协程:无栈协程的本质就是一个状态机(state machine),其切换本质可以理解为寄存器的指针的改变。因为无栈协程的栈内保存的不是数据而是指向数据的指针,协程运行前在堆区中申请一段空间,然后把所有栈帧数据保存在堆区(heap),而原栈区扔保存线程的栈帧。正因为如此,所以协程根本不需要上下文切换,因为全局的上下文就没变过,只需要修改指针,从而改变他们的调用关系就可以。
协程切换使用类似wait、switch这类的动作函数来实现,这样整个协程的运行过程更像在系统栈上的跳跃,而不会有任何的进出栈的开销,自然效率就大很多了。比如Python的协程就是一个无栈协程而且是非对称的,还有无栈协程Protothreads,它利用C语言语法中的switch-case的实现。

下面举两个例子,更能说明这个问题。首先是有栈协程libco的创建和切换:

void* test(void* para){
	co_enable_hook_sys();
	int i = 0;
	sleep(1000); // 协程切换执行权,1000ms后返回
	i++;
	sleep(1000); // 协程切换执行权,1000ms后返回
	i--;
	return 0;
}

int main(){
	stCoRoutine_t* routine;
	co_create(&routine, NULL, test, 0);// 创建一个协程
	co_resume(routine); 
	co_eventloop( co_get_epoll_ct(),0,0 );
	return 0;
}

这段代码中,主协程开启一个协程去执行test函数,在test中我们需要两次从协程中切换出去,这里对应了两个sleep操作,sleep所做的事情就是把当前协程的CPU执行权切换到回调栈的上一层,并在超时或注册的fd就绪时返回(当然样例这里就只是超时了)。那么无栈协程跑相同的代码是怎么样的呢?其实就是翻译成类似于以下代码:

struct test_coroutine {
    int i;
    int __state = 0;
    void MoveNext() {
        switch(__state) {
        case 0:
            return frist();
        case 1:
            return second();
        case 2:
        	return third();
        }
    }
    void frist() {
        i = 0;
        __state = 1;
    }
    void second() {
        i++;
        _state = 2;
    }
    void third() {
    	i--;
    }
};

可以看到,与有栈协程中的test函数相比,这里把整个协程抽象成一个类,函数MoveNext以原来需要执行切换的语句处为界限,把函数test划分为几个部分,并在某部分执行完后进行状态转移(改变_state),下一次调用MoveNext时就会执行下一部分,这样就不需要像有栈协程那样显式的执行上下文切换了,只需要一个简易的调度器(_state)来调度即可。从执行栈的角度看,其实所有的协程共用的都是一个栈,即进程栈/协程执行栈,也可称为系统栈,这时就不需要给协程分配回调栈,同时因为是函数调用,当然也不必显示保存寄存器的值。另外,相比有栈协程把局部变量放在回调栈上,无栈协程直接使用系统栈使得CPU cache的局部性更好,同时也使无栈协程的中断和函数返回几乎没有区别,这样也凸显出无栈协程的高效。无栈协程将subroutine与函数的区别进一步简化为可中断后返回。

总结:有栈协程涉及到对于回调栈中寄存器的保存和修改,也涉及到对每个协程的执行栈分配。现代寄存器基本都是上百个字节数据,虽然保存和恢复的数据量不大,但仍然不可避免的加大整个系统的开销,特别是大数量协程时,有栈协程在效率上势必有一些损失。但由于无栈协程无法解决异步回调模型中上下文保存和恢复问题,功能相对单一,并且有栈协程可采用共享栈来减少上下文切换时的性能损失,所以大部分协程库均为有栈协程。然而简化编程是大方向,不知以后是否会出现复杂功能的无栈协程库,请拭目以待。对此部分还不理解的读者可参考文献4和文献5。

4.2 对称/非对称

协程在执行过程中,可以调用别的协程自己则中途退出执行,之后又从调用别的协程的地方恢复执行,这有点像操作系统的线程:执行过程中可能被挂起,让位于别的线程执行,稍后又从挂起的地方恢复执行。在这个过程中,协程与协程之间实际上不是普通“调用者与被调者”的关系,他们之间的关系根据恢复的方式不同,分为对称协程(symmetric coroutines)和非对称协程(asymmetric coroutines)。

具体来讲,非对称协程(asymmetric coroutines)是跟一个特定的调用者绑定的,协程让出CPU时,只能让回给原调用者。那到底是什么东西“不对称”呢?第一,非对称在于程序控制流转移到被调协程时使用的是suspend/resume操作,而当被调协程让出 CPU 时使用的却是return/yield操作。第二,协程间的地位也不对等,caller与callee关系是确定的,不可更改的,非对称协程只能返回最初调用它的协程。微信团队的libco其实就是一种非对称协程,Boost C++库也提供了非对称协程。另外,挂起(suspend)和恢复(resume)跟yield的区别是:yield后的协程,之后还会被切换回来,但是被suspend挂起的协程,除非调用resume()恢复它,否则永远不会再被执行到。在不同语言中,这三者会有不同的叫法,比如call也会调用新函数时也会同时实现suspend旧函数的功能,有的语言用yield/resume和return,不一而论,但区别不变。

对称协程(symmetric coroutines)则不同,被调协程启动之后就跟之前运行的协程没有任何关系了。协程的切换操作,一般而言只有一个操作yield或return,用于将程序控制流转移给另外的协程。对称协程机制一般需要一个调度器的支持,按一定调度算法去选择yield或return的目标协程。Go语言提供的协程,其实就是典型的对称协程。不但对称,goroutines还可以在多个线程上迁移。这种协程跟操作系统中的线程非常相似,甚至可以叫做“用户级线程”。

总结:其实对于“对称”这个词,阐述的是协程之间的关系,协程之间人人平等,没有谁调用谁,大家都是一样的,也不需要回调栈,只需要一个数据结构存储所有未执行完的协程即可,一般对应无栈协程。而非对称协程拥有回调栈,协程之间存在明显的调用关系,对应有栈协程。至于如何选择,我认为非对称协程更适合已知的IO密集型应用,可以自主把握切换的时机,而对称协程可能会切到未完成的任务,当然调度算法足够优秀也可以避免,也就是说对称协程对调度算法的要求更严苛。

5. 协程的实现

我们前面说过,协程的思想本质上就是控制流的主动让出和恢复机制。在现代语言中,可以实现协程思想的方法很多,这些实现间并无高下之分,所区别的就是是否适合应用场景。理解这一点,我们对于各种协程的分类,如半对称/对称协程、有栈/无栈协程等具体实现就能提纲挈领,无需在实现细节上纠结。

本章内容如下:第一节总述协程的实现方式;第二节了解下协同工作制在python的迭代器和生成器是如何显现;第三节详述当前协程库现状。

5.1 实现方式

协程在实践中的实现方式千差万别,一个简单的原因是,协程本身可以通过许多基本元素构建。基本元素的选取方式不一样,构建出来的协程抽象也就有差别。例如,Lua语言选取了create、resume/yield作为基本构建元素,从调度器层面构建出所谓的“非对程”协程系统;而Julia语言绕过调度器,通过在协程内调用yieldto函数完成了同样的功能,构建出了一个所谓的对称协程系统。尽管这两种语言使用了同样的setjmp库,构造出来的原语却不一样。又如,许多C语言的协程库都使用了ucontext库实现,这是因为POSIX本身提供了ucontext库,不少协程实现是以ucontext为蓝本实现的。这些实现,都不可避免地带上了ucontext库的一些基本假设,如协程间是平等的,一般带有调度器来协调协程(如libtask实现,以及云风的coroutine库)。另外,C++的标准扩展库Boost.context经过重新设计,切换效率更高。它主要有两个接口,一个make_fcontext(),一个jump_fcontext()。相比ucontext,boost的切换模式少了单独对context进行保存(getcontext)和切换(setcontext)过程,而是把两者合并到一起,通过jump_fcontext接口实现直接切换,构思确实精妙。Go语言的一个鲜明特色就是通道(channel)作为一级对象。因此,resume和yield等在其他语言里的原语在Go中都以通道方式构建。我们还可以举出诸多近似的例子。其风格差异往往和语言的历史、演化路径、要解决的问题相关,我们不必苛求其协程模型一定要如此这般。

总的来说,协程为协同任务提供了一种运行时抽象。这种抽象非常适合于协同多任务调度和数据流处理。在现代操作系统和编程语言中,因为用户态线程切换代价比内核态线程小,协程成为了一种轻量级的多任务模型。我们无法预测未来,但可以看到,协程已成为许多擅长数据处理语言的一级对象。随着计算机并行性能的提升,用户态任务调度已成为一种标准的多任务模型。在这样的大趋势下,协程这个简单且有效的模型就显得更加引人注目。

然而提到协程,很多人会想到Python的yield生成器,两者究竟存在什么关系?在讲述协程具体实现之前,我们先来欣赏协程思想在动态语言Python中的光华。

5.2 Python的迭代器和生成器

编程思想能否普及开来,很大程度上在于应用场景。协程没有能在自顶向下的世界里立足,却在动态语言世界中大放光彩,这里最显著的例子莫过于Python的迭代器和生成器。

回想一下在C的世界里,循环的标准写法是:for (i = 0; i < n; ++i) { … }。这行代码包含两个独立的逻辑:for循环控制了i的边界条件,++i控制了i的自增逻辑。对于STL和复杂数据结构,因为往往只支持顺序访问,循环大多写成:for (i = A.first(); i.hasNext();i = i.next()) { … }。这种设计抽象出了一个独立于数据结构的迭代器,专门负责数据结构上元素的访问顺序。迭代器把访问逻辑从数据结构中分离出来,是一个常用的设计模式(GoF 23个设计模式之一),我们在STL和Java Collection中也常常看到迭代器的身影。在适当的时候,我们可以更进一步引入一个语法糖,将循环写成:for item in A.Iterator() {…}

事实上,许多现代语言都支持类似的语法。这种语法抛弃了以i变量作为迭代指针的功能,要求迭代器自身能记住当前迭代位置,调用时返回下一个元素。读者不难看到,这就是我们在文章开始提到的语法分析器的架构。正因为如此,我们可以从协程的角度来理解迭代器:当控制流转换到迭代器时,迭代器负责生成和返回下一个元素。一旦准备就绪,迭代器就让出控制流。在Python中,这种特殊的迭代器实现又被成为生成器。以协程角度切入的好处在于设计大大精简。实际上,在Python中,生成器本身就是一个普通函数,和其他普通函数的唯一不同,在于它的返回语句是协程风格的yield。这里,yield一语双关,既是让出控制流,也是生成迭代器的返回值。当然,yield只是冰山的一角,现代的Python语言还充分利用了yield关键字构建yield from语句、(yield)语法等,让我们毫无困难地将协程的思想融入到Python编程中,限于篇幅这里不再展开。

5.3 当前协程库现状简述

本节讲述当前各主流语言协程库发展现状,包括C/C++、Python、Java和Go。如果你有关注过C++语言的最新动态,可能也会注意到近几年不断有人在给 C++标准委员会提协程的支持方案,比如底层API的C++标准扩展库Boost中的Context,C++20还引入了不太成熟的协程框架CoroutineTS。此外还有其它第三方库,按上下文切换的不同方式分类,比较出名的有:

  1. C++标准扩展库Boost中基于Context切换的Boost.Coroutine2;
  2. 微信后台团队的基于ucontext修改汇编实现上下文切换的libco;
  3. 微信团队又一款产品,基于ucontext/Boost.Context切换的RPC框架PhxRPC;
  4. 魅族公司基于ucontext/Boost.Context的产品级协程框架libgo。

除了服务器主流语言C++,其它语言也在积极引入了协程机制。比如C语言中各式各样的协程库,根据上下文切换方法不同,分为以下几种:

  1. 自己手写汇编实现,如ntyco和coroutine;
  2. 基于GNU发布的linux底层c运行库glibc的ucontext切换,libtask(无hook);
  3. 比较另类的无栈协程Protothreads,它利用C语法中的switch-case的技巧实现;
  4. 参照Boost.Context改写汇编实现的神器tbox,性能尤其优秀,在不断完善中兼容性也得到了保障;
  5. 基于底层C接口setjmp/longjmp实现切换,如libmill(无hook),还有ST (State Threads) 库提供了一种高性能、可扩展服务器(比如web server、proxy server、mail agent等)的实现方案。

其中Protothread最轻,但受限最大,ucontext耗资源性能慢,ntyco和coroutine很久未更新,目前看来tbox和ST是C语言协程库的最佳选择。此外,Java同样有一些试验性的解决方案在不断被提出来,比如有栈协程Coroutines、Quasar,还有最新可能纳入JDK18的JEPs。Python的实现方案有yield生成器、greenlet库、gevent库、asyncio异步协程,以及Python3.7的保留关键字async/await,它属于无栈协程。专为协程而生的Go语言中的Goroutine,被认为是用户态更轻量级的线程,对操作系统不可见,所以goroutine仍然是基于线程实现,因为线程才是CPU调度的基本单位,在go语言内部维护了一组数据结构(如队列)和N个线程的中间层,协程的代码被放进队列中来由线程来实现调度执行,这就是著名的GMP模型(Goroutine,Machine,Processor)。除了语言,还有跨windows平台的ThreadContext库,包括GetThreadContext/SetThreadContext接口,经测试相比linux的汇编实现要慢一些,此外还有Fiber纤程,主要包括CreateFiber/ConvertThreadToFiber/SwitchToFiber接口等,windows平台用户可以去研究下。

本篇到此结束。下一篇我们将分析当前各协程库的优劣势,并给出推荐:libgo,分析其源码目录及部分源代码,最后提引性能神器tbox。

本打算行文尽量简洁,但达不到讲精讲细的目的,所以我对本系列文章的定位是复杂知识点详细总结,在此基础上做到尽量简练。由于查阅了大量资料,耗费了很多精力,虽然谈不上尽善尽美,但也希望各位支持作者一下,来个一键四联(点赞、收藏、评论、转发),希望能帮助不断探索的你。

参考文献

  1. 谈谈协程的历史与现状
  2. 计算机语言协程的历史、现在和未来
  3. C++开源协程库libco——原理及应用
  4. 有栈协程与无栈协程
  5. 使用 C 语言实现协程
  6. libco源码解析(8) hook机制探究

  1. 关于这段历史,可参考文献1和2,强烈建议读一遍 ↩︎

  2. 当初做C项目,老大说不建议使用go,会破坏程序结构。当时就有疑惑:一是不建议使用为什么设计它;二是go语句给我的感觉很简洁,好像某种条件触发的小路幽径,现在看来根源原来在这里。 ↩︎

你可能感兴趣的:(linux,协程,协同工作制,有栈/无栈,对称/非对称,协程库现状)