事件循环

What — 是什么

事件循环(Event Loop)是 Node.js 实现异步非阻塞 I/O 的核心机制,在单线程中通过轮询事件队列来调度任务执行。

核心概念:

  • 调用栈(Call Stack):执行同步代码的 LIFO 栈
  • 微任务队列(Microtask Queue)Promise.thenprocess.nextTick 等,每个阶段后立即清空
  • 宏任务队列(Macrotask Queue)setTimeoutsetImmediate、I/O 回调等,按阶段处理
  • 6 个阶段:timers → pending callbacks → idle/prepare → poll → check → close callbacks

关键特性:

  • 单线程执行,避免锁和竞争
  • 非阻塞 I/O:I/O 操作委托给 libuv 线程池
  • process.nextTick 优先级高于 Promise.then

运行机制:

  • 内存模型:V8 堆内存 + libuv 事件循环
  • 执行模型:同步代码 → 微任务 → 下一个宏任务阶段 → 微任务 → …
  • 并发模型:单线程事件循环 + 线程池(4 线程默认,可配置 UV_THREADPOOL_SIZE)

Why — 为什么

适用场景:

  • I/O 密集型服务(HTTP 服务器、数据库查询)
  • 实时应用(WebSocket、聊天室)
  • 流处理(文件、网络数据流)

对比其他语言:

维度Node.js 事件循环Go GoroutineJava NIO
性能高(I/O 密集)极高
生态极丰富(npm)丰富成熟
上手难度中(理解异步模型)高(Netty 复杂)
并发能力高(I/O 并发)极高

优缺点:

  • ✅ 优点:
    • 异步 I/O 天然适合高并发网络服务
    • 避免多线程的锁和竞争问题
    • 前后端统一语言(JavaScript)
  • ❌ 缺点:
    • CPU 密集任务会阻塞事件循环
    • 回调地狱(可用 async/await 缓解)
    • 错误栈不直观,调试异步链困难

How — 怎么用

快速上手

// 理解执行顺序
console.log('1 - 同步');

setTimeout(() => console.log('2 - 宏任务 setTimeout'), 0);

Promise.resolve().then(() => console.log('3 - 微任务 Promise'));

process.nextTick(() => console.log('4 - 微任务 nextTick'));

console.log('5 - 同步');

// 输出顺序:1 → 5 → 4 → 3 → 2
// nextTick 优先于 Promise

代码示例

避免阻塞事件循环:

// ❌ 危险:CPU 密集任务阻塞整个进程
function heavyCompute(n) {
    let sum = 0;
    for (let i = 0; i < n; i++) sum += Math.sqrt(i);
    return sum;
}

// ✅ 修复:拆分为小块,用 setImmediate 让出控制权
function heavyComputeAsync(n, callback) {
    let sum = 0;
    let i = 0;
    const chunk = 100000;

    function computeChunk() {
        const end = Math.min(i + chunk, n);
        for (; i < end; i++) sum += Math.sqrt(i);
        if (i < n) {
            setImmediate(computeChunk); // 让出事件循环
        } else {
            callback(sum);
        }
    }
    computeChunk();
}

正确处理异步错误:

// ❌ 无法捕获异步回调中的错误
try {
    setTimeout(() => { throw new Error('missed'); }, 0);
} catch (e) {
    // 永远不会到这里
}

// ✅ 使用 async/await + Promise
async function safeOperation() {
    try {
        await riskyAsyncTask();
    } catch (e) {
        console.error('caught:', e.message);
    }
}

常见问题与踩坑

问题原因解决方案
事件循环阻塞CPU 密集任务占用主线程拆分任务 / 使用 Worker Threads / Offload 到子进程
微任务饥饿大量 nextTick 递归,宏任务无法执行避免递归 nextTick,改用 setImmediate
setTimeout(fn, 0) 不是立即执行受最小延迟限制(1ms 嵌套后 4ms)setImmediate 替代(check 阶段)
未处理的 Promise 拒绝没有 .catch 或 try/catch监听 process.on('unhandledRejection')

最佳实践

  • CPU 密集任务使用 worker_threads 或子进程
  • 始终处理 Promise 的 rejection
  • async/await 替代回调链,代码更清晰
  • 生产环境监控事件循环延迟(perf_hooks.monitorEventLoopDelay

面试题

Q1: Node.js 事件循环的 6 个阶段分别是什么?各自处理什么任务?

6 个阶段依次为:timers(执行 setTimeout/setInterval 回调)、pending callbacks(执行系统级回调如 TCP 错误)、idle/prepare(仅内部使用)、poll(获取新 I/O 事件,执行 I/O 回调)、check(执行 setImmediate 回调)、close callbacks(执行 socket.on(‘close’) 等)。每个阶段结束后都会清空微任务队列。

Q2: process.nextTick 和 Promise.then 都是微任务,执行顺序有什么区别?

process.nextTick 属于 nextTick 队列,Promise.then 属于微任务队列。每个阶段结束后先清空 nextTick 队列,再清空微任务队列,因此 nextTick 始终优先于 Promise.then 执行。应避免递归调用 nextTick,否则会造成微任务饥饿,阻塞宏任务。

Q3: setTimeout(fn, 0) 和 setImmediate(fn) 谁先执行?

在主模块(非 I/O 上下文)中,执行顺序不确定,取决于性能和系统调度。但在 I/O 回调(如 fs.readFile 的回调)内部,setImmediate 一定先于 setTimeout 执行,因为 I/O 回调在 poll 阶段处理完毕后立即进入 check 阶段。因此需要”在 I/O 之后优先执行”时应使用 setImmediate。

Q4: 什么是微任务饥饿?如何避免?

微任务饥饿是指大量微任务(尤其是 process.nextTick)不断递归入队,导致宏任务永远无法执行的现象。避免方法:不要递归调用 process.nextTick,改用 setImmediate 让出控制权;确保微任务数量可控,不在循环中无限制地产生微任务。

Q5: 为什么 CPU 密集型任务会阻塞事件循环?如何解决?

Node.js 主线程是单线程,事件循环在主线程上运行。CPU 密集型任务会占用主线程,导致事件循环无法继续轮询,所有 I/O 回调和定时器都被延迟。解决方案:使用 worker_threads 将计算移到工作线程;使用 child_process 拆分到子进程;或将大任务拆分为小片段,通过 setImmediate 分步执行让出主线程。


相关链接: