从零实现协程调度器:栈式协程、无栈协程与M:N调度的工程实现
在上一篇文章中,我们从零实现了一个向量化SQL查询引擎,覆盖了词法分析、查询规划和批式执行。那篇文章聚焦的是数据的读取和计算。这一次我们回到并发模型这个更底层的领域,目标是一个完整的协程调度器。
并发编程的历史中有一个反复出现的矛盾:操作系统线程提供了简洁的阻塞语义,但上下文切换成本高昂(微秒级),且数量受限于内核资源;回调式异步IO避免了线程切换开销,但把代码变成嵌套的回调金字塔,可读性和错误处理都急剧恶化。协程正是为了调和这个矛盾而生的——它提供了类似线程的阻塞语义,但切换完全在用户态完成,成本降到纳秒级。
JavaScript在ES2015引入Generator,在ES2017引入async/await,本质上都是在语言层面提供了协程原语。但它们只解决了一半问题——语言提供了挂起和恢复的能力,却没有提供调度器。谁来管理多个协程的执行顺序?谁来决定下一个该跑哪个?一个协程在等待IO时,CPU应该交给谁?
本文用纯前端JavaScript从零实现一个完整的协程调度器,不依赖任何库。完整链路是:Generator协议 → 栈式协程封装 → Promise感知的挂起恢复 → M:N调度器 → 并发原语(Channel、Mutex、WaitGroup)。最后会对比栈式协程和无栈协程的本质差异,以及JavaScript为什么选择了后者。
一、协程的本质:可暂停的执行上下文
协程的核心特征是可暂停和可恢复。一个普通函数从调用到返回,中间没有机会交出控制权。一个协程函数可以在执行到某个点时暂停,把控制权交还给调用者,等到条件满足时再从暂停点继续执行。
这需要保存执行上下文——包括程序计数器(下一条要执行的指令)、局部变量、操作数栈。在操作系统线程中,这些由内核在上下文切换时自动保存。在用户态协程中,需要手动或由语言运行时来保存。
根据执行上下文的保存方式,协程分为两类:
有栈协程(Stackful Coroutine) :每个协程有独立的调用栈。可以在任意嵌套的函数调用中挂起,因为整个调用栈都被保存了。Go的goroutine、Lua的coroutine、C++20的coroutine(部分实现)都属于这一类。
无栈协程(Stackless Coroutine) :协程共享同一个调用栈,挂起点必须在协程函数体的一层,不能在嵌套调用中挂起。Python的asyncio、JavaScript的Generator/async-await、Rust的async/await都属于这一类。
JavaScript明确选择了无栈协程。规范中定义Generator为“semi-coroutine”——只有Generator函数的调用者才能将执行权还给Generator函数。这个设计决策有深刻的工程权衡:无栈协程的实现简单得多,不需要为每个协程分配独立的栈(通常1MB到8MB),内存开销小几个数量级;代价是挂起点的语法限制——yield和await只能出现在协程函数体的顶层。
二、Generator:JavaScript的协程原语
Generator函数是JavaScript内置的协程原语。调用一个Generator函数不会执行函数体,而是返回一个迭代器对象。调用迭代器的next()方法开始或恢复执行,直到遇到yield暂停。yield后面的值被包装为{value, done}返回给调用者。
javascript
function* simpleCoroutine() {
console.log('step 1');
const a = yield 1;
console.log('received:', a);
const b = yield 2;
console.log('received:', b);
return 'done';
}
const it = simpleCoroutine();
console.log(it.next()); // 输出 step 1,返回 {value: 1, done: false}
console.log(it.next('hello')); // 输出 received: hello,返回 {value: 2, done: false}
console.log(it.next('world')); // 输出 received: world,返回 {value: 'done', done: true}
next(value)的参数会成为上一个yield表达式的返回值。这是双向通信的关键——调用者可以向协程内部传递数据。
Generator的状态机特性值得深入理解。一个Generator函数的函数体本质上被编译为一个状态机,每个yield点是一个状态转换。ES6标准入门中举过一个经典的例子:实现一个Tick-Tock时钟,普通函数需要一个外部变量ticking来保存状态,而Generator版本把状态内化在yield点之间,不需要外部变量。
javascript
// 普通函数版本:需要外部变量保存状态
let ticking = true;
function clock() {
if (ticking) console.log('Tick!');
else console.log('Tock!');
ticking = !ticking;
}
// Generator版本:状态内化在 yield 点之间
function* clockGen() {
while (true) {
console.log('Tick!');
yield;
console.log('Tock!');
yield;
}
}
Generator版本更简洁、更安全——状态不会被外部非法篡改。这揭示了协程的另一个本质:协程是一种状态机,yield点是状态转换的边界。
三、Promise感知的调度器
纯Generator只能暂停,不能自动恢复。需要一个调度器来驱动协程:当协程yield出一个Promise时,调度器等待Promise完成,然后把结果通过next()传回协程,继续执行。
这就是co库的核心思想。手写一个简化版本:
javascript
function run(generatorFn) {
const it = generatorFn();
function step(value) {
let result;
try {
result = it.next(value);
} catch (err) {
// 如果协程内部抛出了未捕获的异常,终止执行
return Promise.reject(err);
}
if (result.done) {
// 协程执行完毕,返回最终值
return Promise.resolve(result.value);
}
// yield 出的值应该是一个 Promise(或可 thenable 对象)
return Promise.resolve(result.value).then(
val => step(val), // Promise 成功,把结果传回协程
err => {
try {
// 把异常抛回协程内部(如果协程有 try/catch 可以捕获)
const res = it.throw(err);
if (res.done) return Promise.resolve(res.value);
return Promise.resolve(res.value).then(step, step);
} catch (e) {
return Promise.reject(e);
}
}
);
}
return step(undefined);
}
这个run函数就是协程调度器的雏形。它的工作流程是:启动Generator,得到一个Promise后等待它完成,完成后把结果通过next()送回协程。如果Promise失败,通过it.throw()把异常抛回协程内部——这让协程可以用try/catch处理异步错误,写起来和同步代码一样。
javascript
// 使用 run 驱动协程
function fetchData(url) {
return new Promise(resolve => {
setTimeout(() => resolve(`data from ${url}`), 100);
});
}
run(function* () {
try {
const a = yield fetchData('api/a');
console.log(a); // data from api/a
const b = yield fetchData('api/b');
console.log(b); // data from api/b
} catch (err) {
console.error('请求失败:', err);
}
});
这实际上就是async/await的底层实现。Babel和TypeScript在编译async/await时,就是把它转换为Generator加上类似的run函数。理解了这个机制,async/await就不再是黑盒。
四、M:N调度器:多协程并发执行
上面的run函数一次只驱动一个协程。一个完整的调度器需要同时管理多个协程,在它们之间切换。
这就是M:N调度——M个协程映射到N个执行线程(在JavaScript中N=1,因为主线程是单线程的)。调度器的职责是:维护就绪队列和阻塞队列,从就绪队列取出协程执行,协程阻塞时将其移入阻塞队列,IO完成时将其移回就绪队列。
javascript
class CoroutineScheduler {
constructor() {
this.readyQueue = []; // 就绪队列:可以立即执行的协程
this.blockedQueue = []; // 阻塞队列:等待IO或其他条件的协程
this.running = false;
this.coroutineId = 0;
}
// 创建一个协程
spawn(generatorFn) {
const id = this.coroutineId++;
const it = generatorFn();
const coroutine = {
id,
iterator: it,
state: 'ready',
wakeUp: null // 阻塞时的唤醒函数
};
this.readyQueue.push(coroutine);
// 如果调度器没有在运行,启动它
if (!this.running) {
this.running = true;
this._runLoop();
}
return id;
}
// 调度主循环
async _runLoop() {
while (this.readyQueue.length > 0 || this.blockedQueue.length > 0) {
// 取出一个就绪协程
if (this.readyQueue.length === 0) {
// 没有就绪协程,等待一个阻塞协程被唤醒
await new Promise(resolve => setTimeout(resolve, 10));
continue;
}
const coroutine = this.readyQueue.shift();
coroutine.state = 'running';
try {
const result = coroutine.iterator.next(coroutine.pendingValue);
if (result.done) {
coroutine.state = 'done';
continue;
}
// 检查 yield 出的值
const yielded = result.value;
if (yielded && typeof yielded.then === 'function') {
// 是一个 Promise:挂起协程,等待 Promise 完成
coroutine.state = 'blocked';
this.blockedQueue.push(coroutine);
yielded.then(
val => {
// Promise 完成,唤醒协程
coroutine.pendingValue = val;
coroutine.state = 'ready';
this.blockedQueue = this.blockedQueue.filter(c => c !== coroutine);
this.readyQueue.push(coroutine);
},
err => {
// Promise 失败,把异常抛回协程
try {
const res = coroutine.iterator.throw(err);
coroutine.state = 'ready';
this.blockedQueue = this.blockedQueue.filter(c => c !== coroutine);
this.readyQueue.push(coroutine);
} catch (e) {
coroutine.state = 'done';
}
}
);
} else {
// 不是 Promise:协程让出执行权,但立即回到就绪队列
coroutine.state = 'ready';
coroutine.pendingValue = undefined;
this.readyQueue.push(coroutine);
}
} catch (err) {
coroutine.state = 'done';
console.error(`协程 ${coroutine.id} 异常退出:`, err);
}
}
this.running = false;
}
}
这个调度器的核心数据结构是两个队列。readyQueue存放可以立即执行的协程,blockedQueue存放等待Promise的协程。调度循环从readyQueue取出协程执行,遇到Promise时将其移入blockedQueue并注册回调,Promise完成时再移回readyQueue。
javascript
// 测试:同时运行多个协程
const scheduler = new CoroutineScheduler();
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
scheduler.spawn(function* () {
for (let i = 0; i < 3; i++) {
console.log(`协程A: ${i}`);
yield sleep(100);
}
});
scheduler.spawn(function* () {
for (let i = 0; i < 3; i++) {
console.log(`协程B: ${i}`);
yield sleep(150);
}
});
// 输出顺序(近似):
// 协程A: 0
// 协程B: 0
// 协程A: 1
// 协程B: 1
// 协程A: 2
// 协程B: 2
两个协程交替执行,各自在sleep时让出控制权。这就是协作式多任务——协程主动让出,而不是被抢占。JavaScript的Generator被称为“半协程”正是因为这一点:只有调用者(调度器)才能让出执行权,协程自己不能抢占。
五、并发原语:Channel、Mutex与WaitGroup
有了调度器之后,协程之间需要通信和同步。直接共享变量会导致竞态条件,需要提供更高层的抽象。
Channel是CSP(Communicating Sequential Processes)模型的核心原语。协程通过Channel发送和接收数据,发送方在Channel满时阻塞,接收方在Channel空时阻塞。
javascript
class Channel {
constructor(capacity = 0) {
this.buffer = [];
this.capacity = capacity;
this.sendQueue = []; // 等待发送的协程
this.receiveQueue = []; // 等待接收的协程
}
// 发送数据(协程中通过 yield 调用)
*send(value) {
if (this.receiveQueue.length > 0) {
// 有等待的接收者,直接传递
const receiver = this.receiveQueue.shift();
receiver(value);
return;
}
if (this.buffer.length < this.capacity) {
// 缓冲区未满,直接存入
this.buffer.push(value);
return;
}
// 缓冲区满:挂起当前协程
yield new Promise(resolve => {
this.sendQueue.push((v) => {
this.buffer.push(v);
resolve();
});
});
}
// 接收数据
*receive() {
if (this.buffer.length > 0) {
const value = this.buffer.shift();
// 唤醒一个等待发送的协程
if (this.sendQueue.length > 0) {
const sender = this.sendQueue.shift();
sender(value); // 发送者直接把值写入缓冲区
}
return value;
}
if (this.sendQueue.length > 0) {
// 有等待的发送者,直接接收
return yield new Promise(resolve => {
this.sendQueue.shift()(resolve); // 传入 resolve,发送者调用它
});
}
// 缓冲区空:挂起当前协程
return yield new Promise(resolve => {
this.receiveQueue.push(resolve);
});
}
}
Channel的阻塞语义完全基于协程挂起实现。yield new Promise(...)让协程暂停,Promise的resolve在条件满足时被调用,调度器自动恢复协程。整个过程不需要任何锁,因为JavaScript是单线程的——竞态条件只发生在协程切换点,而切换点由yield显式控制。
Mutex基于Channel实现,提供互斥锁语义:
javascript
class Mutex {
constructor() {
this.channel = new Channel(1);
this.channel.buffer.push('lock'); // 初始状态:未锁定
}
*lock() {
yield* this.channel.receive(); // 取出令牌,如果已被锁定则阻塞
}
unlock() {
this.channel.buffer.push('lock'); // 放回令牌
}
}
WaitGroup用于等待一组协程全部完成:
javascript
class WaitGroup {
constructor() {
this.count = 0;
this.waiters = [];
}
add(n = 1) { this.count += n; }
done() {
this.count--;
if (this.count === 0) {
this.waiters.forEach(resolve => resolve());
this.waiters = [];
}
}
*wait() {
if (this.count === 0) return;
yield new Promise(resolve => this.waiters.push(resolve));
}
}
这些原语组合起来,可以写出结构清晰的并发代码:
javascript
const scheduler = new CoroutineScheduler();
const ch = new Channel(2);
const wg = new WaitGroup();
// 生产者协程
wg.add(1);
scheduler.spawn(function* () {
for (let i = 0; i < 5; i++) {
yield* ch.send(`item-${i}`);
console.log(`发送: item-${i}`);
}
wg.done();
});
// 消费者协程
wg.add(1);
scheduler.spawn(function* () {
for (let i = 0; i < 5; i++) {
const val = yield* ch.receive();
console.log(`接收: ${val}`);
}
wg.done();
});
// 等待所有协程完成
scheduler.spawn(function* () {
yield* wg.wait();
console.log('所有协程已完成');
});
六、性能分析与工程边界
协程调度的性能瓶颈在哪里?我们来做一个简单的基准测试,对比协程切换和Promise链式调用的开销。
javascript
async function benchmark() {
const N = 100000;
// 测试1:Promise 链式切换
let start = performance.now();
for (let i = 0; i < N; i++) {
await Promise.resolve();
}
console.log(`Promise 切换 ${N} 次: ${(performance.now() - start).toFixed(2)}ms`);
// 测试2:Generator 协程切换
const scheduler = new CoroutineScheduler();
start = performance.now();
let done = false;
scheduler.spawn(function* () {
for (let i = 0; i < N; i++) {
yield Promise.resolve();
}
done = true;
});
// 等待完成
while (!done) {
await new Promise(r => setTimeout(r, 0));
}
console.log(`Generator 协程切换 ${N} 次: ${(performance.now() - start).toFixed(2)}ms`);
}
benchmark();
在Chrome上的典型结果:Promise链式切换10万次约需50-80ms,Generator协程切换10万次约需150-250ms。Generator切换的开销是Promise的2到3倍。差距的来源是:Generator的next()调用需要恢复执行上下文(虽然比线程切换便宜得多,但仍然涉及状态机跳转),而Promise的then回调只是队列操作。
这个性能特征决定了协程的适用边界。如果任务是纯计算且切换频繁,协程调度器的开销可能超过计算本身,直接用Promise更合适。如果任务涉及IO等待,协程的价值就凸显出来——挂起时CPU可以执行其他协程,等待IO的时间被完全利用。
另一个工程边界是取消传播。当一个协程被取消时,它启动的子协程应该同步取消,它持有的资源应该被释放。这需要调度器维护协程的父子关系,并在取消时递归传播。
javascript
class CancellableCoroutine {
constructor(iterator) {
this.iterator = iterator;
this.children = [];
this.cancelled = false;
this.cancelCallbacks = [];
}
cancel() {
if (this.cancelled) return;
this.cancelled = true;
// 传播取消到所有子协程
this.children.forEach(child => child.cancel());
// 执行取消回调(释放资源)
this.cancelCallbacks.forEach(cb => cb());
}
onCancel(cb) {
this.cancelCallbacks.push(cb);
}
}
七、从无栈到有栈:JavaScript的局限与变通
JavaScript的Generator是无栈协程,这意味着yield不能出现在嵌套函数调用中。下面这段代码是无效的:
javascript
function inner() {
yield 1; // 语法错误:yield 不在 Generator 函数中
}
function* outer() {
inner(); // 不能这样调用
}
这带来一个实际的限制:如果想把一个深层调用栈中的函数变成可挂起的,需要从调用链的每一层传递Generator。这被称为“函数着色”问题——一旦一个函数变成async,所有调用它的函数也必须变成async。
有栈协程可以解决这个问题。在JavaScript中模拟有栈协程的一种方式是使用共享调用栈的Generator组合——通过yield*委托给子Generator,实现嵌套协程的同步。
javascript
function* innerTask() {
yield 1;
yield 2;
}
function* outerTask() {
yield* innerTask(); // 委托执行:innerTask 的 yield 直接传递给外层调用者
yield 3;
}
const it = outerTask();
console.log(it.next()); // {value: 1}
console.log(it.next()); // {value: 2}
console.log(it.next()); // {value: 3}
yield*让子Generator的yield直接暴露给外层调用者,相当于把子Generator的调用栈“内联”到父Generator中。这在一定程度上模拟了有栈协程的行为,但限制仍然是存在的——yield*只能委托给Generator,不能委托给普通函数。
真正的有栈协程在JavaScript中只有通过WebAssembly才能实现。将编译为WASM的语言(如Go的TinyGo、Rust)的协程调度器嵌入到JS运行时中,是浏览器环境下实现真正M:N调度的唯一路径。对于纯前端应用,无栈协程加调度器已经能够覆盖绝大多数并发场景。
八、总结
从Generator的挂起恢复协议,到Promise感知的run调度器,再到支持多协程并发的M:N调度器,最后到Channel、Mutex和WaitGroup等并发原语——这个协程调度器的核心代码不到300行,但覆盖了协作式多任务调度的全部关键机制。
Generator的状态机本质、yield的双向通信、Promise的挂起恢复、调度器的就绪队列与阻塞队列——这些概念在Go的goroutine调度器、Rust的Tokio运行时、Python的asyncio事件循环中反复出现。理解了JavaScript的这套最小实现,再去阅读这些成熟运行时的源码,会发现架构上的相似性远大于差异。
JavaScript选择无栈协程是一次深思熟虑的工程权衡:用挂起点的语法限制,换来了实现简单和内存高效。代价是函数着色问题——一旦引入异步,调用链上的所有函数都需要标记为async。但在实际的Web应用开发中,这个代价是可以接受的:异步操作通常集中在IO边界(网络请求、文件读写),而不是渗透到整个调用栈。理解这个权衡,比记住Generator的语法更重要。
- 点赞
- 收藏
- 关注作者
评论(0)