用 TypeScript 看无栈协程
发表于更新于阅读时长 6 分钟如果有英文版的话标题应该叫 Stackless Coroutine,A TypeScript Perspective
众所周知,ES2017 标准中为 JavaScript 规定了 async 函数,它是无栈协程的一种实现。然而考察 JS 引擎的实现是非常困难的,因此本文借助 TypeScript 编译器的转译功能来考察 JS 的 async 的具体实现方式
0. 背景知识
协程(coroutine),纤程(fiber)还有绿色线程(green thread),所指的都是是编程语言中的一类组件,即用户态非抢占性多任务。在拥有这样能力的语言中,该语言的运行时可以挂起/恢复一段程序,使得程序整体获得异步处理的能力。它常被用于提升异步处理的效率(因为众所周知,系统调用是缓慢的,而协程可以和函数调用一样快),也被用来替不支持线程的语言,比如下文中将要讨论的 JavsScript,提供异步能力。
协程可以按照以下 3 个角度进行分类1:
- 对称/不对称。前者提供让出执行权的原语,后者提供唤起协程和把执行权转交给调用者的原语
- 第一类/受限。协程是不是第一类对象
- 有栈/无栈。协程能在调用栈的任意处挂起,还是只能在协程的函数体内挂起
由于本文只讨论协程的实现,因此下文将着重讨论对实现有重要影响的第三点
特色 | 有栈协程 | 无栈协程 |
---|---|---|
可以挂起的位置 | 任意函数 | 仅有显式标注了 async 的函数 |
让出执行权时机 | 任意位置 | 仅可显式 yield |
上下文存放于(至少需要当前函数的栈,寄存器和重入位置) | 由运行时管理的协程栈 | 对象或闭包 |
如何挂起 | 运行时暂停当前函数的执行 | 把普通函数编译成可重入函数,挂起时直接结束当前函数 |
如何恢复 | 由运行时从协程栈中提取局部变量和挂起位置 | 重新调用函数,函数内从 continuation 中提取 label 并找到相应位置 |
额外内存开销 | 每个协程都需要独立的栈空间,可以设计成动态大小来减少开销 接近于零 | |
需要 | 运行时支持 | 编译器/预处理器支持 |
已有代码 | 可能不改写 | 需要改写 |
go lua ruby 采取了前者,python(asyncio) kotlin rust 以及 javascript 选择了后者。
kotlin 的协程实现可以参见这篇博客,在此仅作简要说明。由于 JVM 的闭包是只读的,所以 kotlin 选择了使用对象存放上下文。因此在每个 suspend(kotlin 里对 async 的叫法)函数中,都会生成一个 continuation 对象,它的 label 属性存放恢复点,result 属性存放调用结果,其他属性存放 this 对象,参数和局部变量。在调用异步方法时,额外传入 continuation,如果需要挂起,则结束当前函数,需要恢复时则调用 continuation 的 invokeSuspend 方法,它会用更新过的 continuation 调用原方法,原方法的局部变量从 continuation 中恢复; 否则把 label 值加上 1,进入下一个状态。这一过程正是大名鼎鼎的 CPS 变换。
那么 TypeScript 是怎么做的呢?
1. 从 Promise 开始
和 kotlin 那样白手起家不同,JS 从 ES6 开始就有了异步的基础设施,具体说来则是 Promise 和 Generator,JS 的 async 函数是建立在前两者的基础上的。不妨考察如下程序
ts
async function foo(s: string) {const url = 'sfsdfsdf'try {const a = await fetch(s)const b = await fetch(url)const c = await fetch('dasdsa')return [a, b, c]} catch {return 'error'}}
把编译目标设置为 ES2015,并对编译结果美化,可以得到如下的代码
js
function __awaiter(thisArg, _arguments = [], P = Promise, generator) {return new P(function (resolve, reject) {function fulfilled(value) {try {step(generator.next(value))} catch (e) {reject(e)}}function rejected(value) {try {step(generator.throw(value))} catch (e) {reject(e)}}function step(result) {result.done ? resolve(result.value) : P.resolve(result.value).then(fulfilled, rejected)}step((generator = generator.apply(thisArg, _arguments)).next())})}function foo(s) {return __awaiter(this, undefined, undefined, function* () {const url = 'sfsdfsdf'try {const a = yield fetch(s)const b = yield fetch(url)const c = yield fetch('dasdsa')return [a, b, c]} catch (_a) {return 'error'}})}
可以看到,原来的 async 函数被编译为了对__awaiter
的调用,函数体则几乎不变地被当作第四个参数传进去,只不过从 async 函数变成了生成器函数,而 async 则变成了 yield。
__awaiter
接受四个参数,分别是 this 对象,foo 函数的 arguments(虽然这是不推荐的行为,但还是有人会直接访问它),Promise 实现(可能有人会想用不同于原生 Promise 的某个实现,比如bluebird),以及生成器函数. 那么__awaiter
具体做了什么呢?
首先,一个 async 函数应当返回一个 Promise,所以先要满足这个要求
js
return new P(function (resolve, reject) {...})
其次,在这个 Promise 的构造函数内,使用正确的 this 和 arguemnts 调用生成器函数,获得迭代器,并把第一个值传入step
函数
js
generator = generator.apply(thisArg, _arguments)step(generator.next())
step
函数用来处理迭代器返回的每个值。先判断当前的迭代器是否完成,完成的话直接resolve
它的值即可,async 函数返回的 Promise 此时进入fulfilled
状态
js
function step(result) {result.done ? resolve(result.value) : P.resolve(result.value).then(fulfilled, rejected)}
如果没完结的话,则把fulfilled
以及rejected
挂在迭代器返回的 Promise 上。注意迭代器返回的不一定是 Promise,需要Promise.resolve对其进行包装
fulfille
d和rejected
都用于处理返回的 Promise 其中的值,这里只看其中一个
js
function fulfilled(value) {try {step(generator.next(value))} catch (e) {reject(e)}}
可以看到当返回的 Promise 进入fulfilled
状态时,fulfilled
会把这个值传给迭代器以获取下一迭代,并把返回值再传入step中. 如果这一过程中迭代器抛出错误,则使 async 函数返回的 Promise 进入rejected
状态.
结论上说来,JavaScript 中的 async 函数的核心即是返回 Promise 的迭代器。迭代器每返回一个 Promise,调用者都会等待它完成,之后把得到的值再传回迭代器,并获取下一个 Promise。当迭代器完成或者报错时,async 函数返回的 Promise 即可进入对应状态.
这是典型的用受限协程实现第一类协程的方法,尽管受限协程只能把执行权让给调用者,然而调用者可以把返回的东西转交给调度器,这样就能实现把执行权让给任意协程。
2. 到 Generator 结束
读到这里可能有人发现,第零节和第一节似乎并没有什么关系,闭包和 switch...case 都没有出现,这是因为 JavaScript 的生成器掩盖了这一复杂性。把前文里的生成器函数继续编译下去,则能得到如下代码. 这段代码即使在美化之后也非常复杂,不过可以在 TSC 里看到一些说明,我把它写进了注释中
js
enum Instruction {Next, // 开始或是用 next 恢复生成器Throw, // 用 throw 恢复生成器Return, // 用 return 恢复生成器Break, // 跳转到指定的 labelYield, // 把执行权让给调用者并传一个值YieldStar, // 把执行权让给另一个迭代器Catch, // 处理生成器内部抛出的例外Endfinally // 恢复进入 finally 块之前的最后一个指令}// thisArg存储生成器的 this 指向,body 则是要处理的生成器函数function __generator(thisArg, body) {let continuation = {label: 0, // 就是前面提到的 labelsent() {if (t[0] % 2 === 1) throw t[1]return t[1]}, // 把得到的值送给bodytrys: [],// try 语句块的位置,是一个四元组的数组,分别是 try 的起始和结束 label,// catch 块的起始 label,finally 块的起始 labelops: []// 操作}let f, // 指示生成器正在运行y, // 为yield*准备t // 多功能的临时变量return {next: verb(Instruction.Next),throw: verb(Instruction.Throw),return: verb(Instruction.Return),[Symbol.iterator]() {return this}}// 工具函数function verb(n) {return function (v) {return step([n, v])}}// 处理迭代器function step(op) {if (f) throw new TypeError('Generator is already executing.')while (continuation) {// 实现细节略去}return { value: op[0] ? op[1] : undefined, done: true }}}function foo(s) {let url, a, b, c, errreturn __generator(this, function (continuation) {switch (continuation.label) {case 0:url = 'sfsdfsdf'continuation.label = 1case 1:continuation.trys.push([1, 5, , 6])return [Instruction.Yield, fetch(s)]case 2:a = continuation.sent()return [Instruction.Yield, fetch(url)]case 3:b = continuation.sent()return [Instruction.Yield, fetch('dasdsa')]case 4:c = continuation.sent()return [Instruction.Return, [a, b, c]]case 5:err = continuation.sent()return [Instruction.Return, 'error']case 6:return [Instruction.Return]}})}
可以看到与 kotlin 的实现不同,局部变量使用闭包保存,只有需要传递的状态才被放在continuation
对象里,因此局部变量只需初始化一次,而非每次都要从continuation中还原。每个yield
和return
关键字都会生成一个对应的case
分支,最后还有一个额外的默认返回分支.
- Moura, Ana Lúcia De, and Roberto Ierusalimschy. "Revisiting coroutines." ACM Transactions on Programming Languages and Systems (TOPLAS) 31.2 (2009): 6.↩