事件循环
Node.js 事件循环详解
Node.js 的事件循环是其实现非阻塞 I/O 的核心机制。它基于 libuv 库,将各种异步操作(定时器、I/O、setImmediate 等)组织成不同的阶段,每个阶段都有一个先进先出的回调队列。事件循环会按照固定的顺序反复执行这些阶段。
一、事件循环各阶段
事件循环的完整阶段顺序如下(按执行顺序排列):
| 阶段 | 描述 | 主要回调 |
|---|---|---|
| timers | 执行到期的 setTimeout 和 setInterval 回调。 | setTimeout(cb, delay)、setInterval(cb, delay) |
| pending callbacks | 执行延迟到下一个循环迭代的 I/O 回调(例如某些系统错误如 ECONNREFUSED)。 | 上一轮循环中未执行的 I/O 回调 |
| idle, prepare | 仅内部使用,用于 libuv 内部操作。开发者无需关注。 | - |
| poll | 最重要的阶段。检索新的 I/O 事件(如文件读取、网络请求完成),执行与 I/O 相关的回调(除 timers、check、close 以外的回调)。如果没有其他异步任务,事件循环会阻塞在此阶段等待新事件。 | I/O 回调(如 fs.readFile、http.request 的回调) |
| check | 执行 setImmediate 的回调。 | setImmediate(cb) |
| close callbacks | 执行关闭回调,如 socket.on('close')。 | socket.on('close')、process.on('exit') 等 |
各阶段详细说明
1. timers 阶段
- 执行所有到期的
setTimeout和setInterval回调。 - 注意:实际执行延迟可能大于设定的阈值,因为事件循环可能被其他回调阻塞。
2. pending callbacks 阶段
- 执行一些上一轮循环中未处理的 I/O 回调,通常是与网络或文件系统有关的错误回调(如 ECONNREFUSED)。
3. idle / prepare 阶段
- 仅 libuv 内部使用,开发者编写的 JavaScript 代码不会在此阶段执行。
4. poll 阶段
- 核心行为:
- 如果 poll 队列不为空,则同步取出队列中的回调依次执行,直到队列清空或达到系统上限。
- 如果 poll 队列为空:
- 若有待执行的
setImmediate回调,则立即结束 poll 阶段,进入 check 阶段。 - 若有即将到期的 timers,则检查定时器并跳回 timers 阶段执行到期回调。
- 否则,事件循环会在此阶段阻塞等待新的 I/O 事件或定时器到期,以提高效率。
- 若有待执行的
- 这是 Node.js 实现高并发 I/O 的关键:绝大部分时间事件循环停留在 poll 阶段,等待新的事件。
5. check 阶段
- 执行所有
setImmediate回调。setImmediate的回调会在 I/O 处理完毕后立即执行,不经过 poll 阶段的等待。
6. close callbacks 阶段
- 执行关闭事件回调,如
socket.on('close', () => {...})。
二、process.nextTick 与 setImmediate 的区别与执行时机
process.nextTick
- 不属于事件循环任何一个阶段,而是一个独立的队列,在每个阶段结束后立即执行。
- 执行时机:当前阶段的所有回调执行完毕后,在进入下一个阶段之前,会完全清空
process.nextTick队列。 - 因为
nextTick会在当前阶段末尾执行,所以可被看作比任何后续阶段的回调(包括setImmediate)具有更高的优先级。
setImmediate
- 属于 check 阶段的回调,会在 poll 阶段之后执行。
- 执行时机:事件循环进入 check 阶段时,清空
setImmediate队列。
区别总结
| 特性 | process.nextTick | setImmediate |
|---|---|---|
| 所属阶段 | 独立队列,不属于事件循环任何阶段 | check 阶段 |
| 执行时机 | 当前阶段结束后,立即执行 | 事件循环进入 check 阶段时执行 |
| 优先级 | 非常高(立即执行) | 较低(需经过 poll 阶段) |
| 递归调用影响 | 递归调用 nextTick 会阻塞事件循环(导致 I/O 饥饿) | 递归调用 setImmediate 不会阻塞,因为每次都会经过 poll 阶段 |
| 典型应用 | 在同一个阶段内推迟执行,或强制执行某些清理操作 | 在 I/O 完成后安排工作,或实现递归异步调用 |
代码示例:nextTick vs setImmediate 的执行顺序
setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick"));
console.log("同步代码");
// 输出顺序:
// 同步代码
// nextTick
// setImmediate
setTimeout(fn, 0) 与 setImmediate 的比较
- 如果调用时机在 I/O 循环内(比如在文件读取回调中),
setImmediate总是先于setTimeout(fn,0)执行。 - 如果在主模块中直接调用,顺序取决于事件循环启动时的性能(通常
setTimeout(fn,0)可能先执行,但不确定)。因此要依赖顺序时需使用setImmediate或nextTick。
三、微任务与宏任务在 Node 中的表现
宏任务(MacroTask)
- 在 Node.js 中,宏任务可以理解为事件循环各阶段队列中的回调。
setTimeout、setInterval→ timers 阶段- I/O 回调 → poll 阶段
setImmediate→ check 阶段close事件 → close callbacks 阶段
微任务(MicroTask)
- 微任务包括:
Promise.then/catch/finally回调queueMicrotaskprocess.nextTick(严格说它更特别,优先级高于微任务)
执行顺序规则
在 Node.js 中(以官方文档和常见实现为准),执行顺序为:
- 执行当前阶段的所有宏任务(如 timers 队列中的全部回调)。
- 清空
process.nextTick队列。 - 清空微任务队列(Promise、queueMicrotask)。
- 进入事件循环的下一个阶段。
即:nextTick > 微任务 (Promise) > 其他宏任务(下一阶段)
验证示例
Promise.resolve().then(() => console.log("promise1"));
process.nextTick(() => console.log("nextTick1"));
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
console.log("sync");
// 输出顺序(Node 环境):
// sync
// nextTick1
// promise1
// timeout (或 immediate,取决于启动时机)
// immediate (或 timeout)
注意:timeout 和 immediate 的顺序在非 I/O 循环中不固定;但 nextTick 和 promise 的先后是确定的。
特殊说明
process.nextTick的优先级高于标准微任务,这是 Node.js 的设计选择,目的是让开发者能够在当前操作完成后立即执行某些任务,而不必等到微任务队列清空。- 滥用
process.nextTick会导致事件循环无法进入 poll 阶段,造成 I/O 饥饿。官方建议优先使用setImmediate或Promise。
四、总结表格
| 类型 | 代表 API | 执行阶段/时机 | 优先级 |
|---|---|---|---|
| 宏任务(timers) | setTimeout、setInterval | timers 阶段(每轮最先) | 低(受延迟和队列影响) |
| 宏任务(poll) | 文件 I/O、网络 I/O 回调 | poll 阶段 | 中 |
| 宏任务(check) | setImmediate | check 阶段(poll 之后) | 中低 |
| 宏任务(close) | socket.on('close') | close callbacks 阶段(最后) | 最低 |
| 微任务 | Promise.then、queueMicrotask | 当前阶段结束后、nextTick 之后 | 高 |
| 特殊队列 | process.nextTick | 当前阶段结束后、微任务之前 | 最高 |
推荐参考资料
- Node.js 官方文档:The Node.js Event Loop, Timers, and
process.nextTick() - libuv 文档:Design overview of libuv
- 深入理解 Node.js 事件循环:《Node.js 设计模式》第 2 章
- 高性能 Node.js 最佳实践:官方博客文章 Don't Block the Event Loop
理解事件循环是编写高性能、可预测的 Node.js 应用的基础,尤其是在处理大量 I/O 或需要精细控制异步任务顺序时。
相关文章
全局对象与变量
Node.js 提供模块内可直接使用的全局对象;与浏览器 window 不同,模块顶层的 const/let 不会挂到 global。见 后端入门概览、模块系统。
模块系统
Node.js 的模块系统是其核心设计之一,它允许开发者将代码拆分为多个文件(模块),通过 require(CommonJS)或 import/export(ESM)进行组织和复用。下面详细介绍 CommonJS、ESM 以及两者的互操作。
事件驱动与非阻塞 I/O 模型
Node.js 的底层架构核心是事件驱动与非阻塞 I/O:以 V8 引擎执行 JavaScript、libuv 库处理底层 I/O 与事件循环。涵盖 libuv 线程池、事件循环各阶段、EventEmitter 实践、最佳实践与常见坑。
后端入门概览
本目录覆盖 JavaScript/TypeScript 服务端运行时与框架、数据存储,以及 Rust 系统编程入门,提供从零到部署的完整学习路径。前置建议:JavaScript 基础、工程化 · 环境变量。
文件系统
Node.js 的 fs 模块提供了与文件系统交互的 API,几乎涵盖了所有标准文件操作。它支持三种风格的 API:同步、回调式异步 和 Promise 式异步。下面详细介绍这些 API 的选择策略、流式读写、文件监视以及常用操作。
异步编程与模式
Node.js 的核心优势在于异步非阻塞 I/O,但这也带来了回调地狱、错误处理复杂等问题。下面从解救方案、工具函数、并发控制、异步迭代以及常见反模式等角度展开。
Series
base
2 / 4