Node.js 事件循环入门详解
你可能听说过 Node.js 是单线程的,并使用事件循环来处理数千个并发连接。但这到底意味着什么?如果你是 Node.js 新手,事件循环可能看起来像一个神秘的黑盒。本文用通俗的语言拆解它,帮助你编写更好、更快的 Node.js 代码。
事件循环为何存在
传统的 Web 服务器(如搭配 PHP 的 Apache)会为每个请求创建一个新线程或进程。这可行,但当你有大量并发连接时,它的扩展性并不好——每个线程都会消耗内存和 CPU。Node.js 采用了不同的方法:它在单个线程中运行你的 JavaScript,并使用事件循环来异步处理 I/O 操作。这意味着你的代码不会等待数据库查询或文件读取完成,而是继续执行其他任务,并在结果准备好时再回来处理。
核心组件
在深入了解事件循环之前,让我们先明确几个关键角色:
- 调用栈(Call Stack): 同步 JavaScript 代码运行的地方。函数在执行时被压入和弹出。
- Node API: 处理文件 I/O、网络请求和定时器等昂贵操作的 C++ API。它们在后台运行(使用 libuv 线程池)。
- 回调队列(Callback Queue): 已完成的 Node API 操作的回调在此等待执行。
- 事件循环(Event Loop): 协调器,持续检查调用栈是否为空,如果为空,则将回调从队列移动到栈中。
事件循环如何工作:一个简单示例
考虑以下代码:
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
console.log('End');
输出是什么?如果你猜是 Start、End、Timeout,那就对了。即使超时是 0 毫秒,回调也不会立即执行。以下是发生的过程:
console.log('Start')被压入调用栈,执行,然后弹出。- 调用
setTimeout。Node.js 注册定时器并设置其在 0 毫秒后过期。回调被存储,函数返回。 console.log('End')被压入、执行并弹出。- 现在调用栈为空。事件循环检查定时器阶段,发现定时器已过期。它将回调移动到调用栈,从而输出
Timeout。
这展示了非阻塞特性:定时器不会阻塞其余代码。
事件循环的各阶段
事件循环在几个阶段中处理回调,每个阶段都有特定的用途。理解这些阶段有助于你预测执行顺序。
| 阶段 | 描述 |
|---|---|
| Timers(定时器) | 执行由 setTimeout() 和 setInterval() 调度的回调。 |
| Pending Callbacks(待处理回调) | 执行推迟到下一次循环迭代的 I/O 回调。 |
| Idle, Prepare(空闲、准备) | 供 Node.js 内部使用。 |
| Poll(轮询) | 检索新的 I/O 事件;执行 I/O 相关的回调(除了定时器、setImmediate 和关闭回调之外几乎所有回调)。 |
| Check(检查) | 执行由 setImmediate() 调度的回调。 |
| Close Callbacks(关闭回调) | 执行关闭回调,例如 socket.on('close', ...)。 |
在每个阶段之间,Node.js 会检查微任务:process.nextTick() 回调和 Promise。它们具有更高的优先级,会在当前操作完成后立即执行,然后再进入下一个阶段。
微任务:nextTick 和 Promise
微任务不属于事件循环的阶段;它们在每个阶段之后以及每个回调之后被处理。这使得它们会在定时器和 I/O 回调之前运行。例如:
setTimeout(() => console.log('Timeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
console.log('Sync');
输出:Sync、nextTick、Promise、Timeout。同步代码首先运行,然后是微任务(nextTick 在 Promise 之前),最后是定时器。
警告: 递归调用 process.nextTick() 可能会饿死事件循环,阻止 I/O 发生。对于递归操作,请使用 setImmediate()。
setImmediate 与 setTimeout
setImmediate() 旨在在当前轮询阶段完成后立即执行回调。相比之下,0 毫秒的 setTimeout() 会等待下一个定时器阶段。它们之间的顺序可能因上下文而异,但在 I/O 回调内部,setImmediate() 总是先运行。
为什么这对你的代码很重要
理解事件循环有助于你避免常见的陷阱:
- 不要阻塞事件循环: 像
fs.readFileSync这样的同步操作或繁重的计算会阻塞整个循环,使你的应用无响应。请使用异步版本或卸载到工作线程。 - 明智地使用微任务: 它们在 I/O 之前运行,因此繁重的微任务处理会延迟 I/O 回调。
- 理解回调顺序: 当混合使用定时器、Promise 和 I/O 时,了解各阶段以预测输出。
实际示例:非阻塞文件读取
以下展示了事件循环如何实现非阻塞 I/O:
const fs = require('fs');
console.log('Before read');
fs.readFile('large-file.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log('File read complete');
});
console.log('After read');
输出:Before read、After read,然后是 File read complete。文件读取在后台进行,完成后回调被排队。与此同时,其他代码可以继续运行。
常见问题
Node.js 真的是单线程的吗?
Node.js 在单个线程中运行你的 JavaScript,但它使用 libuv 线程池中的多个线程来处理文件 I/O、DNS 和其他操作。因此,在底层它并不完全是单线程的。
调用栈和事件循环有什么区别?
调用栈执行同步代码。事件循环管理异步回调,在调用栈为空时将回调从队列移动到调用栈。
我可以创建多个事件循环吗?
不能,每个 Node.js 进程只有一个事件循环。但是,你可以使用工作线程来运行单独的 JavaScript 线程,每个线程都有自己的事件循环。
如果你正在处理服务器日志以调试性能问题,可以试试我们的 Nginx 日志分析器,快速解析和可视化日志数据。