从零实现协程调度器:栈式协程、无栈协程与M:N调度的工程实现

举报
Snowplow5180 发表于 2026/10/02 13:12:49 2026/10/02
【摘要】 在上一篇文章中,我们从零实现了一个向量化SQL查询引擎,覆盖了词法分析、查询规划和批式执行。那篇文章聚焦的是数据的读取和计算。这一次我们回到并发模型这个更底层的领域,目标是一个完整的协程调度器。并发编程的历史中有一个反复出现的矛盾:操作系统线程提供了简洁的阻塞语义,但上下文切换成本高昂(微秒级),且数量受限于内核资源;回调式异步IO避免了线程切换开销,但把代码变成嵌套的回调金字塔,可读性和错...

在上一篇文章中,我们从零实现了一个向量化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的语法更重要。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。