基于协程的异步编程入门
大家好啊,我是说的异步。今天来给大家协程的东西啊。
网络编程时,大家常用的实现并发的方式有多进程、多线程和 IO 复用几种。其中,多进程和多线程的做法都是在文件描述上做阻塞的读写操作,由操作系统来决定什么时候什么进程/线程可以执行,从而实现并发的服务。而 IO 复用则是利用 select poll kqueue epoll 等系统调用来监控文件描述符,仅在确定操作文件描述符时不会引起阻塞时才对其进行操作,从而实现并发的服务。
几种实现并发的方式中,IO 复用的并发模型的资源消耗最小。然而,之前异步编程的模型并不是很成熟,由于在做 IO 复用的并发模型时,程序状态切换、中间变量等等都需要手动维护,写出来的程序结构相当复杂、控制流乱飞、而且处理同一件事情的逻辑分布在好几个地方。异步程序的实现非常依靠程序员的聪明才智。再加上互联网早期网站对并发量的要求并不是很高,异步编程的模型几乎不是主流选择。
后来人们把协程、Reactor 模式和 IO 复用的并发模型结合,并且抽象出了运行时的概念来统一调度协程、对接事件循环,极大地简化了人们在编写异步程序时的心智负担。加入 async/await 语法后人们可以像写同步程序一样编写异步程序,相当简单。很多语言和框架现在都加入了异步和协程的特性,好时代,来临力。
这篇文章将教你常见的协程结构,简易协程的运行时架构,以及各个部分是怎么协作的。但是考虑到篇幅,并不会教你如何实现它们。同时不会教大家 IO 复用是什么,因为大家操作系统还是别的什么课上肯定已经学过了,就是 select 和 epoll 那一套。也不会教大家协程怎么用,因为大家肯定已经用过了。
协程的数据结构
主流的协程分为两种,有栈协程和无栈协程。它们的内部结构,以及控制流转移的方式相当不同。
有栈协程
有栈协程的结构与我们平时用的线程的结构几乎一模一样。它会在自己数据结构中存放完整的上下文数据(在 Linux 下的 C 中,通常是由 ucontext 库创建的 ucontext_t 对象;在 Lua 中,它会保存一个完整的协程栈)。当需要发生控制流转换,比如协程自已调用 yield 函数,或者遇到了阻塞点时,会触发一次上下文的切换。这个上下文的切换是在用户空间完成的,以使用 ucontext 库实现的 C 协程为例,它在上下文切换时通常会使用 swapcontext() 函数,将下一个需要执行的协程的整个上下文与当前的上下文交换,接着开始执行。当前协程的寄存器和局部变量等都被保存下来,存放在 ucontext_t 对象中,等待下一次执行。
可以看到,因为保存了完整的上下文信息,协程的使用体验和普通的线程相当一致。在有栈协程里,你可以递归调用函数,并且在任意一层函数的任意位置转移控制流。并且理论上你可以直接指定哪个协程下一个执行,不需要运行时的参与,虽然不会有人想这么做就是了。
无栈协程
要描述无栈协程实现方式是一件很困难的事情,因为各个语言中实现无栈协程的方式都略有不同。不过我们还是可以考察一些无栈协程之间的共同点。
无栈协程的结构与有栈协程的结构相当不同。相比有栈协程的数据结构中会保存整个栈,无栈协程仅保存了一些状态参数和自己所用到的跨 yield 的局部变量。这也使得它的数据结构相比有栈协程的来说非常小巧。
跨 yield 的局部变量:指协程交出控制权前与拿到控制权后都在使用的局部变量。
无栈协程通常会用 Promise(JavaScript) 或者 Future(Rust) 之类的名字来指代它的数据结构。两者基本上可以认为是同一个东西,大家在平时编程时应该也用到过。这里不再详细介绍它的状态、使用方法之类的东西。
一般来说,Promise(Future) 会支持一个名为 .then() 的成员方法,它接收一个匿名函数作为参数,在该 Promise fulfilled(ready)后以 fulfill 的结果作为参数调用该匿名函数。实际使用时,如果 .then() 函数内部存在较复杂的控制流,很容易使代码陷入函数套函数、Promise 套 Promise 的窘境。所以,实践时我们一般会用一种叫做 await 的语法糖来与 Promise 配合,相信大家或多或少也用过一些。await 接收一个 Promise 作为参数后,将当前协程的控制权交出,直到它参数的那个 Promise 状态不为 pending(即已执行完毕)后才继续执行后面的代码。
虽然很多语言都有 await 语法糖,但是它们的实现并不相同。这是语言本身的特性决定的。
考虑这段 JavaScript 代码(我不太会 js,有错误的话请告诉我一下):
1 | var x = await some_promise1(1) |
如果让我们自己来决定 await 语法糖应该如何变换代码,最容易想到的也最通用的实现模式是以 await 调用作为分界点对代码做 CPS 变换:
1 | some_promise(1).then((x) => { |
它利用了我们已经有的 Promise 类型的 .then() 函数基础设施,看起来和语言非常统一且优雅。但是实际上根本没有语言是这么实现 await 操作的。各个语言的 Promise 都有各自内部的神秘魔法。Javascript 和 Python 在内部使用了自己的 generator 机制实现了 await 操作;而 Rust 选择将整个 async 代码块编译成状态机,以 await 作为分隔点把各个部分的代码放到状态机的各处;Perl 比较逆天,解释器不支持语法糖就运行时改符号表并配合 C 语言扩展自己实现了一个。
相比有栈协程较为统一的 “在换出时存下所有东西,换入时复制回来” 的做法,无栈协程的实现算得上是八仙过海各显神通,需要与语言特性以及编译器/解释器紧密配合。所以别再说什么 “无栈协程不就是个 generator/状态机/CPS 变换后的函数” 啦!
有栈协程的局限性
有栈协程的结构十分接近正常的线程,它几乎没有什么局限性。能想到的也就是:
- 性能可能比无栈协程低
- 会受到栈溢出问题影响
但是实现了有栈协程的语言一般都会自己配上一个栈扩容机制(比如 Go),所以后者一般也不是什么问题。
无栈协程的局限性
无栈协程看起来像函数,实际上不是函数。在编译器/解释器和运行时的共同努力下,无栈协程用起来大部分时候和普通线程差不多,但是仍在少部分情况下会有点不同。这些是由无栈协程本身结构决定的。下面列举两个:
嵌套同步函数中转移控制权
无栈协程会在什么时候转移控制权呢?答案是在遇到 await 的时候。而所有的 await 在你写代码的时候都已经确定了,已经变成 generator/状态机/CPS 变换后的函数/其他什么奇怪东西 了。也就是说,无法做出一个高阶的异步函数。考虑下面这段代码:
1 | var numbers = [4, 9, 16, 25]; |
这段代码遍历 numbers 并且尝试将其通过互联网依次发送。这段代码是不能运行的,因为这里用到的闭包是一个同步函数,内部无法用 await 来转移控制权。其他同步函数也是同理,一旦调用同步函数,就只能等它执行完毕。
有栈协程因为在上下文切换时会保留完整的函数调用栈,所以允许从在任意深度的函数调用中将控制权转移出来。
抢占式调度
著名编程语言 Go 中实现了抢占式的协程调度。以它为例。
在旧版本的抢占式调度器中,Go 语言会在协程调用函数时进行协程调度。有栈协程中并不存在同步函数与异步函数的区别,这种抢占式调度实际上相当于在每个函数的入口插桩。
可以发现,旧版本的抢占式调度器中,如果某个协程一直运行并且不调用任何函数,那么抢占式调度器将会失效。因此在新版本中,Go 用 sysmon 监控线程配合信号机制,实现全新的抢占式调度器。当 sysmon 线程发现某个协程执行时间过长时,就会向对应的线程发送操作系统信号,触发上下文的切换。
sysmon 线程:始终在后台运行的监控线程,并不会执行 goroutine。
而这两种抢占式的调度器均不能在无栈协程中实现。Go 旧版本的调度器需要协程具有在普通函数调用中挂起的能力,新版本的调度器需要协程具有在任意位置挂起的能力。这两种都是无栈协程所难以实现的。因此,目前并没有采用无栈协程的主流语言实现了抢占式的调度器。
常见语言的实现选择
- C:并没有官方实现。常见的协程库均为有栈协程。难用。
- Rust:一般采用无栈协程,标准库仅提供 Future 机制但是并不提供运行时。好用。各个运行时的能力稍有区别。
- Go:语言核心提供了有栈协程及运行时的支持,相当统一且好用。完全可以把 goroutine 当成线程来写。
- Lua:有栈协程。只研究了一下是怎么实现的,并没有用它写过程序。
- Python:greenlet 提供了有栈协程支持,asyncio 提供了无栈协程支持。
- Javascript:提供了无栈协程的语法支持。还没太看懂实现方式,但是我感觉它的内部实现的数据结构可能是种类似有栈协程的,会保留整个协程栈的东西。
- Perl:提供了有栈协程的支持。在此基础上有人通过运行时修改符号表的神奇操作提供了 Promise 的语法支持。