3. 事件循环与回调机制

在异步网络编程中,事件循环(Event Loop)是整个运行时的心脏。它并不神秘,本质上是一个“永不停止的调度器”:不断检查是否有已就绪的 I/O 事件、是否有到期的定时器、是否有等待执行的回调,然后按既定顺序把它们派发出去。理解事件循环,关键不是记住某个框架的 API,而是理解“任务从哪里来、排到哪个队列、什么时候被取出执行”这条链路。

事件循环的工作原理

以 Node.js 为例,事件循环由 libuv 驱动,每一轮循环称为一个 tick,大致经历若干阶段:timers(处理到期的 setTimeout/setInterval)、pending callbacks(处理部分系统回调)、poll(等待并处理 I/O 事件)、check(处理 setImmediate)、close callbacks(处理关闭回调)。每个阶段都有一个 FIFO 队列,只有当队列清空或达到系统限制时,才会进入下一阶段。此外还有两个特殊队列:process.nextTick 队列和微任务(Promise.then)队列,它们不属于任何阶段,而是在每个阶段切换之间被优先清空,且 nextTick 优先于微任务。

这意味着一个重要的实践结论:微任务和 nextTick 会“插队”到当前阶段与下一阶段之间。如果在一个微任务里不断产生新的微任务,事件循环会被“饿死”,poll 阶段永远轮不到,I/O 回调无法执行。这是排查“服务卡死但 CPU 不高”类问题的常见切入点。

事件循环一轮 tick 的阶段与队列优先级 timers setTimeout / setInterval pending callbacks 系统级回调 poll 等待并处理 I/O check setImmediate close callbacks socket.on('close') 下一轮 tick 阶段切换之间:微任务插队区(每阶段结束后清空) process.nextTick 队列(最高优先) 在当前操作完成后立即执行 Promise 微任务队列 nextTick 清空后执行 回调注册 → 触发链路 注册 fs.readFile(cb) 入队 I/O 完成 → poll 队列 取出 poll 阶段调度 执行 cb(err, data)

任务队列与回调注册

回调注册的本质是“把函数和某个事件源绑定”。以文件读取为例:

const fs = require('fs');

fs.readFile('/data/config.json', 'utf8', (err, data) => {
  if (err) {
    console.error('读取失败:', err.code);
    return;
  }
  console.log('配置长度:', data.length);
});

console.log('这一行先执行');

调用 fs.readFile 时,Node 把读取请求交给 libuv 线程池,同时把回调函数登记到该请求上,然后立即返回。主线程继续执行 console.log,所以输出顺序是“这一行先执行”在前。当线程池完成读取,会把结果和回调一起放入 poll 阶段的队列,事件循环下一轮 poll 阶段取出并执行回调。这就是“注册—等待—触发”的完整链路。

需要区分两类回调:一类是 I/O 回调,由事件循环阶段调度;另一类是微任务回调,如 Promise.then,在当前调用栈清空后立即执行。二者混用时,执行顺序容易出错,建议在关键路径上用日志验证,而不是凭直觉推断。

定时器管理

定时器并不保证“精确到毫秒”。setTimeout(fn, 100) 的含义是“至少 100ms 后,把 fn 放入 timers 队列”,实际执行时间取决于事件循环何时回到 timers 阶段。如果 poll 阶段有大量 I/O 或长任务,定时器会被推迟。

常见实践参数示例:心跳检测间隔设为 3000ms,超时阈值设为 10000ms,即连续 3 次心跳未响应才判定断线。这种“间隔 × 次数”的设计比单次精确超时更抗抖动。清理定时器同样重要:连接关闭时必须调用 clearInterval(timer),否则定时器持续触发,造成内存泄漏和无效回调堆积。

定时器回调中不要执行超过间隔时长的同步计算。若单次处理耗时 200ms 而间隔为 100ms,回调会排队堆积,表现为“越跑越慢”。此时应改用 setTimeout 递归调度,让下一次定时从本次处理完成后开始计时。

回调地狱及其初步缓解

当多个异步操作存在依赖关系时,嵌套回调会迅速膨胀。例如依次读取用户、订单、商品三个文件:

fs.readFile('user.json', 'utf8', (e1, u) => {
  if (e1) return handle(e1);
  fs.readFile('order.json', 'utf8', (e2, o) => {
    if (e2) return handle(e2);
    fs.readFile('goods.json', 'utf8', (e3, g) => {
      if (e3) return handle(e3);
      render(JSON.parse(u), JSON.parse(o), JSON.parse(g));
    });
  });
});

这段代码的问题不只是“难看”:错误处理分散在三处、无法用 try/catch 捕获、中间变量难以复用、新增一步就要再嵌一层。初步缓解策略有三条,按改造成本从低到高排列。

  • 具名函数拆分:把每层回调提取为独立函数,降低嵌套深度,但控制流仍然分散。
  • Promise 链式化:用 .then 串联,错误统一由末尾 .catch 处理,中间值通过返回值传递。
  • async/await 线性化:以同步写法表达异步逻辑,配合 try/catch,可读性最好。

用 async/await 重写上面的例子:

const { readFile } = require('fs/promises');

async function loadAll() {
  try {
    const [u, o, g] = await Promise.all([
      readFile('user.json', 'utf8'),
      readFile('order.json', 'utf8'),
      readFile('goods.json', 'utf8'),
    ]);
    render(JSON.parse(u), JSON.parse(o), JSON.parse(g));
  } catch (err) {
    handle(err);
  }
}

这里用 Promise.all 把三个无依赖的读取并行化,总耗时约等于最慢的一次读取,而不是三次之和。若三者存在依赖,则改为顺序 await。判断依据很简单:后一步是否需要前一步的结果——需要则顺序,不需要则并行。

async/await 只是语法糖,底层仍是 Promise 微任务。在 await 之后,函数会从当前调用栈退出,后续代码进入微任务队列。因此不要在 await 之前持有必须同步完成的共享状态假设,否则会出现难以复现的竞态。

小结与观察方法

验证事件循环行为最直接的方式是打时间戳日志。例如在脚本开始记录 t0 = Date.now(),在 setTimeout、setImmediate、Promise.then、nextTick 各自回调中打印 Date.now() - t0,即可直观看到四者的相对顺序。多次运行若顺序稳定,说明理解正确;若出现抖动,通常是 poll 阶段耗时波动所致,而非调度逻辑改变。掌握这条观察链路,后续排查定时器漂移、回调堆积、微任务饿死等问题时就有了可靠的抓手。