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 阶段
执行回调 :setTimeout 和 setInterval 中到期的回调。
注意 :这里的"到期"是指设定的延迟时间已到。但回调的实际执行时间可能稍晚,因为事件循环可能正忙于其他阶段。 每个阶段都有一个 FIFO 回调队列,事件循环会清空该队列(或达到系统上限)后才进入下一阶段。
二、pending callbacks 阶段
执行回调 :某些系统操作的回调,如 TCP 错误(ECONNREFUSED)等。大多数 I/O 回调都在 poll 阶段处理,但部分底层回调在此阶段执行。
三、idle, prepare 阶段
内部使用:供 libuv 内部使用,开发者无法直接干预。
四、poll 阶段(核心)
主要职责:
- 检索新的 I/O 事件,执行 I/O 回调(如文件读取完成、网络请求响应)。
- 处理除了
setImmediate、setTimeout、setInterval、close之外的几乎所有异步回调(例如fs.readFile、http.request等)。
行为规则:
- 如果 poll 队列非空,则同步清空队列中的回调(按顺序执行)。
- 如果 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 会在当前所有微任务结束后才执行。
setImmediate 与 process.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 资源的浪费。

最后
- 《深入浅出Node.js》