SteveHawk's Blog

协程



我很喜欢协程。最早接触到协程的概念是在 2019 年,那时因为课程项目使用 Go 语言,见识到了 Goroutine 的强大。后来工作上主力用 Python,也逐渐开始深度了解和使用 asyncio。最近又研究了下 Gleam 这门新语言(披着 Rust 皮的 Erlang),对 Gleam/Erlang 的协程模型也有了一些了解。于是趁这个机会,写一写我对协程的理解和看法。

何谓协程

维基百科的协程条文说道:

There is no single precise definition of coroutine.

确实是这样,这个概念本身出现了好几十年,在不同的语言、不同的库里有五花八门的实现,大家都有不同的理解。维基百科的条文引用了一些古老的文献书籍,给出的定义相当晦涩难懂。

在我看来,协程就是(或者近似于)一个轻量的用户态线程,能够以很小的开销实现异步执行。

系统级/内核级线程虽然相较进程更轻量(除了 Python 用户估计也没人会纠结多进程吧),但是依然有不少开销。线程切换的时候,内核需要亲自出场确认中断和上下文没有问题;线程分配内存的时候,最小的单元也得是一个内存页。另外因为操作系统需要健壮地运行各种线程(无论好的坏的会崩溃的),所以还需要分配很多资源进行防御操作,防止一个线程把整个系统搞挂。

而协程通常是运行在线程内部,协程切换对操作系统完全无感,内存分配也可以任意小。另外因为每一门语言都有自己的规则和限制,所以只需要很少的状态就可以表达一个协程。这样一来,协程的轻量级优势让它比系统原生内核线程的性能要好上不少,几乎没有理由不用。

命名

一定有非常 pedantic 的老学究可以从我上面的解释里挑出很多刺,比如 Goroutine 压根不是 Coroutine

协程生态里的命名是我最头疼的一件事情。一堆非常相似的东西,但是有细微的差别,因此有不同的名字。同一个东西在不同语言或者不同库里,也会被叫作不同的名字。

Coroutine, goroutine, fiber, green thread, virtual thread, lightweight process,这些概念都是描述这群相似的“轻量用户态线程”,但是又没有一个概括性的词描述他们。

看起来大趋势是,大家都开始习惯用协程(Coroutine)来概括这群东西。但是问题是,协程的协(Co-routine)代表协作(Co-operative)的意思,不是协作式的(比如 Golang,后面细说)怎么也能叫协程?也见过有人会用 Green thread 概括,但是绿色线程也是协作式的!

感觉我也陷入了 pedantic 的圈套。我们真的非常需要一个真正有概括性的、朗朗上口的名字了。在这个名字诞生之前,我决定还是把他们都叫作协程。(所以这篇文章标题叫协程啊哈哈)

现在我们知道不同实现的协程有很多微妙的差别,下面就按几个常见的分类维度展开讲讲。

协作/抢占

协作式(Cooperative)和抢占式(Preemptive)是协程最常见也是最有趣的分类维度。这两者的区别在于,协程是自愿(协作式地)交出控制权,还是被动(抢占式地)失去控制权。

协作式

对于 Javascript/Rust 使用的这些 async/await 形式的协程,他们都是协作式的。每一个 async 定义的函数都是一个协程,在这个协程里 await 另一个协程的时候,就意味着交出了控制权,由调度器决定接下来运行哪个协程。只要协程不 await,那这个线程里就只会一直执行这个协程,其他协程都得等(或者多线程调度器的话,只能放到别的线程跑)。

Python 虽然和 JS/Rust 一样是使用 async/await 语法的协作式协程,但不同的是,协程交出控制权的位置并不是 await,而是 yield(123)。只有在 await 的目标执行 yield 的时候,才会让上层协程暂停执行交出控制权。如果代码执行中没有碰到 yield,那就算 await 了也不会交出控制权到调度器。Python 的命名也有一些误导,Python 里的 coroutines 只是一个戴着面具的生成器,asyncio.Task 才更接近本文讨论的协程的概念。

这里也催生了一些有趣的技巧,比如新建了一个任务,想要尽快执行怎么办?可以在建立任务后使用 await asyncio.sleep(0) (底下就是一个 yield)主动交出一下控制权,如果事件循环空闲的话这个任务就会立刻开始运行。这对于 IO 类的任务来说非常合适,可以尽快先运行到阻塞的地方等着,这个时间正好回去干正事。(不过 Python 3.14 的 create_task 增加了 eager_start 选项,可以指定 task 立刻执行了)

抢占式

Go 和 Erlang 则实现了抢占式的协程。从使用体感上来说,这种协程和线程几乎没有什么两样,就是创建一个协程,然后扔给调度器跑。我们并不知道什么时候会运行哪个协程,反正调度器会安排。那调度器是怎么安排的呢?我们刚才看到 JS/Rust/Python 都是在 await(或者 yield)的位置主动交出控制权,所以叫协作式;那我们顺理成章推测,抢占式意味着调度器不需要协程同意,可以类似于系统线程一样直接中断执行,进行上下文切换。

这个推测非常有道理,表现上来看也确实是这样,但是底层实现其实没有那么简单。查找 Erlang process(对,Erlang 的协程叫 process…)相关资料的时候,经常会看到相互矛盾的讲法,有些人说 Erlang process 是协作式,有些人说是抢占式。怎么回事?介绍 Erlang BEAM 虚拟机的书 The BEAM Book第十章深入介绍了 Erlang/BEAM 的协程机制。其中讲到:

The preemptive multitasking on the Erlang level is achieved by cooperative multitasking on the C level. The Erlang language, the compiler and the virtual machine works together to ensure that the execution of an Erlang process will yield within a limited time and let the next process run.

One can describe the scheduling in BEAM as preemptive scheduling on top of cooperative scheduling. A process can only be suspended at certain points of the execution, such as at a receive or a function call. In that way the scheduling is cooperative—a process has to execute code which allows for suspension.

这讲得很明白了。原来 Erlang process 只是披着抢占式的皮,实际上底层还是协作式的!大概类似于所有的函数都默认是 async,所有的函数调用都默认 await,只是不用写出来,但是执行的时候依然是在这些(所有的)函数调用的地方交出控制权,让调度器决定接下来跑谁。调度器采用了一个叫作 reduction counting 的方法来计算协程的用量,基本上就是协程能够调用函数次数的额度。这里的 reduction 名字是从早期 Prolog 版本的 Erlang 来的,Prolog 的每一个执行步骤都叫作规约 reduction。这个名字沿用至今,在这里约等于函数调用。额度早期为 2000,现在是 4000,意味着一个协程调用函数超过 4000 次(或者 IO 阻塞),就会被调度器暂停执行(并恢复额度),换其他协程执行。

那这披着抢占式皮的协作式协程,相比之前纯协作式的有什么区别?在我看来,主要的两大优势在于不用用户操心调度,以及没有函数染色问题(后面再细讲)。

再看 Go 这里,goroutine 的实现是怎么样的呢?神奇的是,直到 2020 年的 Go 1.14 才引入了真正的异步抢占机制。意味着在这之前(包括我 2019 年刚接触 Go 的时候)goroutine 和 Erlang process 一样,底层其实是协作式的!相较 Erlang 的函数调用计数,Go 采用了 10ms 的时间分片,一个协程运行超过 10ms(或者 IO 阻塞)就会被调度器暂停执行,切换别人。但是这样存在一个问题:如果一个协程里有一个长循环,但是一直不调用任何函数,那就会直接阻塞整条线程,因为调度器没有办法真的半路暂停这个协程的执行。

等等!为什么 Go 有这个问题,Erlang 没事呢?巧了,Erlang(当然也包括 Elixir 和 Gleam)作为函数式语言,压根没有循环(for,while 等),所有的循环都得要靠递归实现,这样一来自然没有办法长时间运行而不调用任何函数。虽然一些 FFI 情况下还是可能会阻塞,但是总体上问题不大。而 Go 就没办法绕过这个问题了,所以后来 Go 1.14 终于引入了真正的抢占机制,利用 SIGURG 这个信号通知协程中断执行,保存栈上下文然后交出控制权。

所以有人把 Erlang 和以前的 Go 的方式叫作 cooperative preemption 协作式抢占,而现在的 Go 叫作 non-cooperative preemption 非协作式抢占(抢占式抢占?lol)。

有栈/无栈

另一种常见的分类,是把协程分为有栈协程(stackful coroutine)和无栈协程(stackless coroutine)。顾名思义,有栈协程有自己独立的调用栈,就算在嵌套函数调用中也可以暂停执行交出控制权;而无栈协程没有自己的栈,只能共享主线程的栈,因此也只能在最上层移交控制权。

碰巧的是,上一节提到的协作式协程都是无栈协程,抢占式协程都是有栈协程。对于那些采用 async/await 语法的协程实现来说,正好协程都是以单个函数为单位,调用其他协程必定要交出控制权,因此不存在嵌套调用,无须额外存一个调用栈,因此他们都是无栈协程。至于无栈协程是不是都是协作式,我想抢占式估计也是可以实现,但是那样好像会引入太多复杂度?对于有栈协程,只是碰巧这次举的两个例子都是抢占式,实际上协作式也完全没有问题(你看 Erlang 不其实本质还是协作式嘛,改成真的协作式当然没问题)。

总结来讲,无栈协程主要就是这些 async/await 形式的协程,而有栈协程就是更类似线程形式的那种协程。

有调度器/无调度器

有无调度器是一个蛮有意思的角度。在这篇 Distinguishing coroutines and fibers 文章里,作者对比了 C++ Boost 库里实现的 coroutine 和 fiber 的区别。其中就提到,fiber 是有调度器的,而 coroutine 没有调度器也不需要调度器。

根据这篇文章对这个语境下 coroutine 的介绍,我感觉这其实和 Python 的 coroutine 概念很像,只是一个披着协程皮的生成器而已。这里的 coroutine 只是意味着函数之间可以交替执行,即协作式多任务,而不是真正意义上的并发执行。而这里的 fiber 应该是一个协作式的有栈协程。

要从“多任务”进化到并发,以我的理解,调度器应该是必不可少的,至少也得要有一个事件循环这样的简单调度器。

并行/并发

要从并发进化到并行,那光事件循环可能就不够了。

Python/JS 的协程都是运行在一个事件循环(event loop)上,可以是内置实现,或是其他实现比如 Linux 内核的 io_uring。事件循环本质上其实就是个任务队列,负责追踪协程任务,把准备好的协程取出来运行,把等待阻塞的协程扔回队列。事件循环的功能太过于简陋,只能应付单线程上的并发任务,并没有足够的能力高效运行多线程的并行任务。

多线程场景下,包括 Rust/Go/Erlang,都实现了工作窃取(work stealing)机制。每个线程都有一个独立的任务队列,但是显然这样容易分配不均,某些线程已经提前完成任务进入空闲,但是有些线程还在忙碌。工作窃取机制这时候会让空闲的线程去其他线程的任务队列窃取任务,确保所有线程都能保持忙碌。

染色问题

最后聊聊臭名昭著的函数染色问题。在这篇文章里,作者把异步函数描述为红色函数,同步函数描述为蓝色函数。只有红色函数可以调用红色和蓝色函数,蓝色函数只能调用蓝色函数(只有异步函数可以调用异步函数和同步函数,同步函数只能调用同步函数)。因为异步函数必须要被 await,而只有异步函数可以 await 别人,同步函数不可以,这确实是使用 async/await 语法的协程(无栈协程)共有的痛点。对于新项目,如果规划好要用异步,那从头全部都用异步就行。但是就怕一个同步的老项目引入异步依赖,那这就红蓝火葬场了。

正如作者所说,Go/Erlang/Lua/Ruby 这类有栈协程的设计避免了染色问题。也很好理解,有栈协程的语法通常都类似于启动一个线程,而不用给函数标注是否 async,也就是上色。在有栈协程的眼里,所有的函数都是平等无色的,不用纠结谁能不能调用谁。无栈协程最大的问题就是把协程和函数糅在了一起,同步和异步世界都在函数上,两者要交互势必没法躲开染色;有栈协程的协程和函数是泾渭分明的两个东西,异步世界与函数无关,和同步世界的交互依赖共享内存或者通道等方式,也就不会把函数扯进来。

硬要说的话,有栈协程的开销应该是比无栈协程大一些的。这可以说是用一些运行时的开销,换取了函数的无色吧。

尾声

正如开头所说,我很喜欢协程,我觉得基于协程的异步编程是绝对的未来。Coroutine is goated!


#tech notes
4476 words

↪ comment
↪ reply by email