生成器与协程:从 spawn 到 async/await
生成器背后的无栈协程,以及 async/await 是怎么用生成器实现的。
~/posts/generators-and-coroutines $ cat post.md
接着上一篇
之前那篇 数组拉平:从递归到生成器 介绍了 yield 和 yield* 的接管机制。生成器更广泛的应用是写异步函数——这篇主要记录生成器的协程机制、异步用法,以及 async/await 是怎么用生成器实现的。
协程
生成器函数能暂停和恢复,根本原因是协程。协程在 Go 里是核心 feature;在 Node 里实现得更面向对象一些,叫无栈协程(stackless coroutine)——显式的协程上下文不存在栈上。
函数递归无论如何都要用到栈,但栈可以被隐藏在 this 这种依赖结构里。有栈协程用寄存器(register)索引局部变量,访问上下文时通过变量名直接拿到栈寄存器位置加偏移量。无栈协程的”上下文”通常就是 this——区别在于它的上下文变量存在一个对象/类里,访问成员变量是去那个对象里取已经索引好的地址。
不论哪种方式,栈的生命周期都比函数本身长。函数返回后模拟的寄存器会销毁,而 this 在承担普通函数上下文的同时也充当协程上下文的索引——甚至不需要单独销毁。
无限循环
如果想让一个生成器无限循环输出,怎么写?
function* infiniteNumber(): IterableIterator<number> {
let index: number = 0;
while (1) {
yield index++;
}
}
const iterator: IterableIterator<number> = infiniteNumber();
while (1) {
if (iterator.next().done) {
break;
}
}
虽然写了 break,但永远不会执行——每次 next 都有值返回。反过来这也是把生成器内容全部消费完的标准做法。
异步执行器:spawn
spawn 函数用递归方式实现自动执行器,在协程里”等待” Promise 完成再继续:
const spawn = (genF) => {
return new Promise((resolve, reject) => {
const gen = genF();
const step = (nextF) => {
let next;
try {
next = nextF();
} catch (e) {
return reject(e);
}
if (next.done) {
return resolve(next.value);
}
Promise.resolve(next.value).then(
(v) => step(() => gen.next(v)),
(e) => step(() => gen.throw(e)),
);
};
step(() => gen.next(undefined));
});
};
这段代码改写自阮一峰的实现。
它做的事情:从生成器拿出一个 yield 的值,把它包成 Promise,等 resolve 后把结果通过 gen.next(v) 推回生成器、让生成器继续往下跑;遇到 reject 则用 gen.throw(e) 把异常推进生成器;生成器自己处理完控制权再回到执行器。一直跑到 done 为止。
async / await 的实现
async 函数本质上就是被 spawn 这样的执行器自动驱动的生成器。
async function fn() { ... }被编译器(或运行时)等价处理成”一个返回 Promise 的函数,函数体内每个await都是一个 yield 出去的值,由执行器接管 resolve/reject 后再继续”。await只能写在async里,正是因为它需要生成器机制的支持——纯函数没法在中间挂起再恢复。
也就是说,下面这段:
async function readBoth() {
const a = await readFileA();
const b = await readFileB();
return [a, b];
}
在生成器语义下等价于:
function readBoth() {
return spawn(function* () {
const a = yield readFileA();
const b = yield readFileB();
return [a, b];
});
}
每个 await 对应一次 yield,执行器吃下 Promise、resolve 后把结果回填给生成器,生成器继续运行。整套 async/await 语法本质上是生成器 + 执行器的语法糖。