浏览器与NodeJS 中的 Event Loop 到底是什么?

1,038 阅读10分钟

前言

老是搞不懂事件循环?微任务宏任务是啥怎么个执行顺序?NodeJS中的事件循环执行机制好乱?那就花上10分钟来了解它吧。

本文具体讲述浏览器和Node.js中的事件循环及其执行机制,包括微任务和宏任务的小知识,最后提供3道面试题供你测试。

浏览器事件循环特性

浏览器的事件循环模型主要通过处理 I/O 通常事件和回调来执行,使其永不阻塞

首先看一张大名鼎鼎的JS和V8引擎执行图。在下图中,我们可以看到首先从执行栈和堆中依次取出相应的函数,然后根据API的要求(定时器时间、回调请求)依次放入回调队列,最后通过一个叫事件循环(Event Loop)的机制根据相应的规则执行。

image-20220616090202719

接下来就来一起看看什么是事件循环

浏览器中的事件循环

什么是浏览器中的事件循环?

可以这么说,浏览器中的事件循环相当于在我们编写的JavaScript代码和浏览器API调用的事件(setTimeout、ajax、监听事件等)之间搭建一个通过回调函数进行沟通的桥梁

关于同步与异步

我们都知道 JavaScript其实是单线程的,为了防止某个 耗时任务 导致 程序假死 的问题 ,JavaScript把单线程任务分为 同步任务 与 异步任务

同步( synchronous ):同步任务表示立即执行的任务,在主线程上排队执行;只有当上一个任务执行完毕,才能执行下一个任务,一般在mian Script(全局作用域下的立即执行代码)中的都为同步执行任务;

异步 ( asychronous ):异步执行的任务,不进入主线程;在异步任务有结果(达到触发条件)后,就将其注册的回调函数放入任务队列中等待主线程空闲的时候读取执行

我们先来看一张图:

image-20220616091549979

我们可以看到当执行同步时,主线程会在每一次执行完一次同步任务后重新回到主线程执行下一个同步任务;而主线程会从任务队列中读取异步任务的回调函数,并将其放到执行栈中依次执行,这个过程是循环不断的,这种运行机制称为 EventLoop (事件循环)

关于微任务与宏任务

Event Loop 事件循环中也分为两个任务:微任务 与 宏任务

在JS中每一个回调函数其实就是一个任务

宏任务(macrotask)

每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)

宏任务队列:ajax,setTimeout, setInterval, DOM监听, UI Rendering 等

微任务(microtask)

在当前宏任务执行结束后立即执行的任务

微任务队列:Promise的then回调,Mutation Observer API, Object.observe, queueMicrotask() (自己声明的微任务)等

浏览器中的事件循环执行顺序

浏览器中的事件循环维护着两个队列:微任务队列与宏任务队列

image-20220616094259974

我们根据这张图来看事件循环的内部执行机制

它会先将JS中编写的顶层script代码优先执行,再执行微任务队列中的每一个任务,如果当前轮中的微任务队列已被执行完,则开始依次执行宏任务队列中的每一个任务;但是对应宏任务有个执行规律,在执行任何一个宏任务之前(不是队列,是一个宏任务),都会先查看微任务队列中是否有任务需要执行;也就是宏任务执行之前,必须保证微任务队列是空的;如果不为空,那么久优先执行微任务队列中的任务(回调);

可能会有点乱,但是我们只需要记住一个原则:在当前的微任务没有执行完成时,是不会执行下一个宏任务的

Node 的基本架构

在具体阐述NodeJS中的事件循环之前,我们先来看一下Node的基本架构:

  • APPLICATION:javasctipt执行代码
  • V8:提供 JavaScript 运行环境,可以说它就是 Node.js 的发动机
  • Node.js Bindings: 提供 JS 和 C++ 的桥梁,封装 V8 引擎 和 Libuv 的细节,向上层提供基础 API 服务
  • LIBUV:专门为 Node.js 开发的一个封装库,提供跨平台的 非阻塞异步 I/O 能力(后面详解);也是最重要的一个,因为它为 Node.js 提供了完善的事件循环处理机制;

image-20220616100432084

上图这么一个流程:当JS把执行代码传递给V8引擎运行环境时,就会通过 Bindings 将其传递给 libuv 进行事件循环处理;

阻塞IO与非阻塞IO

想要搞清楚什么的 非阻塞异步IO,我们需要了解什么的阻塞IO与非阻塞IO

前言:其实在JavaScript中是不能直接对文件进行操作的,实际上任何程序中的文件操作都是需要进行系统调用内部的文件系统的,所以说是操作系统执行的IO操作(输入与输出的操作);

因此操作系统为我们提供了阻塞式调用与非阻塞式调用:

阻塞式调用: 调用结果返回之前,当前线程处于阻塞态(阻塞态CPU是不会分配时间片的),调用线程只有在得到调用结果之后才会继续执行

非阻塞式调用: 调用执行之后,当前线程不会停止执行,只需要过一段时间来检查一下有没有结果返回即可

在我们实际开发场景中,就有很多用到这种非阻塞式调用,例如:网络请求本身使用的Socket通信与文件的读写操作等等..

但是非阻塞IO与异步又有什么关系呢?

阻塞和非阻塞是对于被调用者来说的

在我们这里就是系统调用,操作系统为我们提供了阻塞调用和非阻塞调用;

同步和异步是对于调用者来说的

如果我们在发起调用之后,不会进行其他任何的操作,只是等待结果,这个过程就称之为同步调用;

如果我们再发起调用之后,并不会等待结果,继续完成其他的工作,等到有回调时再去执行,这个过程就是异步调用

因此 非阻塞异步IO则可以表示为:调用执行后不会停止执行且不会等待其他调用结果的输入输出方案

问题

但是非阻塞IO也会存在一定的问题:

如果我们没有获取到需要读取的结果,那么就意味着 为了知道是否读取到了完整的数据,我们需要频繁的去确定读取到的数据是否是完整的,这个过程称之为轮训操作; 它会反复的确认当前数据是否完全准备就绪,但是如果我们的主线程频繁的去进行轮训的工作,那么必然会大大降低性能;因此就要看libuv展示出来的特性了

Node中的微任务宏任务

相对于浏览器中的微任务宏任务,Node显得更丰富一点;

微任务:

next tick queue【微任务之前的队列】:process.nextTick(在本轮循环执行的,而且是所有异步任务里面最快执行的)

other queue【其他队列】:Promise的then回调、queueMicrotask(自己定义的回调函数)

宏任务:

timer queue【时间队列】:setTimeout、setInterval;

poll queue【IO操作/轮询队列】:IO事件;

check queue【检查队列】:setImmediate(这里队列主要针对setImmediate);

close queue【关闭】:close事件;

具体的信息会在下图解释清楚

Node的事件循环机制

在刚刚提到Node架构图中我们能看到,libuv提供了一个线程池( Worker threads),这个线程池负责所有相关的操作,并且会通过轮训等方式等待结果;每当获取到结果时,就将对应的回调放到事件循环(某一个事件队列)中,最后事件循环就可以负责接管后续的回调工作,并告知JavaScript应用程序执行对应的回调函数;

我们可以看到官网对此的一个描述,如下图:

image-20220616180103995

每个阶段都有一个 FIFO 队列来执行回调,虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行;当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,循环渐进,直到代码处理完毕;

几道面试题

最后讲几道面试题帮助理解Node中的事件循环

关于setTimeout 与 setImmediate:

setTimeout(() => console.log(1));
setImmediate(() => console.log(2));

// 答案:可能是 1 2,也可能是 2 1 🧐 

**解析:**关于这个问题的答案其实是不确定的,因为setTimeout的第二个参数默认为0;

但是实际上,Node 做不到0毫秒,最少也需要1毫秒, setTimeout的第二个参数的取值范围在1毫秒到2147483647毫秒之间;也就是说,setTimeout(f, 0)等同于setTimeout(f, 1),实际执行的时候,进入事件循环以后,有可能到了1毫秒,也可能还没到1毫秒,取决于系统当时的状况;

如果没到1毫秒(比事件循环初始化快),那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate的回调函数

进阶版:

fs.readFile('xxx', () => {
    setTimeout(function () {
        console.log(1)
        Promise.resolve(console.log(5)).then(() => console.log(3))
        process.nextTick(() => console.log(4))
    }, 0);
    setImmediate(() => console.log(2))
})

// 答案为:21543

**解析:**首先当前 fs 的操作是在poll阶段,即已经越过timers阶段了,就顺着下面执行到check阶段,则第一轮输入setImmediately中的 2;第二轮进入到timers阶段内部,优先输出log函数 1,接下来因为Promise中的resolve回调级别为同步代码,则输入 5 ;再下来同步代码都执行完了,就顺其自然的优先执行 process.nextTick 中的4了,最后再执行Promise.thn()微任务中的 3;

对于process.nextTick() 的技巧:本轮同步代码执行完立即执行它,所有传递到 process.nextTick() 的回调将在事件循环继续之前解析

终极版:

这个可能相对来说有点复杂ヾ(≧▽≦*)o,不过相信你看到这里应该也能做出真确答案o( ̄▽ ̄)d

const fs = require('fs');
let num;
function someAsyncApiCall(callback) {
    process.nextTick(callback);
}
someAsyncApiCall(() => {
    console.log('0 num' + num);
    console.log(`1 process.nextTick()`);
});
num = 1;

function apiCall(arg, callback) {
    if (typeof arg !== 'string')
        return process.nextTick(
            callback,
            `2 process.nextTick()`
        );
}
apiCall(num, (res) => {
    console.log(res);
})

setTimeout(() => {
    console.log('3 setTimeout()');
});

Promise.resolve(console.log("4 resolve")).then(() => {
    console.log('5 Promise.then() ');
});

fs.readFile('xxx', () => {
    setTimeout(() => {
        console.log(`6 SetTimeout - 内部IO执行`);
    });
    setImmediate(() => {
        console.log(`7 SetImmediate - 内部IO执行`);
    })
})

setImmediate(() => {
    console.log(`8 SetImmediate`);
})

答案:

4 resolve
0 num1
1 process.nextTick()
2 process.nextTick()
5 Promise.then() 
3 setTimeout()
8 SetImmediate
7 SetImmediate - 内部IO执行
6 SetTimeout - 内部IO执行

如果你判断失败,相信你也能独自根据文章找到执行顺序的规律,如果还有疑惑,那就评论一起探讨一下( ̄︶ ̄)↗ 

本文参考文档:

官网:# The Node.js Event Loop, Timers, and process.nextTick()

阮一峰:Node 定时器详解

最后如果本文对于本文有疑惑,还请指导勘正 (●'◡'●)