事件循环与异步机制
🎯 引言
学完这篇文章,你能说清 Node.js 单线程为什么还能高效处理大量请求,理解事件循环的基本工作流程,并能正确预测 setTimeout、setImmediate、Promise 混合代码的输出顺序。
🧱 Node.js 是单线程的吗
执行 JavaScript 代码的主线程只有一条,这是事实。但 Node.js 靠“事件循环 + 非阻塞 I/O”这套机制,一条线程就能同时应付大量请求。
用餐厅打比方:一个服务员不会站在某桌旁边干等菜做好,而是点完单立刻去服务下一桌,等厨房喊“菜好了”再回来上菜。Node.js 也一样:读写文件、查数据库这些耗时操作交给系统去办,主线程继续处理别的请求,事情办完了再通过回调通知主线程来取结果。
🧱 为什么这样设计
服务器程序的大部分时间都花在等待 I/O 上:等文件读完、等数据库返回、等网络响应。如果每个请求都阻塞等待,线程就会大量闲置。
非阻塞的设计把等待时间利用了起来:一个请求在等数据库时,主线程可以去处理下一个请求。所以 Node.js 非常适合 I/O 密集型场景,比如接口服务、网关、实时聊天。
🧱 事件循环的简化模型
不用深入底层源码,初学者记住这个主流程就够用了:
- 同步代码:按书写顺序,先全部执行完。
- 微任务:
process.nextTick先执行,然后是Promise.then。 - 宏任务各阶段循环:timers 阶段执行到期的
setTimeout/setInterval,poll 阶段执行 I/O 回调,check 阶段执行setImmediate。每完成一个阶段,都会回头清空微任务队列。
console.log('1. 同步代码'); // 第 1 步:同步先跑
process.nextTick(() => console.log('2. nextTick')); // 第 2 步:微任务里它优先
Promise.resolve().then(() => console.log('3. Promise')); // 第 3 步:微任务
setTimeout(() => console.log('4. setTimeout'), 0); // 第 4 步:timers 阶段
setImmediate(() => console.log('5. setImmediate')); // 第 5 步:check 阶段
⚡ 执行顺序实战
运行上面的代码,输出是:
1. 同步代码
2. nextTick
3. Promise
4. setTimeout
5. setImmediate
逐个解释:
- 同步代码永远先执行,没有悬念。
process.nextTick的优先级高于Promise.then,它会在当前操作结束后、事件循环继续之前立刻执行。setTimeout(fn, 0)属于 timers 阶段,setImmediate属于 check 阶段,timers 排在 check 前面,所以 4 在 5 前面。
setTimeout(fn, 0) 和 setImmediate 的先后只在“主模块直接调用”时可能不稳定(受准备耗时影响)。把这段代码放进文件读取的回调里,setImmediate 就一定会先执行,因为 I/O 回调处于 poll 阶段,下一个阶段就是 check。面试遇到可以再深究,日常开发知道这个规律即可。💡 给初学者的实用建议
- 写异步代码优先用
async/await,它的可读性比嵌套回调和.then链好得多。 - 不要在主线程写超大的同步循环,它会卡住所有请求。
- 面试常考执行顺序题,掌握“同步 → nextTick → Promise → setTimeout → setImmediate”这条主线,就能应付常见题目。
🧾 小节总结
- Node.js 的 JS 主线程是单线程,靠事件循环和非阻塞 I/O 处理并发。
- 服务器大量时间在等 I/O,非阻塞设计把等待时间利用了起来,适合 I/O 密集型场景。
- 执行顺序主线:同步代码 →
process.nextTick→Promise.then→setTimeout→setImmediate。 - CPU 密集型计算会堵住事件循环,应避免在服务里执行。
❓ 知识问答
Q1:setTimeout(fn, 0) 是立刻执行吗?
不是。“0 毫秒”只是理论上尽快,它至少要等同步代码和微任务全部执行完,事件循环进入 timers 阶段才会执行。
Q2:setImmediate 和 setTimeout(fn, 0) 谁先执行?
主模块里直接调用时顺序不固定,受进程启动耗时影响;在 I/O 回调里调用时 setImmediate 一定先执行。日常开发不要依赖两者的相对顺序。
Q3:process.nextTick 和 Promise.then 谁优先?
process.nextTick 优先。事件循环每次继续之前,会先清空 nextTick 队列,再执行 Promise 微任务。
Q4:Node.js 既然是单线程,为什么还能同时处理很多请求?
因为耗时的 I/O 操作都交给了操作系统底层处理,主线程不阻塞等待,只负责调度和处理结果。请求量越大、I/O 占比越高,这个机制的优势越明显;具体能支撑多大的并发量,和你的代码质量、机器配置都有关系,需要实测验证。
🧪 小练习
不运行代码,先写出下面这段代码的输出顺序,再用 node 运行验证你的答案:
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => {
console.log('C');
process.nextTick(() => console.log('D'));
});
process.nextTick(() => console.log('E'));
console.log('F');
// 请先在这里写下你预测的顺序,再运行验证
🎉 恭喜你已经理解 Node.js 的事件循环机制!下一篇我们学习 process 对象,掌握命令行参数和环境变量的用法。
