Node.js 事件循环实战
在我的上一篇文章(《深入探索 Node.js 架构》)中,我们学习了 Node.js 的内部机制,以及如何在单线程环境下高效地执行多个异步操作。我们也讨论了事件循环的工作原理,以及它如何推动 Node.js 的事件驱动架构。我建议您在阅读本文之前先阅读我之前的文章。
在本文中,我们将通过一个代码示例来深入了解事件循环及其不同的阶段。
在开始之前,你可能会问,为什么Node.js开发者需要了解事件循环?答案是:
-
事件循环负责处理应用程序代码的所有调度,任何关于事件循环的误解都可能导致性能低下和代码错误。
-
如果你应聘的是Nodejs后端开发职位,那么这是一个非常重要的面试问题。
那么,我们开始吧 :)
正如我们之前讨论的,事件循环就是一个循环。它只是循环遍历同步事件多路复用器发送的事件集,触发回调并推动应用程序继续运行。
事件循环阶段
事件循环包含多个不同的阶段,每个阶段都维护一个待执行的回调队列。回调函数根据其在应用程序中的使用方式,被分配到不同的阶段。
轮询
- 轮询阶段执行与 I/O 相关的回调函数。
- 大部分应用程序代码在此阶段执行。
- Node.js应用程序的起点
查看
setImmediate()在此阶段,将执行通过触发的回调函数。
关闭
- 此阶段执行通过以下方式触发的回调
EventEmitter close events: - 例如,当 net.Server TCP 服务器关闭时,它会发出一个在此阶段运行的关闭事件。
定时器
- 在此阶段,通过
setTimeout()和触发的回调函数setInterval()将被执行。
待办的
- 在此阶段会运行特殊的系统事件,例如 net.Socket TCP soccer 抛出
ECONNREFUSED错误时。
除此之外,还有两个特殊的微任务队列,可以在阶段运行时向其中添加回调。
-
第一个微任务队列处理使用
process.nextTick(). -
第二个微任务队列处理
promises拒绝或解决操作。
执行优先级和顺序
-
微任务队列中的回调优先级高于阶段普通队列中的回调。
-
下一个 tick 微任务队列中的回调函数会在 promise 微任务队列中的回调函数之前运行。
-
应用程序启动运行时,事件循环也随之启动,并逐一处理各个阶段。Node.js 会在应用程序运行时根据需要将回调函数添加到不同的队列中。
-
当事件循环执行到某个阶段时,它会运行该阶段队列中的所有回调函数。一旦给定阶段的所有回调函数都执行完毕,事件循环就会进入下一个阶段。
我们来看一个代码示例:
输出结果为:8、3、2、1、4、7、6、5
让我们来看看幕后发生了什么:
-
代码执行从轮询阶段开始逐行执行。
-
首先,需要安装 fs 模块。
-
接下来,
setImmediate()运行该调用,并将其回调添加到check queue。 -
接下来,
promise解析,并向添加回调promise microtask queue。 -
然后,
process.nextTick()接下来运行,将其回调添加到next tick microtask queue。 -
接下来,它
fs.readFile()告诉 Node.js 开始读取文件,并poll queue在准备就绪后将其回调函数放在其中。 -
最后
console.log(8)调用函数,并在屏幕上打印 8。
当前技术栈就介绍到这里。
-
现在,系统会查询两个微任务队列。首先检查下一个 tick 的微任务队列,并调用回调函数 3。由于下一个 tick 的微任务队列中只有一个回调函数,因此接下来会检查 Promise 微任务队列,并执行回调函数 2。至此,两个微任务队列的查询完毕,当前轮询阶段结束。
-
现在,事件循环进入检查阶段。该阶段包含回调函数 1,该函数随后被执行。此时两个微任务队列均为空,因此检查阶段结束。
-
接下来检查关闭阶段,但该阶段为空,因此循环继续。计时器阶段和待处理阶段也出现同样的情况,事件循环再次回到轮询阶段。
一旦应用程序重新进入轮询阶段,它基本上不会执行其他操作,而是等待文件读取完毕。文件读取完毕后,fs.readFile()回调函数就会运行。
-
由于数字 4 是回调函数的第一行,因此它会立即被打印出来。
-
接下来,
setTimeout()发起调用,并将回调 5 添加到定时器队列中。 -
接下来会执行调用
setImmediate(),将回调 6 添加到检查队列中。 -
最后,调用 process.nextTick(),将回调 7 添加到下一个工单微任务队列中。
轮询阶段现已结束,接下来将再次查询微任务队列。
- 回调函数 7 从下一个 tick 队列运行,
-
检查承诺队列后发现为空,轮询阶段结束。
-
事件循环再次进入检查阶段,此时遇到回调函数 6。程序打印出回调函数编号,并确定微任务队列为空,该阶段结束。
-
再次检查关闭阶段,发现为空。
-
最后,查询计时器阶段,执行回调 5,并在控制台上打印 5。
-
完成上述步骤后,应用程序就没有其他工作要做了,就会退出。
众所周知,Node.js 运行时环境是单线程的。在单个栈中运行过多代码会导致事件循环阻塞,并阻止其他回调函数触发。为了避免这种事件循环阻塞的情况,您可以将 CPU 密集型操作拆分到多个栈中执行。例如,如果您要处理 1000 条数据记录,可以考虑将其拆分为 10 个批次,每个批次 100 条记录,并setImmediate()在每个批次结束后使用 `fork` 语句来继续处理下一个批次。另一种方法是创建一个新的子进程并将处理任务卸载到该子进程中。但切勿使用 `fork` 语句拆分此类工作process.nextTick()。这样做会导致微任务队列永远不会清空,您的应用程序将永远被困在同一个阶段。运行时不会抛出任何错误,而是会将其变成一个消耗 CPU 资源的僵尸进程。
以上就是关于事件循环的全部内容。
希望您喜欢这篇文章,并觉得它有趣且有用 :)
谢谢,回头见!
参考
- 使用 Node.js 构建分布式系统(书籍)

