面试官:聊聊事件循环机制

本文正在参加「金石计划」

当面试官对我说:聊聊事件循环机制吧?我的内心颤了一下,因为我知道要扣分了。关于事件循环机制呢,我知道有两类,一类是浏览器的事件循环,另一类是nodejs的事件循环,很不幸的是,我一直没有花时间去更进一步的了解nodejs的事件循环机制,所以我当时我只能尴了个大尬地说:nodejs的事件循环机制我不是很了解....。

以防万一,所以是时候进一步了解nodejs的事件循环机制了。浏览器的事件循环机制这里就不再赘述,移步浏览器的事件循环机制

一、基础内容

Node10版本之前,nodejs采用的是libuv 0.10版本,使用的是混合事件循环机制,而Node10版本以后,nodejs采用的是libuv 1.x版本,使用的是基于阶段事件循环机制。实际上两者的事件循环基本相同,但是前者有一些缺陷,比如在某些情况下事件循环可能会阻塞无法退出;而后者一方面更稳定,性能更好,能够更好地处理异步I/O以及系统事件,另一方面,使用阶段的概念也使得开发者可以更好地了解nodejs应用程序的运行过程等。所以接下来的事件循环是基于Node 10版本以后的作为标准。

NodeJS采用v8作为javascript的解析引擎,但是NodeJS的事件循环机制是基于libuv库实现的,可以理解为一个不断运行并等待事件发生的循环,不同于浏览器的事件循环,Node将事件分为如下六个不同的阶段:

1、定时器检测阶段(timers):执行setTimeout、setInterval的回调
​
2、I/O事件回调阶段(I/O callbacks):执行上一次循环中一些未执行的I/O回调
​
3、闲置阶段(idle,prepare):仅系统内部使用
​
4、轮询阶段(poll):检查是否有I/O事件的回调函数需要执行,若有,则立即执行;若无,则等待下一个定时器准备就绪或新的I/O事件
​
5、检查阶段(check):执行setImmediate的回调
​
6、关闭事件回调阶段(close callbacks):执行一些资源释放的操作,比如处理socket的close事件等
复制代码

从以上六个阶段可以看出,每个阶段都有对应的任务队列,事件循环会根据执行优先级一次执行队列中的任务,并且在每个阶段之间执行微任务和更新时间戳

1、定时器检测阶段(timers)

Node.js 中的定时器是一种会在一段时间后调用给定函数的内部构造。 定时器函数的调用时间取决于用于创建定时器的方法以及 Node.js 事件循环正在执行的其他工作。

也就是说,在NodeJS中,定时器的时间也不是绝对标准的,定时器超时触发是通过在定时器堆(heap)中设置定时器,在定时器到期时将回调函数放入到定时器检测阶段(timers)任务队列中,等待事件循环进入该阶段执行回调函数。

setTimeout(() => {
    console.log('timeout1');
}, 1000)
​
setTimeout(() => {
    console.log('timeout2');
}, 0)
​
//timeout2 timeout1
复制代码

而在Node中,定时器的延时时间也不是绝对准确的,取决于当前事件循环的状态,举个例子:

setTimeout(() => {
    console.log('timeout1');
}, 1000)
​
setTimeout(() => {
    console.log('timeout2');
}, 0)
​
for (let i = 0; i < 100000; i++) {}//模拟一个耗时任务
console.log('loop end');
复制代码

当事件循环遇到一个耗时任务时,肯定要花费一定的时间去执行,等这个耗时任务执行完毕后,方可执行定时器。

2、I/O事件回调阶段(I/O callbacks)

I/O任务队列是存储与I/O操作(如网络请求、文件读写等)相关的回调函数。举个例子:

const fs = require('fs')
// 读取文件
fs.readFile('./text/file.json', (err, data) => {
    if (err) throw err;
    console.log(JSON.parse(data));
});
复制代码

3、闲置阶段(idle,prepare)

准备工作。

4、轮询阶段(poll)

在node事件循环中,轮询阶段会检查是否有I/O事件的回调函数需要执行,如果有,则立即执行它们。如果没有,则会等待一段时间,等待新的I/O事件的到来。以下是一个简单的示例,展示了轮询阶段的行为:

// 轮询阶段的例子
const fs = require('fs');
​
console.log('Script started');
​
// 读取文件
fs.readFile('./text/file.json' (err, data) => {
  // 回调函数将在轮询阶段执行
  console.log('File contents:', JSON.parse(data));
});
​
console.log('Script ended');
复制代码

image.png

在这个示例中,读取文件的回调函数会在轮询阶段被执行

当代码执行到fs.readFile时,Node会启动文件读取操作,发起I/O请求,并将回调函数挂起。Node会检查是否有I/O事件的回调函数需要执行,由于文件读取操作需要一些时间才能完成,Node不会立即执行回调函数。相反,Node会在轮询阶段等待一段时间,等待文件读取操作完成。当文件读取操作完成时,Node会接收到I/O事件的事件通知,然后立即执行回调函数,打印文件内容。

5、检查阶段(check)

在这个阶段是执行setImmediate的回调,在 I/O 事件的回调之后调度 callback 的“立即”执行。

当多次调用 setImmediate() 时,则 callback 函数会按照它们的创建顺序排队执行。 每次事件循环迭代都会处理整个回调队列。 如果立即定时器从正在执行的回调中排队,则直到下一次事件循环迭代才会触发该定时器。

如果 callback 不是函数,则将抛出 TypeError

此方法具有可使用 timersPromises.setImmediate() 获得的 promise 的自定义变体。

6、关闭事件回调阶段(close callbacks)

在关闭事件回调阶段,会执行一些资源释放的操作,例如关闭数据库连接、关闭文件或网络连接等。例如,下面的代码演示如何在关闭事件回调阶段关闭数据库连接:

const { MongoClient } = require('mongodb');
​
// 创建数据库连接
const client = new MongoClient(uri, { useNewUrlParser: true });
​
// 在关闭事件回调阶段关闭数据库连接
process.on('beforeExit', () => {
  console.log('Closing database connection...');
  client.close();
});
复制代码

当事件循环接收到关闭事件时,会执行beforeExit回调函数,释放数据库连接。或者是Socket对象的close事件:

let dgram = require('dgram');
​
let socket = dgram.createSocket("udp4");
​
socket.on('close', () => {//socket对象的close事件
    console.log('Socket对象已关闭');
});
​
socket.close();
复制代码

如上所示,当调用Socket对象的close方法之后close事件就会被触发。

从以上六个阶段可以看出,在事件循环中,最常见和耗时的是poll阶段,它会在等待I/O事件发生时阻塞代码的执行,直到有事件发生或定时器到期。而在idle和check阶段,会执行一些轻量级的回调任务。

二、事件循环

1、宏任务和微任务

nodejs中的宏任务和微任务:

macro-task:setTimeoutsetInterval、 setImmediate、script(整体代码)、 I/O 操作等。
micro-task: process.nextTicknew Promise().then(回调)等
复制代码

process.nextTick 是一个独立于 eventLoop 的任务队列。

在每一个事件循环 阶段完成后都会去检查 nextTick 队列,如果队列里有任务,则该任务优先于微任务执行。举个例子:

setTimeout(function() {
    console.log('timer1');
    process.nextTick(function() {
        console.log('nextTick1');
    });
    new Promise(function(resolve) {
        console.log('promise1');
        resolve();
    }).then(function() {
        console.log('promise1 callback');
    });
});
​
process.nextTick(function() {
    console.log('nextTick2');
});
​
new Promise(function(resolve) {
    console.log('promise2');
    resolve();
}).then(function() {
    console.log('promise2 callback');
});
​
setTimeout(function() {
    console.log('timer2');
    process.nextTick(function() {
        console.log('nextTick3');
    })
    new Promise(function(resolve) {
        resolve();
    }).then(function() {
        console.log('promise3 callback');
    });
})
复制代码

执行顺序如下:

node-loop.gif

从图片可以看出,process.nextTick拥有最高的优先级,比微任务更高的优先级;另外,微任务在事件循环的各个阶段执行。如果有新的宏任务被加入到队列中,该宏任务会在下一个循环周期执行。

2、循环机制

image.png

根据如图所示,事件循环的流程大致如下: 1.执行 timers 阶段的回调函数,如果队列为空,则跳过。 2.执行 I/Ocallbacks 阶段的回调区数,如果队列为空,则跳过。 3.执行 poll 阶段的回调函数,直到队列为空或达到系统限制,此阶段主要用于l/0 事件等待,当队列不为空时,如果有轮询的触发条件满足,则执行相应的回调函数,否则将等待直到触发条件满足,可者达到系统限制。 4.执行 check 阶段的回调函数,如果队列为空,则跳过。 5.执行微任务(microtask),包括process.nextTick、Promise.then 等回调。

6.如果 Event Loop 处于被停止的状态则结束,否则回到第一步。 在事件循环的过程中,如果有新的宏任务(macrotask)被加入到队列中,则会在下一个循环周期执行。同时,微任务(microtask)和宏任务(macrotask)的执行顺序是不同的,微任务会在当前阶段结束后立即执行,而宏们务会在下一个阶段开始时执行。

总之,事件循环是Node.js实现异步I/O和非阻塞操作的重要机制,开发者需要灵活掌握事件循环的机制,以充分发挥Node.js的性能和优势。

3、注意点

在 Node.js 的事件循环中,setTimeout 和 setImmediate 执行顺序是有区别的。

举个例子:

setTimeout(() => {
  console.log('setTimeout')
}, 0)
​
setImmediate(() => {
  console.log('setImmediate')
})
复制代码

其输出结果不确定,可能是先输出 setTimeout,也可能是先输出 setImmediate

这是因为 setTimeout 是将回调函数加入到定时器队列中,等待一定时间后再执行。而 setImmediate 是将回调函数加入到 check 队列中,在下一次事件循环迭代中执行。

如果当前事件循环中没有其他任务执行,那么在下一个事件循环从 check 队列中取出回调函数并执行,这时 setImmediate 的回调函数就会先执行,因为它在定时器队列中加入的时间比 setTimeout 的回调函数早。

但是如果当前事件循环中有其他任务执行,那么下一个事件循环开始时就会先执行已经在定时器队列中的任务,因此 setTimeout 的回调函数会先执行。

综上所述,setTimeoutsetImmediate 的执行顺序是不确定的,取决于当前事件循环的状态。

三、新老版本的事件循环机制

在Node.js的事件循环机制方面,新旧版本之间主要有以下不同: 1、引入微任务(microtasks)队列:Node.js 11.0.0版本引入了微任务队列,用于存储当当前任务完成后需要立即执行的回调函数,如Promise.then、process.nextTick等,它会在当前阶段执行完所有任务后立即执行其中的回调函数,而不需要等待下一个事件循环周

2、timers阶段处理:在旧版本中,如果某个定时器的回调执行时间过长,会导致其他定时器回调的执行受到影响。在新版本中,该问题得到修复,timers阶段的回调会在执行一定时间后进行重新调度,来确保即使存在长时间的回调执行也不会影响其他定时器的执行。

3、I/O callbacks阶段处理:在旧版本中,当事件循环进入此阶段时,会立即执行存储在队列中的回调函数,而在新版本中,如果I/O callbacks队列有较多的回调需要执行,Node.js会将其拆分为小任务,根据一定的策略分配执行,以防止长时间阻塞其他回调函数的执行。

4、nextTick队列清空策略:在旧版本中,process.nextTick回调函数的执行比Promise等微任务更高优先级,当nextTick队列非常长时,执行nextTick回调函数可能会阻塞事件循环。而在新版本中,Node.js 会每隔一定数量或一定时间就清空nextTick队列,以确保事件循环的正常运行和其他回调函数的及时执行。综上所述,新旧版本在事件循环机制方面有一些不同,其中的改进主要是为了优化事件处理和提高Node.js的性能。

猜你喜欢

转载自juejin.im/post/7217386885794136125