Node.js 事件循环

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行时环境,它让 JavaScript 能够在服务器端运行,并提供了一套高效的异步 I/O 模型。

JavaScript 最初是为浏览器中操作 DOM 而创建的,为了避免多线程并发修改 DOM 导致的复杂锁竞争和状态不一致,它被设计为单线程。Node.js 沿用了这一特性,使得开发者无需面对多线程编程中的竞态条件、死锁等难题。

单线程意味着代码执行是顺序的,开发者更容易推理程序的行为。在 Node.js 中,所有用户代码都在同一个主线程上运行,通过事件循环异步非阻塞 I/O 来处理高并发。

虽然主线程是单线程,但 Node.js 通过 libuv 库实现了事件循环。当遇到 I/O 操作(如读写文件、网络请求)时,Node.js 会将其交给 libuv 的线程池或操作系统内核异步处理,主线程继续执行后续代码。I/O 完成后,线程池将回调放入事件循环队列,主线程在合适时机执行回调。这样,一个单线程就能处理成千上万的并发连接,而不会阻塞。

Node.js 事件循环

Node.js 事件循环共包含 6 个主要阶段(按顺序循环)

js 复制代码
   ┌───────────────────────────┐
┌─>│           timers          │  setTimeout, setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  系统操作(如 TCP 错误)回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  libuv 内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  I/O 回调(文件、网络等)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  setImmediate
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  socket.on('close', ...)
│  └───────────────────────────┘

一、timers 阶段

执行回调setTimeoutsetInterval到期的回调。

注意 :这里的"到期"是指设定的延迟时间已到。但回调的实际执行时间可能稍晚,因为事件循环可能正忙于其他阶段。 每个阶段都有一个 FIFO 回调队列,事件循环会清空该队列(或达到系统上限)后才进入下一阶段。

二、pending callbacks 阶段

执行回调 :某些系统操作的回调,如 TCP 错误(ECONNREFUSED)等。大多数 I/O 回调都在 poll 阶段处理,但部分底层回调在此阶段执行。

三、idle, prepare 阶段

内部使用:供 libuv 内部使用,开发者无法直接干预。

四、poll 阶段(核心)

主要职责

  1. 检索新的 I/O 事件,执行 I/O 回调(如文件读取完成、网络请求响应)。
  2. 处理除了 setImmediatesetTimeoutsetIntervalclose 之外的几乎所有异步回调(例如 fs.readFilehttp.request 等)。

行为规则

  1. 如果 poll 队列非空,则同步清空队列中的回调(按顺序执行)。
  2. 如果 poll 队列为空 ,则事件循环会阻塞等待 新的 I/O 事件,直到:
    • 有 I/O 事件到达,执行其回调;
    • 或者 timers 阶段有定时器到期;
    • 或者 check 阶段有 setImmediate 等待处理。

注意 :在等待过程中,如果有 setImmediate 回调,则不会等待 I/O 事件,而是立即进入 check 阶段。

五、check 阶段

执行回调setImmediate 的回调。

为什么单独阶段?为了在 poll 阶段空闲时能尽快执行某些高优先级的任务,而不必等待下一次循环。

六、close callbacks 阶段

执行回调 :关闭事件,如 socket.on('close', ...)process.on('exit', ...) 等。

为什么 setTimeout(0) 不能立即执行?

setTimeout 的回调不是 在注册后立即执行的。即使延迟为 0,它仍然需要等待事件循环到达 timers 阶段 时才会被检查。而 setImmediate 则会在当前循环的 check 阶段被执行。

由于 check 阶段在当前循环中必然会出现(除非进程退出),而 timers 阶段是下一轮循环的开始,所以 setImmediate 总是比 setTimeout(0) 先执行。

举例

setTimeout vs setImmediate

1、setTimeout vs setImmediate(在 I/O 回调外)

js 复制代码
setTimeout(() => console.log('setTimeout'), 0);

setImmediate(() => console.log('setImmediate'));

setTimeout 与 setImmediate 执行顺序不确定。setTimeout 的回调在 timers 阶段 执行,setImmediate 的回调在 check 阶段执行。

  • 当事件循环第一次进入 timers 阶段 时,可能定时器尚未被加入队列(因为代码执行需要时间),导致 timers 队列为空,随后进入 poll 阶段 ,发现没有 I/O 事件,于是立即进入 check 阶段 执行 setImmediate,此时 setImmediate 先输出。
  • 相反,如果定时器在 timers 阶段检查时已经到期,则 setTimeout 会先输出。

2、setTimeout vs setImmediate 嵌套

js 复制代码
setImmediate(() => {
  console.log('immediate1');
  setTimeout(() => console.log('timeout inside immediate'), 0);
});

setTimeout(() => {
  console.log('timeout1');
  setImmediate(() => console.log('immediate inside timeout'));
}, 0);
// 输出顺序不定,但通常 immediate1 先于 timeout1,然后内部的回调按阶段顺序执行

外层顺序不确定,但内部回调会在各自对应的阶段执行:setTimeout 内部调用 setImmediate 会在 check 阶段执行;setImmediate 内部调用 setTimeout 会在下一轮 timers 阶段执行。

3、setTimeout vs setImmediate (在I/O回调内的)

js 复制代码
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('setTimeout'), 0);
  
  setImmediate(() => console.log('setImmediate'));
});
// 输出:setImmediate → setTimeout

在 I/O 回调(poll 阶段)中,setImmediate 会在 check 阶段执行,setTimeout 会在下一轮 timers 阶段执行,因此 setImmediate 总是先于 setTimeout

4、setTimeout vs setImmediate vs process.nextTick(在I/O回调内的)

js 复制代码
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  process.nextTick(() => console.log('nextTick'));
});
// 输出:nextTick → immediate → timeout
  • I/O 回调在 poll 阶段执行。回调内先注册 setTimeout、setImmediate 和 process.nextTick。
  • 当前 poll 阶段结束后,立即执行微任务 process.nextTick,输出 nextTick。
  • 然后事件循环进入 check 阶段,执行 setImmediate 回调,输出 immediate。
  • 最后,setTimeout 的回调要等到下一轮事件循环的 timers 阶段才会执行,因此最后输出 timeout。

5、在 process.nextTick 内的 setTimeout vs setImmediate

js 复制代码
process.nextTick(() => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// 输出:immediate → timeout

process.nextTick 的回调执行完后,事件循环会继续,此时 poll 队列可能为空,timers 阶段可能没有到期定时器,然后进入 check 阶段执行 setImmediate,再到下一轮的 timers 阶段执行 setTimeout,所以 immediate 先于 timeout

setTimeout vs fs.readFile

js 复制代码
const fs = require('fs');

setTimeout(() => console.log('timeout'), 0);

fs.readFile(__filename, () => {
  console.log('readfile');
});
// 输出:可能为 timeout → readfile,也可能 readfile → timeout

setTimeout 的回调在 timers 阶段执行,fs.readFile 的回调在 poll 阶段执行。谁先执行取决于文件读取完成的时间与定时器到期的先后顺序。

process.nextTick vs Promise.resolve

js 复制代码
Promise.resolve().then(() => console.log('Promise'));

process.nextTick(() => console.log('nextTick'));
// 输出:nextTick → Promise 

process.nextTick 在当前阶段结束后立即执行,优先级高于 Promise 微任务。

process.nextTick 嵌套

js 复制代码
process.nextTick(() => {
  console.log('nextTick 1');
  process.nextTick(() => console.log('nextTick 2'));
});

console.log('同步代码');
// 输出:同步代码 → nextTick 1 → nextTick 2

process.nextTick 在当前阶段结束后执行,如果其中又调用了 process.nextTick,它会在当前微任务队列末尾追加,但仍在当前阶段结束前执行。

js 复制代码
process.nextTick(() => console.log('1'));

process.nextTick(() => {
  console.log('2');
  process.nextTick(() => console.log('3'));
});

process.nextTick(() => console.log('4'));
// 输出:1 2 4 3

第一次 nextTick 队列执行时,会依次执行 1、2、4。在执行 2 时,又添加了一个 nextTick(3),这个新添加的 nextTick 会在当前微任务队列清空后、下一阶段开始前执行,因此 3 在 4 之后输出。

微任务阻塞事件循环

js 复制代码
function loop() {
  process.nextTick(loop);
}

loop();

setTimeout(() => console.log('timeout'), 0);

// 程序永远不会输出 timeout,因为 nextTick 递归导致微任务队列永远不为空

process.nextTick 与 Promise 嵌套

js 复制代码
process.nextTick(() => {
  console.log('nextTick1');
  Promise.resolve().then(() => console.log('promise inside nextTick'));
});

Promise.resolve().then(() => {
  console.log('promise1');
  process.nextTick(() => console.log('nextTick inside promise'));
});
// 输出:nextTick1 → promise1 → promise inside nextTick → nextTick inside promise

当前阶段结束,先执行所有 nextTick,再执行所有 Promise 微任务。但 nextTick 内注册的 Promise 会在当前微任务队列之后执行(即 nextTick 队列清空后,Promise 队列清空前),而 Promise 内注册的 nextTick 会在当前所有微任务结束后才执行。

setImmediateprocess.nextTick (在I/O回调内)

js 复制代码
process.nextTick(() => {
  console.log('nextTick1');
  Promise.resolve().then(() => console.log('promise inside nextTick'));
});

Promise.resolve().then(() => {
  console.log('promise1');
  process.nextTick(() => console.log('nextTick inside promise'));
});
// 输出:nextTick1 → promise1 → promise inside nextTick → nextTick inside promise

I/O 回调执行完后,会先执行所有微任务(nextTick 在此),然后再进入 check 阶段执行 setImmediate

错误优先的回调函数

WHAT? 错误优先的回调函数(Error-First Callback)是 Node.js 中一种约定俗成的异步回调模式,其核心特点是:回调函数的第一个参数用于传递错误对象,后续参数才是成功的结果数据。

WHY? Node.js 的异步操作通常不返回结果,而是通过回调函数在操作完成后通知调用方。为了统一错误处理,防止遗漏或混淆,社区逐渐形成了"错误优先"的规范。这种模式使得开发者可以在一开始就检查错误,代码结构清晰,避免 try/catch 无法捕获异步错误的困境。

异步 I/O

同步I/O(Synchronous I/O)遵循"顺序执行、阻塞等待"的逻辑:当程序发起一个I/O操作(如文件读写、网络请求、数据库交互)时,主线程会暂停后续代码的执行,一直等待I/O操作完成并返回结果后,才继续执行下一行代码。这种模式在面对大量I/O请求时,会导致主线程频繁阻塞,系统资源利用率极低------比如读取一个大文件可能需要几百毫秒,这段时间内整个程序都会"卡住",无法响应其他请求。

而 Node 异步I/O(Asynchronous I/O)则打破了这种阻塞困境:当主线程发起一个I/O请求后,不会等待其完成,而是立即继续执行后续代码;当I/O操作在后台完成后,会通过事件通知机制,将结果反馈给主线程,由主线程处理后续的回调逻辑。简单来说,异步I/O的核心是"非阻塞",让主线程从"等待I/O"中解放出来,专注于处理更核心的逻辑,从而提升系统的并发处理能力。 异步I/O:内核全权处理,完成后主动通知。

整个异步 I/O流程图

阻塞I/O VS 非阻塞I/O?

操作系统内核对于 I/O 只有两种方式:阻塞与非阻塞。

1、阻塞 I/O:调用之后一定等到系统内核层面完成所有操作后,调用才结束。

  • 缺点(CPU 等待浪费):造成 CPU 等待 IO,浪费等待时间,且 CPU 的处理能力得不到充分利用;

2、非阻塞 I/O:不带数据直接返回,要获取数据,还需要通过文件描述符再次读取。

  • 缺点(CPU 资源浪费):
    • 1、由于完整的 I/O 并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。
    • 2、然后需要轮询去确认是否完成数据获取,它会让 CPU 处理判断状态,是对 CPU 资源的浪费。

最后

  1. 《深入浅出Node.js》
相关推荐
米丘2 小时前
从 HTTP 到 WebSocket:深入 Vite HMR 的网络层原理
http·node.js·vite
Forever7_2 小时前
紧急!Axios 被投毒,3亿项目受到影响!教你怎么自查!
前端·axios
zzialx1232 小时前
HarmonyOS:照片添加多样式的水印信息
前端
Kel2 小时前
深入 Ink 源码:当 React 遇见终端 —— Custom Reconciler 全链路剖析
react.js·架构·node.js
前端冒菜师2 小时前
记一次AI全栈开发的过程
前端·ai编程
Giant1002 小时前
深度玩转 Cursor Rules:让 AI 生成的代码 100% 符合团队规范
前端·面试
代码煮茶2 小时前
Vue3 组件通信实战 | 8 种组件通信方式全解析
前端·vue.js
kyriewen2 小时前
自定义事件:让代码之间也能“悄悄对话”
前端·javascript·面试
子兮曰2 小时前
别把它当成一次普通“源码泄露”:Claude Code 事件给 AI Agent 团队提了什么醒
前端·npm·claude