有栈协程和无栈协程
协程
协程coroutine是用户级线程,由应用程序而非系统内核控制线程调度和上下文切换,是非抢占的、主动让出CPU资源的多线程。和内核级线程thread相比,用户级线程优点在于内存占用小、切换成本低、能大量创建,如果配合非阻塞式API能够处理大规模并发,缺点是没法发挥多核的性能、同一时刻一个CPU上只有一个协程,但是可以通过在内核级线程中使用协程间接发挥多核性能。例如Golang利用内核支持,可以在多核CPU上执行协程。
有栈和无栈协程
协程的实现分为有栈协程(stackful)和无栈协程(stackless)两种。有栈协程指每个协程会保存单独的上下文(执行栈、寄存器等),协程的唤醒和挂起就是拷贝、切换上下文;无栈协程指单个线程内所有协程都共享一个执行栈,协程的切换就是简单的函数返回。
函数调用栈
调用栈是一段连续的地址空间,无论是caller(调用方)还是callee(被调用方)都位于这段空间之内。而调用栈中一个函数所占用的地址空间我们称之为「栈帧」(stack frame),调用栈就是若干个栈帧拼接而成的。下图就是一个典型的x86系统的栈结构,里面存储了main()和pow()两个函数的栈帧。
从图中可以看出,调用栈是一个用来跟踪程序运行状态的数据结构,记录了程序每一次函数调用的位置、参数和返回值等信息(由编译器维护)。这包括了函数的整个执行过程。函数的上下文包括这个函数的栈帧和此时寄存器的值。
有栈协程
函数运行在调用栈上,把函数作为一个协程,那么协程的上下文就是这个函数及其嵌套函数的(连续的)栈帧存储的值,以及此时寄存器存储的值。如果我们调度协程,也就是保存当前正在运行的协程上下文,然后恢复下个将要运行的协程的上下文。这样我们就轻松的完成了协程调度。并且因为保存的上下文和普通函数执行的上下文是一样的,所以有栈协程可以在任意嵌套函数中挂起(无栈协程不行)。
有栈协程的优点在易用性上,通常只需要调用对应的方法,就可以切换上下文挂起协程。在有栈协程调度时,需要频繁的切换上下文,开销较大。单从实现上看,有栈协程更接近于内核级线程,都需要为每个线程保存单独的上下文(寄存器、栈等),区别在于有栈协程的调度由应用程序自行实现,对内核是透明的,而内核级线程的调度由系统内核完成,是抢占式的。
例如Golang在进行协程调度时会保存栈寄存器和程序计数器(汇编支持),再进行协程调度。
无栈协程
相比于有栈协程直接切换栈帧的思路,无栈协程在不改变函数调用栈的情况下,采用类似生成器(generator)的思路实现了上下文切换。通过编译器将生成器改写为对应的迭代器类型(内部实现是一个状态机)。
而无栈协程需要在编译器将代码编译为对应的状态机代码,挂起的位置在编译器确定。无栈协程的优点在性能上,不需要保存单独的上下文,内存占用低,切换成本低,性能高。缺点是需要编译器提供语义支持,无栈协程的实现是通过编译器对语法糖做支持,比如C#的yield return, aysnc\await,编译器将带有这些关键字的方法编译为生成器,以及对应的类型作为状态机。
只有状态机的支持才能进行协程调度,例如Rust中的tokio,基于Future的用户态线程,根据poll方法获取Future状态,它不可以在任意嵌套函数中挂起(同步代码未实现状态机)。
总结
有栈协程 | 无栈协程 | 备注 | |
---|---|---|---|
例子 | Golang goroutine | Rust async\await | |
是否拥有单独的上下文 | 是 | 上下文包括寄存器、栈帧 | |
局部变量保存位置 | 栈 | 堆 | 无栈协程的局部变量保存在堆上,比如generator的数据成员 |
优点 | 1. 每个协程有单独的上下文,可以在任意的嵌套函数中任何地方挂起此协程 2. 不需要编译器做语法支持,通过汇编指令即可实现 | 1. 不需要为每个协程保存单独的上下文,内存占用低 2. 切换成本低,性能更高 | |
缺点 | 1. 需要提前分配一定大小的堆内存保存每个协程上下文,所以会出现内存浪费或者栈溢出 2. 上下文拷贝和切换成本高,性能低于无栈协程 | 1. 需要编译器提供语义支持,比如async\await语法糖 2. 只能在这个生成器内挂起此协程,无法在嵌套函数中挂起此协程 3. 关键字有一定传染性,异步代码必须都有对应的关键字。作为对比,有栈协程只需要做对应的函数调用 | 无栈协程无法在嵌套函数中挂起此协程,有栈协程由于是通过保存和切换上下文包括寄存器和执行栈实现,可以在协程函数的嵌套函数内部yield这个协程并唤醒 |
参考
[1]有栈协程与无栈协程