用 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 函数是建立在前两者的基础上的。不妨考察如下程序
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,并对编译结果美化,可以得到如下的代码
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,所以先要满足这个要求
return new P(function (resolve, reject) {
...
})
其次,在这个 Promise 的构造函数内,使用正确的 this 和 arguemnts 调用生成器函数,获得迭代器,并把第一个值传入step
函数
generator = generator.apply(thisArg, _arguments)
step(generator.next())
step
函数用来处理迭代器返回的每个值。先判断当前的迭代器是否完成,完成的话直接resolve
它的值即可,async 函数返回的 Promise 此时进入fulfilled
状态
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 其中的值,这里只看其中一个
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 里看到一些说明,我把它写进了注释中
enum Instruction {
Next, // 开始或是用 next 恢复生成器
Throw, // 用 throw 恢复生成器
Return, // 用 return 恢复生成器
Break, // 跳转到指定的 label
Yield, // 把执行权让给调用者并传一个值
YieldStar, // 把执行权让给另一个迭代器
Catch, // 处理生成器内部抛出的例外
Endfinally // 恢复进入 finally 块之前的最后一个指令
}
// thisArg存储生成器的 this 指向,body 则是要处理的生成器函数
function __generator(thisArg, body) {
let continuation = {
label: 0, // 就是前面提到的 label
sent() {
if (t[0] % 2 === 1) throw t[1]
return t[1]
}, // 把得到的值送给body
trys: [],
// try 语句块的位置,是一个四元组的数组,分别是 try 的起始和结束 label,
// catch 块的起始 label,finally 块的起始 label
ops: []
// 操作
}
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, err
return __generator(this, function (continuation) {
switch (continuation.label) {
case 0:
url = 'sfsdfsdf'
continuation.label = 1
case 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.↩