基于 Node.js 10+ 版本,为你讲解事件循环的原理,不过要注意这个事件循环原理浏览器的原理是不同的,Node.js 10+ 版本后虽然在运行结果上与浏览器一致,但是两者在原理上一个是基于浏览器,一个是基于 libev 库。浏览器核心的是宏任务和微任务,而在 Node.js 还有阶段性任务执行阶段。

node.js启动过程可以分为以下步骤:

  1. 调用platformInit方法 ,初始化 nodejs 的运行环境。
  2. 调用 performance_node_start 方法,对 nodejs 进行性能统计。
  3. openssl设置的判断。
  4. 调用v8_platform.Initialize,初始化 libuv 线程池。
  5. 调用 V8::Initialize,初始化 V8 环境。
  6. 创建一个nodejs运行实例。
  7. 启动上一步创建好的实例。
  8. 开始执行js文件,同步代码执行完毕后,进入事件循环。
  9. 在没有任何可监听的事件时,销毁 nodejs 实例,程序执行完毕。

以上就是 nodejs 执行一个js文件的全过程。接下来着重介绍第八个步骤,事件循环。

Node.js 事件循环

事件循环通俗来说就是一个无限的 while 循环。现在假设你对这个 while 循环什么都不了解,你一定会有以下疑问。

  1. 谁来启动这个循环过程,循环条件是什么?
  2. 循环的是什么任务呢?
  3. 循环的任务是否存在优先级概念?
  4. 什么进程或者线程来执行这个循环?
  5. 无限循环有没有终点?

带着这些问题,我们先来看看 Node.js 官网提供的事件循环原理图。

Node.js 循环原理

下面为 Node.js 官网的事件循环原理的核心流程图。

  1. ┌───────────────────────┐
  2. ┌─>│ timers
  3. └──────────┬────────────┘
  4. ┌──────────┴────────────┐
  5. I/O callbacks
  6. └──────────┬────────────┘
  7. ┌──────────┴────────────┐
  8. idle, prepare
  9. └──────────┬────────────┘ ┌───────────────┐
  10. ┌──────────┴────────────┐ incoming:
  11. poll │<─────┤ connections,
  12. └──────────┬────────────┘ data, etc.
  13. ┌──────────┴────────────┐ └───────────────┘
  14. check
  15. └──────────┬────────────┘
  16. ┌──────────┴────────────┐
  17. └──┤ close callbacks
  18. └───────────────────────┘

未命名.jpg
从上面可以看到,这一流程包含 6 个阶段,每个阶段代表的含义如下所示:

(1)timers:本阶段执行已经被 setTimeout() 和 setInterval() 调度的回调函数,简单理解就是由这两个函数启动的回调函数。一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。
注意:

  • 技术上来说,poll 阶段控制 timers 什么时候执行
  • 这个下限时间有个范围:[1, 2147483647],如果设定的时间不在这个范围,将被设置为1。单位为毫秒

(2)I/O pending callbacks:本阶段执行某些系统操作(如 TCP 错误类型)的回调函数。

如一个TCP socket在想要连接时收到ECONNREFUSED, 类unix系统会等待以报告错误,这就会放到 I/O callbacks 阶段的队列执行. 名字会让人误解为执行I/O回调处理程序, 实际上I/O回调会由poll阶段处理.

(3)idle、prepare:仅系统内部使用,你只需要知道有这 2 个阶段就可以。

(4)poll:检索新的 I/O 事件,执行与 I/O 相关的回调,其他情况 Node.js 将在适当的时候在此阻塞。这也是最复杂的一个阶段,所有的事件循环以及回调处理都在这个阶段执行,

poll 阶段有两个主要功能

  • (1)执行下限时间已经达到的timers的回调
  • (2)然后处理 poll 队列里的事件。 当event loop进入 poll 阶段,并且 没有设定的 timers(there are no timers scheduled),会发生下面两件事之一:

(5)check:setImmediate() 回调函数在这里执行,setImmediate 并不是立马执行,而是当事件循环 poll 中没有新的事件处理时就执行该部分,如下代码所示:

  1. const fs = require('fs');
  2. setTimeout(() => {
  3. console.log('1');
  4. }, 0);
  5. setImmediate( () => {
  6. console.log('setImmediate 1');
  7. });
  8. fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
  9. if (err) throw err;
  10. console.log('read file success');
  11. });
  12. Promise.resolve().then(()=>{
  13. console.log('poll callback');
  14. });
  15. console.log('2');

在这一代码中有一个非常奇特的地方,就是 setImmediate 会在 setTimeout 之后输出。有以下几点原因:

  • setTimeout 如果不设置时间或者设置时间为 0,则会默认为 1ms;
  • 主流程执行完成后,超过 1ms 时,会将 setTimeout 回调函数逻辑插入到待执行回调函数 poll 队列中;
  • 由于当前 poll 队列中存在可执行回调函数,因此需要先执行完,待完全执行完成后,才会执行check:setImmediate。

因此这也验证了这句话,先执行回调函数,再执行 setImmediate

(6)close callbacks:执行一些关闭的回调函数,如 socket.on(‘close’, …)。

以上就是循环原理的 6 个过程,针对上面的点,我们再来解答上面提出的 5 个疑问。

运行起点

从图 1 中我们可以看出事件循环的起点是 timers,如下代码所示:

  1. setTimeout(() => {
  2. console.log('1');
  3. }, 0);
  4. console.log('2')

在代码 setTimeout 中的回调函数就是新一轮事件循环的起点,看到这里有很多同学会提出非常合理的疑问:“为什么会先输出 2 然后输出 1,不是说 timer 的回调函数是运行起点吗?”

这里有一个非常关键点,当 Node.js 启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的 API、调度定时器,或者 process.nextTick(),然后再开始处理事件循环。因此可以这样理解,Node.js 进程启动后,就发起了一个新的事件循环,也就是事件循环的起点。

总结来说,Node.js 事件循环的发起点有 4 个:

  • Node.js 启动后;
  • setTimeout 回调函数;
  • setInterval 回调函数;
  • 也可能是一次 I/O 后的回调函数。

以上就解释了我们上面提到的第 1 个问题。

Node.js 事件循环

在了解谁发起的事件循环后,我们再来回答第 2 个问题,即循环的是什么任务。在上面的核心流程中真正需要关注循环执行的就是 poll 这个过程。在 poll 过程中,主要处理的是异步 I/O 的回调函数,以及其他几乎所有的回调函数,异步 I/O 又分为网络 I/O 和文件 I/O。这是我们常见的代码逻辑部分的异步回调逻辑。

事件循环的主要包含微任务和宏任务。具体是怎么进行循环的呢?如图 2 所示。

Node事件循环原理 - 图2

图 2 事件循环过程

在解释上图之前,我们先来解释下两个概念,微任务和宏任务。

微任务:在 Node.js 中微任务包含 2 种——process.nextTick 和 Promise。微任务在事件循环中优先级是最高的,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且 process.nextTick 和 Promise 也存在优先级,process.nextTick 高于 Promise。

宏任务:在 Node.js 中宏任务包含 4 种——setTimeout、setInterval、setImmediate 和 I/O。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列。这也解释了我们前面提到的第 3 个问题,事件循环中的事件类型是存在优先级。

在图 2 的左侧,我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。

  • 同步代码。
  • 将异步任务插入到微任务队列或者宏任务队列中。
  • 执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。

如果微任务和宏任务都只有一层时,那么看起来是比较简单的,比如下面的例子:

  1. const fs = require('fs');
  2. console.log('start');
  3. fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
  4. if (err) throw err;
  5. console.log('read file success');
  6. });
  7. setTimeout(() => {
  8. console.log('setTimeout');
  9. }, 0);
  10. Promise.resolve().then(()=>{
  11. console.log('Promise callback');
  12. });
  13. process.nextTick(() => {
  14. console.log('nextTick callback');
  15. });
  16. console.log('end');

根据上面介绍的执行过程,我们来分析下上面代码的执行过程:

  1. 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;
  2. 再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:Promise.resolve 和 process.nextTick,宏任务队列包含:fs.readFile 和 setTimeout;
  3. 先执行微任务队列,但是根据优先级,先执行 process.nextTick 再执行 Promise.resolve,所以先输出 nextTick callback 再输出 Promise callback;
  4. 再执行宏任务队列,根据优先级先执行 fs.readFile 再执行 setTimeout,但是这里需要注意,即使先执行 fs.readFile,但是它执行需要时间肯定大于 1ms,所以虽然 fs.readFile 先于 setTimeout 执行,但是 setTimeout 执行更快,所以先输出 setTimeout ,最后输出 read file success。

根据上面的分析,我们可以得到如下的执行结果:

  1. start
  2. end
  3. nextTick callback
  4. Promise callback
  5. setTimeout
  6. read file success

但是当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:

  1. const fs = require('fs');
  2. setTimeout(() => {
  3. console.log('1');
  4. fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
  5. if (err) throw err;
  6. console.log('read file sync success');
  7. });
  8. }, 0);
  9. fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
  10. if (err) throw err;
  11. console.log('read file success');
  12. });
  13. Promise.resolve().then(()=>{
  14. console.log('poll callback');
  15. });
  16. console.log('2');

在上面代码中,有 2 个宏任务和 1 个微任务,宏任务是 setTimeout 和 fs.readFile,微任务是 Promise.resolve。

  1. 整个过程优先执行主线程的第一个事件循环过程,所以先执行同步逻辑,先输出 2。
  2. 接下来执行微任务,输出 poll callback。
  3. 再执行宏任务中的 fs.readFile 和 setTimeout,由于 fs.readFile 优先级高,先执行 fs.readFile。但是处理时间长于 1ms,因此会先执行 setTimeout 的回调函数,输出 1。这个阶段在执行过程中又会产生新的宏任务 fs.readFile,因此又将该 fs.readFile 插入宏任务队列。
  4. 最后由于只剩下宏任务了 fs.readFile,因此执行该宏任务,并等待处理完成后的回调,输出 read file sync success。

根据上面的分析,我们可以得出最后的执行结果,如下所示:

  1. 2
  2. poll callback
  3. 1
  4. read file success
  5. read file sync success

在上面的例子中,我们来思考一个问题,主线程是否会被阻塞,具体我们来看一个代码例子:

  1. const fs = require('fs');
  2. setTimeout(() => {
  3. console.log('1');
  4. sleep(10000)
  5. console.log('sleep 10s');
  6. }, 0);
  7. fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
  8. if (err) throw err;
  9. console.log('read file success');
  10. });
  11. console.log('2');
  12. function sleep ( n ) {
  13. var start = new Date().getTime() ;
  14. while ( true ) {
  15. if ( new Date().getTime() - start > n ) {
  16. break;
  17. }
  18. }
  19. }

我们在 setTimeout 中增加了一个阻塞逻辑,这个阻塞逻辑的现象是,只有等待当次事件循环结束后,才会执行 fs.readFile 回调函数。这里会发现 fs.readFile 其实已经处理完了,并且通知回调到了主线程,但是由于主线程在处理回调时被阻塞了,导致无法处理 fs.readFile 的回调。因此可以得出一个结论,主线程会因为回调函数的执行而被阻塞,这也符合图 2 中的执行流程图。

如果把上面代码中 setTimeout 的时间修改为 10 ms,你将会优先看到 fs.readFile 的回调函数,因为 fs.readFile 执行完成了,并且还未启动下一个事件循环,修改的代码如下:

  1. setTimeout(() => {
  2. console.log('1');
  3. sleep(10000)
  4. console.log('sleep 10s');
  5. }, 10);

最后我们再来回答第 5 个问题,当所有的微任务和宏任务都清空的时候,虽然当前没有任务可执行了,但是也并不能代表循环结束了。因为可能存在当前还未回调的异步 I/O,所以这个循环是没有终点的,只要进程在,并且有新的任务存在,就会去执行。

实践分析

了解了整个原理流程,我们再来实践验证下 Node.js 的事件驱动,以及 I/O 到底有什么效果和为什么能提高并发处理能力。我们的实验分别从同步和异步的代码性能分析对比,从而得出两者的差异。

Node.js 不善于处理 CPU 密集型的业务,就会导致性能问题,如果要实现一个耗时 CPU 的计算逻辑,处理方法有 2 种:

  • 直接在主业务流程中处理;
  • 通过网络异步 I/O 给其他进程处理。

接下来,我们用 2 种方法分别计算从 0 到 1000000000 之间的和,然后对比下各自的效果。

主流程执行

为了效果,我们把两部分计算分开,这样能更好地形成对比,没有异步驱动计算的话,只能同步的去执行两个函数 startCount 和 nextCount,然后将两部分计算结果相加。

  1. const http = require('http');
  2. *
  3. * 创建 http 服务,简单返回
  4. */
  5. const server = http.createServer((req, res) => {
  6. res.write(`${startCount() + nextCount()}`);
  7. res.end();
  8. });
  9. * 0 计算到 500000000 的和
  10. */
  11. function startCount() {
  12. let sum = 0;
  13. for(let i=0; i<500000000; i++){
  14. sum = sum + i;
  15. }
  16. return sum;
  17. }
  18. * 500000000 计算到 1000000000 之间的和
  19. */
  20. function nextCount() {
  21. let sum = 0;
  22. for(let i=500000000; i<1000000000; i++){
  23. sum = sum + i;
  24. }
  25. return sum;
  26. }
  27. *
  28. * 启动服务
  29. */
  30. server.listen(4000, () => {
  31. console.log('server start http://127.0.0.1:4000');
  32. });

接下来使用下面命令启动该服务:

启动成功后,再在另外一个命令行窗口执行如下命令,查看响应时间,运行命令如下:

运行完成以后可以看到如下的结果:

  1. 499999999075959400
  2. real 0m1.100s
  3. user 0m0.004s
  4. sys 0m0.005s

启动第一行是计算结果,第二行是执行时长。经过多次运行,其结果基本相近,都在 1.1s 左右。接下来我们利用 Node.js 异步事件循环的方式来优化这部分计算方式。

异步网络 I/O

异步网络 I/O 对比主流程执行,优化的思想是将上面的两个计算函数 startCount 和 nextCount 分别交给其他两个进程来处理,然后主进程应用异步网络 I/O 的方式来调用执行。

我们先看下主流程逻辑,如下代码所示:

const http = require('http');
const rp = require('request-promise');
 * 
 * 创建 http 服务,简单返回
 */
const server = http.createServer((req, res) => {
Promise.all([startCount(), nextCount()]).then((values) => {
let sum = values.reduce(function(prev, curr, idx, arr){
return parseInt(prev) + parseInt(curr);
        })
        res.write(`${sum}`);
        res.end(); 
    })
});
 * 从 0 计算到 500000000 的和
 */
async function startCount() {
return await rp.get('http://127.0.0.1:5000');
}
 * 从 500000000 计算到 1000000000 之间的和
 */
async function nextCount() {
return await rp.get('http://127.0.0.1:6000');
}
 * 
 * 启动服务
 */
server.listen(4000, () => {
console.log('server start http://127.0.0.1:4000');
});

代码中使用到了 Promise.all 来异步执行两个函数 startCount 和 nextCount,待 2 个异步执行结果返回后再计算求和。其中两个函数 startCount 和 nextCount 中的 rp.get 地址分别是:

其实是两个新的进程分别计算两个求和的逻辑,具体以 5000 端口的逻辑为例看下,代码如下:

const http = require('http');
 * 
 * 创建 http 服务,简单返回
 */
const server = http.createServer((req, res) => {
let sum = 0;
for(let i=0; i<500000000; i++){
        sum = sum + i;
    }
    res.write(`${sum}`);
    res.end();
});
 * 
 * 启动服务
 */
server.listen(5000, () => {
console.log('server start http://127.0.0.1:5000');
});

接下来我们分别打开三个命令行窗口,使用以下命令分别启动三个服务:

node startServer.js
node nextServer.js 
node async.js

启动成功后,再运行如下命令,查看执行时间:

运行成功后,你可以看到如下结果:

499999999075959400
real    0m0.575s
user    0m0.004s
sys     0m0.005s

结果还是一致的,但是运行时间缩减了一半,大大地提升了执行效率。

响应分析

两个服务的执行时间相差一半,因为异步网络 I/O 充分利用了 Node.js 的异步事件驱动能力,将耗时 CPU 计算逻辑给到其他进程来处理,而无须等待耗时 CPU 计算,可以直接处理其他请求或者其他部分逻辑。第一种同步执行的方式就无法去处理其逻辑,导致性能受到影响。

如果使用压测还可以使对比效果更加明显,我将在第 12 讲为你详细介绍关于压测使用以及分析过程。

单线程 / 多线程

在面试过程中,面试官经常会问这个问题 “Node.js 是单线程的还是多线程的”。

学完上面的内容后,你就可以回答了。

主线程是单线程执行的,但是 Node.js 存在多线程执行,多线程包括 setTimeout 和异步 I/O 事件。其实 Node.js 还存在其他的线程,包括垃圾回收、内存优化等。

这里也可以解释我们前面提到的第 4 个问题,主要还是主线程来循环遍历当前事件