查看: 243|回复: 3

Node.js事件循环六阶段与宏微任务执行顺序解析

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
Node.js 基于单线程 JavaScript 运行时,依靠事件循环处理文件读取、网络请求、数据库查询等非阻塞 I/O。理解六个阶段、宏任务与微任务的优先级,以及 EventEmitter 的注册和触发方式,是排查异步回调顺序问题的基础。

一、事件循环的六个阶段
事件循环按阶段轮转,每个阶段维护自己的回调队列:
1. Timers:执行 setTimeout() 和 setInterval() 到期的回调。
2. I/O Callbacks:处理部分延迟的 I/O 回调,例如某些系统错误回调。
3. Idle、prepare:Node.js 内部使用,业务代码通常不直接关心。
4. Poll:检索新的 I/O 事件,执行与 I/O 相关的回调,是核心阶段;没有新事件时会检查其他阶段。
5. Check:执行 setImmediate() 回调。
6. Close Callbacks:处理关闭类回调,例如 socket.on('close', ...)。

流程上,任务先进入事件循环队列,事件循环按阶段顺序处理;poll 阶段可能等待新事件到达。setImmediate() 的回调在 check 阶段执行,setTimeout() 的回调在 timers 阶段执行,因此二者顺序受当前事件循环状态影响。

二、setTimeout 与 setImmediate 的示例
  1. setTimeout(() => {
  2.     console.log('Timeout callback');
  3. }, 0);
  4. setImmediate(() => {
  5.     console.log('Immediate callback');
  6. });
  7. console.log('Main thread execution');
复制代码
输出时,Main thread execution 会先打印,因为主线程同步代码先执行。随后 Immediate callback 与 Timeout callback 的先后取决于事件循环状态;原文示例说明一般 setImmediate() 会先执行。排查这类顺序问题时,不要只凭字面上的 0 毫秒判断,应结合 timers 与 check 阶段的调度位置分析。

三、宏任务与微任务
宏任务包括 setTimeout、setInterval、setImmediate 和 I/O 操作等;微任务包括 process.nextTick 和 Promise.then。微任务优先级高于宏任务,会在当前阶段的回调结束后立即执行。
  1. setTimeout(() => {
  2.     console.log('Timeout callback');
  3. }, 0);
  4. Promise.resolve().then(() => {
  5.     console.log('Promise callback');
  6. });
  7. console.log('Main thread execution');
复制代码
输出顺序为:
Main thread execution
Promise callback
Timeout callback
同步代码先执行;当前操作结束后先清空微任务队列,Promise 回调先于定时器宏任务执行。

四、process.nextTick 的优先级
process.nextTick() 会在当前操作结束后、下一个阶段开始前执行,属于微任务,并且优先级高于 Promise。
  1. process.nextTick(() => {
  2.     console.log('Next tick callback');
  3. });
  4. console.log('Main thread execution');
复制代码
输出:
Main thread execution
Next tick callback

把四类任务放在一起比较时,可用下面的代码观察顺序:
  1. setTimeout(() => console.log('setTimeout'), 0);
  2. setImmediate(() => console.log('setImmediate'));
  3. Promise.resolve().then(() => console.log('Promise'));
  4. process.nextTick(() => console.log('nextTick'));
复制代码
输出顺序为 nextTick、Promise,然后 setTimeout 或 setImmediate 取决于事件循环状态。也就是说,nextTick 队列先于 Promise 微任务,宏任务中的 timers 与 check 先后要结合阶段判断。

五、EventEmitter 与事件驱动
Node.js 的事件驱动编程主要通过 events 模块内置的 EventEmitter 类实现。事件相当于观察者模式中的主题,注册到事件上的处理函数相当于观察者;通过继承 EventEmitter 可以创建自己的事件发射器,并注册、触发事件。
基本流程是:用 on() 注册事件和处理器,用 emit() 触发事件,事件循环调度相应回调执行。
  1. const EventEmitter = require('events');
  2. const myEmitter = new EventEmitter();
  3. myEmitter.on('greet', () => {
  4.     console.log('Hello, world!');
  5. });
  6. myEmitter.emit('greet');
复制代码
输出 Hello, world!。

再看一个多事件串联的例子:
  1. var events = require('events');
  2. var eventEmitter = new events.EventEmitter();
  3. var connectHandler = function connected() {
  4.     console.log('连接成功。');
  5.     eventEmitter.emit('data_received');
  6. };
  7. eventEmitter.on('connection', connectHandler);
  8. eventEmitter.on('data_received', function() {
  9.     console.log('数据接收成功。');
  10. });
  11. eventEmitter.emit('connection');
  12. console.log('程序执行完毕。');
复制代码
执行 node main.js 后输出:
连接成功。
数据接收成功。
程序执行完毕。
这里 connection 事件触发 connectHandler,connectHandler 内部再触发 data_received,最终同步代码继续打印。

六、异步文件读取与错误优先回调
Node.js 异步函数通常把回调函数作为最后一个参数,回调的第一个参数是错误对象。下面的代码用 fs.readFile() 读取 input.txt:
  1. var fs = require('fs');
  2. fs.readFile('input.txt', function(err, data) {
  3.     if (err) {
  4.         console.log(err.stack);
  5.         return;
  6.     }
  7.     console.log(data.toString());
  8. });
  9. console.log('程序执行完毕');
复制代码
文件存在时,先打印程序执行完毕,再输出文件内容;文件不存在时,异步回调收到错误对象,输出类似 Error: ENOENT, open 'input.txt' 的错误信息。这个顺序说明 fs.readFile() 不会阻塞后面的同步代码,错误处理必须放在回调内部完成。

七、实践中的关注点
事件循环负责非阻塞 I/O 的调度,六个阶段决定回调进入哪个队列;process.nextTick 与 Promise.then 属于微任务,其中 process.nextTick 优先级更高;setTimeout、setImmediate 和 I/O 回调属于宏任务,前后顺序要看 timers、poll、check 阶段。EventEmitter 的 on() 与 emit() 把事件注册和触发解耦,适合处理连接、数据到达、关闭等异步事件。异步 API 的错误优先回调写法,则要求在回调中先判断 err,再处理 data。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Node.js事件循环六阶段与宏微任务执行顺序解析

写得很系统,六个阶段的梳理和代码示例都很清楚,尤其把 nextTick、Promise、timers、check 放在一段里对比,排查顺序问题时很实用。 有两点想补充讨论一下: 一是顶层代码里 setTimeout(fn, 0) 和 setImmediate 的先后其实是不确定的,取决于进程启动耗时和定时器精度,多跑几次可能两种顺序都出现。文中说“一般 setImmediate 会先执行”稍微有点绝对,容易让初学者以为这是稳定规律。更稳的结论是:只有在 I/O 回调(poll 阶段)里同时注册这两者时,setImmediate 才一定先执行。建议把这句话和后面的“取决于事件循环状态”对齐一下,或者直接给一个在 fs.readFile 回调里对比的例子,会更有说服力。 二是 process.nextTick 的定位。它虽然优先级高于 Promise.then,但严格说它不在微任务队列里,而是 Node 单独维护的 nextTick 队列,会在每个阶段结束、进入下一阶段之前清空,并且清空过程中新加入的 nextTick 还会继续执行,递归调用过多会卡住事件循环。文中写成“属于微任务,优先级高于 Promise”,对理解执行顺序没影响,但如果有人追问“为什么 nextTick 比 Promise 还早”,就会卡在这里,提一句两个队列的区别会更完整。 另外帖子最后“回调的第一个参”后面似乎
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Node.js事件循环六阶段与宏微任务执行顺序解析

感谢楼主的整理,六阶段加上宏微任务的组合讲得挺系统,尤其把 nextTick 和 Promise 分开说,这点对排查顺序问题很关键。 分享一点自己踩坑的体会:关于 setTimeout 和 setImmediate 谁先,放在主模块里跑确实看运气,机器状态、进程启动开销都会影响;但如果把这两个都写进一个 I/O 回调里(比如 fs.readFile 的回调),setImmediate 基本就是稳定先执行,因为 poll 阶段结束后紧接着就是 check 阶段,而 timers 要等到下一次循环才轮询。所以实际调顺序问题时,先确认代码处在哪个阶段,比盯着那个 0 毫秒有意义得多。 另外提醒一下 nextTick 递归调用的风险,它虽然是微任务里优先级最高的,但如果在里面对自己反复排队,事件循环会一直卡在清空 nextTick 队列上,I/O 和定时器都推进不了,日志上看起来像"挂死"。调试时可以留意一下是不是这种写法。 第六节异步文件读取那里好像被截断了,"回调的第一个参"后面没了,如果方便的话可以把错误优先回调那部分补完,fs.readFile 的 err 和 data 判断习惯对新手挺实用的。 第五节那个 connection 串 data_received 的例子举得好,正好说明 emit 本身是同步调用处理器的,所以"程序执行完毕"一定在最后。这点和事件循环的异步调度要区分开
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Node.js事件循环六阶段与宏微任务执行顺序解析

这篇梳理挺系统的,六个阶段和宏微任务优先级讲得比较清楚,尤其是把 nextTick 和 Promise 分开比较,以及 EventEmitter 的 emit 同步触发例子,对排查异步顺序问题很有帮助。有个小地方可以补充:在主模块里 setTimeout(0) 和 setImmediate 的先后确实不稳定,放到 I/O 回调里观察会更典型,通常 setImmediate 会先执行;另外 process.nextTick 严格说和 Promise 微任务不是同一个队列,但会在 Promise 之前清空。EventEmitter 的 emit 是同步调用监听器,监听器里再 emit 也会同步嵌套执行,所以文中的输出顺序能解释得通。错误优先回调部分好像截断了,期待后续补全。整体很适合作为事件循环排查的入门提纲。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-6 07:31 , Processed in 0.022534 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部