Node.js事件循环

Posted by WWJ Blog on January 16, 2020

Node.Js事件循环

事件循环是Node.Js处理非阻塞I/O操作的机制,在Node.Js启动之后,就会初始化事件循环,处理脚本文件(或扔进REPL里的代码)。在事件循环阶段,可能回调用一些异步函数,调度计时器函数,或者Process.nextTick(); 在了解事件循环之前,我们先来看一张插图,该图表示程序从开始到结束之间的事件循环流程。

Alt text 该图中有三个流程的颜色是灰色的,这三个流程是Node 内部使用的阶段。Node开发者编写的代码仅以微任务的形式在计时器阶段、轮询阶段和检查阶段被执行;

回调队列

每个阶段都会有在该阶段执行的【先入先出(FIFO)回调队列】。一般而言,对于某个特定阶段的所有逻辑都会在该阶段开始时执行,在此之后,队列中的回调会一直被执行,直到队列为空或者达到系统相关限制。

微任务

微任务在主线程和事件循环的每个阶段之后执行。 在Node领域,微任务分为两种:

  • process.nextTick();
  • 已解决或者已被拒绝 Promise 的 then() 处理函数;

如果process.nextTick()和Promise两者同时存在的话,会先执行process.nextTick(); 在主线程和事件循环的每一个阶段执行完成之后,会立刻调用微任务回调;

new Promise((resolve) => {
  resolve();
}).then(() => {
  console.log('promise');
});

process.nextTick(() => {
  console.log('nextTick');
});

控制台会输出

nextTick
promise

计时器阶段

计时器阶段会执行到期计时器回调函数,计时器回调函数分为两种:

  • Timeout计时器
  • Immediate计时器

Immediate计时器是一个Node对象,它会在下一个检查阶段开始之前执行,下面会讲到检查阶段会做什么事情;

Timeout计时器是一个Node对象,会在计时器回调函数到期后尽快执行。在创建计时器对象的时候,需要传入两个参数,第一个参数为一个匿名函数,即到期之后将会被执行的函数,第二个参数为delay,表示delay多少秒到期。如果第二个参数不传或者传入0的话,那么默认会延迟1毫秒;但是操作系统调度或者其他正在执行的回调可能会影响计数器回调

需要注意的点是:系统不是等到计数器阶段的计时器回调函数执行完毕之后再进入轮询阶段,如果这个时候设置了一个计数器需要在100毫秒之后被执行,而这个时候回调队列里面又有一个异步读取文件的回调函数,且函数执行完毕只要95毫秒,那么系统则会先执行读取文件操作,等到回调队列为空或达到阙值,事件循环机制将查看最快到达阈值的计时器,然后重新回到计时器阶段执行计时器函数。

const fs = require('fs');

const timer = setTimeout(() => {
	console.log('setTimeout');
}, 100);

fs.readFile('./text.txt', () => {
	console.log('finished read file');
});

控制台输出的是

finished read file
setTimeout

可以看出系统没有按照自上而下的顺序执行代码,而是先执行读取文件操作,再去执行定时器函数,了解事件循环机制对上面的输出就不会感到奇怪了;

如果我们把定时器设置为0,那么我们再来看下控制台的输出会是什么

const fs = require('fs');

const timer = setTimeout(() => {
	console.log('setTimeout');
}, 0);

fs.readFile('./text.txt', () => {
	console.log('finished read file');
});
setTimeout
finished read file

用事件循环机制来解释上面的输出就是在1毫秒的时候回调队列里面还没有可以被执行的回调函数,这个时候事件循环机制查看最快到达阈值的计时器,就是在1毫秒之后需要被执行的timer

轮询阶段

代码中的异步函数都将会在这个阶段被执行,轮询阶段有以下两个功能

  1. 计算阻塞I/O(同步代码)的时间
  2. 执行回调队列里的回调函数

当Node进入轮询阶段,且在这个阶段没有要被调度的计时器的话,将会发生以下两种情况:

  1. 当回调队列不为空的情况下,将会访问回调队列并同步执行回调队列里的回调函数,直到回调队列为空,或者已经达到系统的硬性限制。
  2. 当回调队列为空的情况下,可能又会有以下两种情况发生:
    • 如果脚本被 setImmediate() 调度,则Node就会立刻进度检查阶段
    • 如果脚本未被 setImmediate() 调度,那么就会等待回调添加至回调队列,然后立即执行

当Node进入轮询阶段,且在这个阶段没有要被调度的计时器的话,事件循环机制查看最快到达阈值的计时器,然后马上回到计时器阶段,执行计时器函数。

检查阶段

这个阶段只有 setImmediate() 回调会在该阶段中被执行,当时间循环在轮询阶段调度了 setImmediate() 回调,则会马上进入该阶段。

setTimeout 对比 setImmediate

setTimeout和setImmediate很类似,但是因为事件循环机制调度它们的阶段不同,也是会有一些差别的。 第一:如果是在主线程中调度setTimeout和setImmediate,那么其实他们的执行顺序是不确定的,因为它们会受到其他正在运行中的应用程序的影响

const timer = setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

Alt text 那如果是在轮询阶段调度setTimeout和setImmediate,那么setImmediate一定是会在setTimeout之前被执行

const fs = require('fs');

fs.readFile('./text.txt', (err, res) => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);

  setImmediate(() => {
    console.log('setImmediate');
  });
});

Alt text

使用 setImmediate() 相对于setTimeout() 的主要优势是,如果setImmediate()是在 I/O 周期内被调度的,那它将会在其中任何的定时器之前执行,跟这里存在多少个定时器无关