详解NodeJS事件循环

官网:node官网-事件循环
之前分享过浏览器中的事件循环,感兴趣的可以去看:从js单线程到浏览器多线程与多进程,再到JS的事件循环

浏览器中的事件循环是由HTML规范来定义,之后由各浏览器厂商实现的,而node中的事件循环的定义与实现均由libuv引擎完成。

node使用chrome v8引擎作为js解释器,v8引擎分析代码后,主线程立即执行同步任务,而异步任务则由libuv引擎驱动执行,而且不同异步任务的回调事件会放在不同的队列中等待主线程执行,不再是简单的宏任务队列和微任务队列。因此在nodeJS中,虽然程序运行表现出的整体状态与浏览器中传统的js大致相同,先同步后异步,但是对于异步的部分,node则依靠libuv引擎来进行更复杂的管理。

宏任务队列和微任务队列

六个基本阶段(六个宏任务队列)

在这里插入图片描述

  1. timers:计时器阶段,处理setTimeout()和setInterval()定时器的回调函数
  2. pending callbacks :待定回调阶段,用于处理系统级别的错误信息,例如 TCP 错误或者 DNS 解析异常
  3. idle,prepare:仅在内部使用,可以忽略不计
  4. poll:轮询阶段,等待I/O事件(如网络请求或者文件I/O等)的发生,然后执行对应的回调函数,并且会处理定时器相关的回调函数。如果没有任何I/O事件发生,此阶段可能会使事件循环阻塞
  5. check:检查阶段,处理 setImmediate() 的回调函数。check 的回调优先级比 setTimeout 高,比微任务要低
  6. close callbacks:关闭回调阶段,处理一些关闭的回调函数,比如 socket.on(‘close’)

nextTick队列(微任务队列)

该事件队列独立于6个阶段的事件队列之外,用于存储 process.nextTick() 的回调函数。

microTask队列(微任务队列)

该事件队列也独立于6个阶段的事件队列之外,用于存储 Promise(Promise.then()、Promise.catch()、Promise.finally())的回调函数。

NodeJS事件循环流程

以上六个基本阶段和两个独立的事件队列构成了node事件循环的核心部分,在一次循环迭代的流程中,需要注意:

  1. nextTick队列、microTask队列中的任务穿插于6个阶段之间进行,每个阶段进行前会先执行并清空nextTick队列、microTask队列中的回调任务(可以理解为一次循环迭代至少处理6次nextTick队列和microTask队列中的任务)
  2. nextTick队列、microTask队列执行的次数在Node v11.x版本前后有一些差异,(上文中的至少很有深意),具体如下:
    a. Node版本小于11时,nextTick队列、microTask队列中的任务只会在6个阶段之间进行,因此一次循环迭代最多处理6次这两个队列
    b. Node版本大于11时,任何一个阶段的事件队列中任务之间都会处理一次这两个队列,因此一次循环迭代至少处理6次这两个队列,上限则受各个阶段总任务数影响而不固定
    c. 上述2个版本之间的区别,被认为是一个应该要修复的bug,因此在v11.x之后,node修改了nextTick队列、microTask队列的处理时机。从宏、微任务的角度看,修复后的流程和传统js的事件循环保持了一致
  3. nextTick队列中任务的优先级高于microTask队列

setTimeout() 与 setlmmediate() 的特殊情况

我们知道 setTimeout()的回调是在 timers阶段执行,setImmediate()的回调是在 check阶段执行,并且事件循环是从 timers阶段开始的,那么 setTimeout()的回调一定会先于 setImmediate()的回调执行吗?答案是不一定。在只有这两个函数且近乎同时触发的情况下,它们回调的执行顺序不是固定的(受调用时机、计算机性能影响)。下面是一个例子:

// 示例1(node v12.16.3)
setTimeout(() => {
 console.log("setTimeout");
});

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

// 结果:
// setTimeout -> setImmediate
// 或
// setImmediate -> setTimeout

上面示例1中的这段代码输出结果就是不固定的,这是因为这种情况下回调不一定完全准备好了。因为主线程没有同步代码需要执行,程序一开始就进入了事件循环。这时setTimeout()的回调并不是一定完全准备好了,因此就可能会在第一次循环迭代的check阶段中执行setImmediate()的回调,再到第二次循环迭代的timers阶段执行setTimeout()的回调;同时也有可能setTimeout()的回调一开始就准备好了,这样就会按照先setTimeout()再setImmediate()的顺序执行回调。由此就造成了输出结果不固定的现象。

有以下两种方法可以使输出顺序固定:
① 人为添加同步代码的延时器,保证回调都准备好了(延时器的时长设定可能会受机器运行程序时的性能影响,因此该方法严格意义上并不能100%固定顺序)。
② 将这两个方法放入pending callbacks、idle,prepare、poll阶段中任意一个阶段即可,因为这些阶段执行完后是一定会先到check再到下一个迭代的timers。由于pending callbacks、idle,prepare阶段都偏向于系统内部,因此一般可以放入poll阶段中使用。

如下示例2,我们人为加上一个2000ms的延时器,输出的结果就固定了,如下所示:

//示例2(node v12.16.3)
setTimeout(() => {
 console.log("setTimeout");
});

setImmediate(() => {
 console.log("setImmediate2");
});

const sleep = (delay) => {
 const startTime = +new Date();
 while (+new Date() - startTime < delay) {
   continue;
 }
};
sleep(2000);

// 结果:setTimeout -> setImmediate

如下示例3,我们将函数放入文件I/O的回调中,输出的结果也就固定了,如下所示:

//示例3(node v12.16.3)
const fs = require("fs");

fs.readFile("./fstest.js", "utf8", (err, data) => {
  setTimeout(() => {
    console.log("setTimeout");
  });

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

// 结果:setImmediate -> setTimeout 

NodeJS事件循环示例

console.log('1'); //1层同步

//1层timers,setTimeout1
setTimeout(function() {
    console.log('2'); //2层同步
    process.nextTick(function() {
        console.log('3'); //2层nextTick队列
    })
    new Promise(function(resolve) {
        console.log('4'); //2层同步
        resolve();
    }).then(function() {
        console.log('5'); //2层microTask队列
    })
})

process.nextTick(function() {
    console.log('6'); //1层nextTick队列
})

new Promise(function(resolve) {
    console.log('7'); //1层同步
    resolve();
}).then(function() {
    console.log('8'); //1层microTask队列
})

//1层timers,setTimeout2
setTimeout(function() {
    console.log('9'); //2层同步
    process.nextTick(function() {
        console.log('10'); //2层nextTick队列
    })
    new Promise(function(resolve) {
        console.log('11'); //2层同步
        resolve();
    }).then(function() {
        console.log('12'); //2层microTask队列
    })
})

console.log('13'); //1层同步

//(node v12.16.3)结果:1 -> 7 -> 13 -> 6 -> 8 -> 2 -> 4 -> 3 -> 5 -> 9 -> 11 -> 10 -> 12
//(node v8.16.0)结果:1 -> 7 -> 13 -> 6 -> 8 -> 2 -> 4 -> 9 -> 11 -> 3 -> 10 -> 5 -> 12

图解:node12+版本下的执行顺序

在这里插入图片描述

  • 首先是1层的同步任务直接执行:1、7、13
  • 进入事件循环
  • 执行1层的nextTick队列:6
  • 执行1层的microTask队列:8
  • 进入timer阶段,由于setTimeout1的回调任务先进入队列,因此先执行setTimeout1的2层同步任务:2、4
  • 执行setTimeout1的2层nextTick队列:3
  • 执行setTimeout1的2层microTask队列:5
  • setTimeout1的2层代码均执行完毕,再执行setTimeout2的2层同步代码:9、11
  • 执行setTimeout2的2层nextTick队列:10
  • 执行setTimeout2的2层microTask队列:12

和浏览器中事件循环的区别

浏览器事件循环在每次宏任务执行后,浏览器有机会进行UI渲染,但实际渲染取决于是否触发了重排或重绘。

  • 环境差异: 浏览器环境和 Node.js 环境是不同的。浏览器中的事件循环主要用于处理用户交互事件(例如点击、滚动等)和网络请求等异步任务,而 Node.js 的事件循环主要用于处理 I/O 操作(例如文件操作、网络请求等)和自定义的事件。

  • API 差异: 浏览器和 Node.js 提供了不同的 API 来处理异步操作。浏览器中常用的异步 API 包括定时器函数(如 setTimeout、setInterval)、AJAX 请求和 Web API(如 Fetch API、WebSockets 等)等。而 Node.js 中常用的异步 API 包括文件系统操作、网络操作、数据库访问等。

  • 宏任务和微任务: 在浏览器中,事件循环通常分为宏任务(macrotasks)和微任务(microtasks)两个阶段。宏任务包括用户交互事件、网络请求等,而微任务包括 Promise、MutationObserver 等。而在 Node.js 中,事件循环没有明确的宏任务和微任务的划分,所有的异步任务都被视为一个个独立的阶段。

  • 事件驱动模型: 浏览器中的事件循环通常以单线程的方式执行,即所有的 JavaScript 代码都在同一个线程中执行。而 Node.js 中的事件循环是基于多线程的,通过使用 libuv 库实现了事件循环的异步执行,使得 Node.js 可以处理大量的并发请求。

应用场景影响

● Node.js 更强调后端服务的高效I/O处理和高并发能力,因此其事件循环机制侧重于快速响应I/O事件和维持稳定的事件处理流。
● 浏览器 则侧重于UI渲染和用户交互的实时响应,故其事件循环设计确保了UI的流畅更新和事件的及时处理。

  • 18
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值