美文网首页
JS 事件循环机制

JS 事件循环机制

作者: elle0903 | 来源:发表于2023-02-20 11:46 被阅读0次

    前言

    我们知道 JS 是单线程的,这也意味着,同一个时间,它只能做一件事,那么,它是怎么做到在执行代码的同时,去监听一些事件的呢?

    想要回答这个问题,我们就得去深入了解 JS 的事件循环机制。

    现阶段来说,JS 的主要运行环境包含两个:

    1. 浏览器
    2. Node.js

    两者的实现从结果上来说,可以说殊途同归,深入到内部却大相径庭。

    本文的内容也将包含这两个部分:

    1. 浏览器内部的事件循环机制。
    2. Node.js内部的事件循环机制。

    废话不多说,直接开始吧。

    浏览器的事件循环机制

    进入正题之前,我们需要先了解一些基本概念。

    首先,JS 虽然是单线程的,运行它的浏览器却不是,浏览器是多进程的框架,每一个标签页都包含一个独立的渲染进程,那里面又包含若干独立线程。

    多进程架构的浏览器

    JS 引擎线程负责 JS 代码的解释和运行,除此之外,那里面还包含一个定时器触发线程、一个事件触发线程、一个异步 HTTP 请求线程

    有了这个基本概念之后,我们就可以详细来看看 JS 的事件循环机制了。

    在浏览器里,JS 的事件循环机制是怎样的?

    我们知道 JS 引擎在执行一段代码时,会首先将这些代码按顺序排放好,再依次执行这些内容。同步的代码,它当然可以直接执行,但如果遇到异步的,它就会触发一些操作。

    • 遇到计时器(setTimeout、setInterval 等),它会找到定时器触发线程
    • 遇到事件监听(鼠标点击、键盘操作等),它会找到事件触发线程
    • 遇到异步 HTTP 请求,它会找到异步 HTTP 请求线程

    把事情交给这些线程去处理,JS 引擎继续往下执行。

    接下来,在 JS 引擎忙着执行余下的代码时,定时器触发线程开始了倒计时,事件触发线程开始了事件监听,异步 HTTP 请求线程发起了 HTTP 请求。

    当倒计时结束、事件被触发、HTTP请求有了回应,这些线程会再次跟 JS 引擎线程通信,它们会告诉它:好消息,异步操作执行结束了,可以开始执行回调函数了!

    但这时候 JS 引擎可能正忙,前面我们又说了,JS 是单线程的,同一个时刻,只能做一件事,那么,该设计一个怎么样的机制,才能让 JS 引擎能够及时收到这些消息,并以最快的速度去执行这些回调呢?

    答案是任务队列

    JS 引擎会维护一个任务队列,当它闲置下来时,便会去查询这个任务队列,发现那里面不为空,它便按照次序,去执行那里面的回调。

    于是那些异步任务处理线程需要做的事,也就是在异步操作执行结束之后,把需要执行的回调函数,推到这个队列里面去。

    这样一来,事情就很清晰了。
    我们来给浏览器里的 JS 事件循环机制下一个定义吧。

    浏览器里的 JS 事件循环机制指的是:JS 引擎会维护一个任务队列,当异步操作发生时,它会将该异步操作分配给不同的线程,等异步操作执行结束,这些线程再将各自的回调加入任务队列中,等待 JS 引擎闲置时去执行。

    浏览器的 JS 引擎循环执行任务队列内容的操作,就被称为浏览器的 JS 事件循环机制

    来个示意图。

    浏览器里的 JS 事件循环机制

    有了这个概念之后,我们就可以进一步深入了,——是的,还没完,我们还可以进一步深入!

    下面来介绍两个新概念:宏任务和微任务。

    不要被名字吓到,你其实跟它们很熟悉。

    宏任务(macro task)和微任务(micro task)

    前面我们提到一个任务队列,它由 JS 引擎创建,事件循环也是针对它发生。
    在上面,我们为了方便理解,假设只有一个这样的任务队列,JS 引擎有空时,便去按顺序执行那里面的内容。

    但其实这样的任务队列不止一个。

    这样的任务队列有两个:宏任务队列微任务队列

    这两个队列的作用很好理解,宏任务队列就是用来保存宏任务的,微任务队列就是用来保存微任务,一眼就能看明白,那么问题来了,宏任务是什么?微任务又是什么?

    宏任务是什么?微任务又是什么?

    简单来说,这就是一个分类。

    我们知道,JS 代码在执行过程中会产生很多异步任务,前面提到的计时器是其中一种,事件监听是另一种,除此之外,还有 HTTP 请求、Promise 和 MutaionObserver 等。

    出于某种原因,JS 引擎对它们进行了分类,将一部分称为宏任务,剩下的称为微任务

    常见宏任务如下:

    • 计时器:setTimeout、setInterval、setImmediate
    • 事件监听:鼠标点击和键盘事件等
    • 异步 HTTP 请求

    常见微任务如下:

    • promise.then、promise.catch
    • MutaionObserver
    • process.nextTick

    浏览器的 JS 引擎,对宏任务队列和微任务队列的处理方式也是不同的。

    简单来说,微任务队列的优先级高于宏任务队列

    微任务队列不为空的情况下,JS 引擎优先执行微任务队列的内容,等微任务队列空了,它才会去执行宏任务队列的内容。

    考虑一下下面一段代码。

    console.log('同步代码1');
    
    setTimeout(() => console.log('宏任务:setTimeout'), 0);
    
    new Promise((resolve) => {
        console.log('同步代码2');
        resolve();
    })
    .then(() => console.log('微任务:promise.then'));
    
    console.log('同步代码3');
    

    这段代码的输出结果是:

    1. 同步代码1
    2. 同步代码2
    3. 同步代码3
    4. 微任务:promise.then
    5. 宏任务:setTimeout

    虽然 setTimeout 先于 promise.then 被添加进任务队列,但因为微任务队列的优先于高于宏任务队列的缘故,promise.then 被先一步执行。

    Node.js 的事件循环机制

    首先,我们知道,事件循环机制之所以出现,最重要一个原因,就是 JS 是单线程的。

    它不像其他多线程语言一样,可以在需要的时候,随时开一个线程,去单独处理任务,再在结束之后,将线程销毁。它避开了新建、销毁线程,以及线程切换的资源消耗,同时也带来了新的问题。

    如何处理异步操作?

    为了解决这个问题,有了 JS 的事件循环机制。

    一言以蔽之,JS 事件循环机制的诞生,就是为了解决 JS 执行过程中所遇到的异步操作问题。

    在浏览器端,我们通常可能遇见的异步操作有:

    • 计时器
    • 事件监听
    • 异步请求
    • 其他种种

    到了 Node.js 方面,跟用户有关的鼠标和键盘操作的用户事件监听虽然没了,却多了很多服务端特有的异步任务。

    • 文件读写相关的 I/O 任务
    • 线程相关的 process.nextTick
    • 服务相关的 server.close,socket.on('close', ...)

    所以虽然在 Node.js 端,事件循环机制仍然涉及到主线程、任务队列、宏任务、微任务的概念,微任务的优先级,也仍旧高于宏任务,其他方面却还存在一些不同。

    首先,它重新给宏任务分了类。
    其次,它添加了任务阶段的概念。

    下面我们逐一来了解。

    宏任务的新分类

    • 计时器 Timers:用于存储和执行 setTimeoutsetInterval 等的回调函数。
    • 系统任务回调 Pending Callbacks:用于存储和执行系统任务执行结束之后的回调,比如端口监听、比如 TCP 错误。
    • I/O 回调 I/O Polls:用于存储和执行 I/O 执行结束之后的回调,比如读写文件、比如读写数据库。
    • 检查 Check:用于存储和执行 setImmediate 的回调。
    • 关闭回调 Close Callbacks:用于存储和执行关闭相关的回调,比如 socket.on('close', ...)

    任务阶段这个新概念

    在浏览器里,JS 引擎单独维护一个宏任务队列,队列内的宏任务按照入队时间,依次被执行。

    但在 Node.js 内,事情变得有一点点不相同。

    它会按照宏任务的类型,维护不同的宏任务队列内。

    计时器有一个单独的计时器队列;系统任务回调有一个单独的系统任务回调队列;I/O 回调也有单独的I/O 回调队列。

    当事件循环函数开始执行,它会依次去读取这些队列,再依次执行这些队列内的回调函数,等一个队列被清空,它进入下一个。

    相当于在浏览器里,宏任务们不分组,只要是宏任务,大家都待在一起,执行时按照入队顺序。到了 Node.js 里,不同的宏任务被分了组,只有前一组执行结束,后一组才有执行的机会。

    举一个具体的例子吧。

    计时器队列内有 3 个待执行回调,系统任务队列内有 5 个待执行回调,I/O 队列内有 2 个,那么等事件循环函数开始执行时,它不会把这些任务混杂在一起,先后去执行,它会先去执行计时器队列内的回调,等计时器队列的三个回调执行结束,它再去执行系统任务回调,之后来到 I/O。

    Node.js 的事件循环机制

    Node.js 的跨平台能力是基于 Libuv 库实现的,JS 引擎并不会真正去操作数据库或者读写文件,它所做的事情,只是让 JS 去调用 Node.js 的 API,而 Node.js 的 API 真正调用的,又都是底层的C++代码。

    Livub 库给不同类型的宏任务进行了分组,这些分组,就是 Node.js 事件循环的不同任务阶段。

    它们的执行顺序如下图所示:

    Node.js 事件循环的不同任务阶段

    循环开始时,循环函数会先去读取计时器回调的内容,等那里的回调队列被清空,它继续去读取系统任务的回调队列,之后来到 I/O 回调阶段。

    I/O 回调阶段跟其他几个阶段有些不同。

    这个阶段的回调队列被清空后,循环函数不会立刻往下,它会先进行一些检查。

    • 如果它的下一个阶段,也就是 check 阶段的回调队列(setImmediate 的回调)不为空,那么它继续往下走去。
    • 如果 check 阶段的回调队列为空,但 timers 阶段的回调队列不为空,那么循环函数将从头开始,——从 timers 阶段开始,重新执行。
    • 如果这两者都为空,那么它将暂停执行,等待新的回调进入。

    有了这个了解之后,我们再来看看 Node.js 微任务。

    浏览器端区分了宏任务和微任务,Node.js 端同样如此,这儿的微任务定义也跟浏览器端没有差别。对于 Node.js 来说,常见的微任务同样是那些,但在执行的时机上,两者还是存在一些细微的差别。

    Node.js 11 之前,Libuv 会在执行完一个任务阶段的所有回调之后,再去检查和执行微任务队列的内容,等微任务队列被清空,它进入下一个任务阶段

    也就是说,下面这段代码的输出,可能不尽如人愿。

    setTimeout(() => {
        console.log('宏任务 timers 阶段:timeout1')
        Promise.resolve().then(function () {
            console.log('微任务:promise1');
        });
    }, 0);
    setTimeout(() => {
        console.log('宏任务 timers 阶段:timeout2')
        Promise.resolve().then(function () {
            console.log('微任务:promise2');
        });
    }, 0);
    

    在浏览器里,这段同步代码执行结束后,定时器触发线程会多出两条计时任务。

    由于计时任务属于宏任务,所以时间到了之后,这两条计时任务的回调函数,会被先后添加进 JS 引擎的宏任务队列。

    第一个回调函数先被执行,控制台输出宏任务 timers 阶段:timeout1,Promise.resolve 属于同步代码,直接执行,其后的 Promise.then 向 JS 引擎的微任务队列,添加一条微任务。

    第一个回调函数执行结束后,回调函数本身被从宏任务队列当中移除,之后,由于微任务的优先级高于宏任务,所以浏览器会暂停遍历宏任务队列,先去检查微任务队列的内容,发现不为空,于是第二条微任务:promise1被输出。

    微任务队列被清空后,JS 引擎会继续往下,检查宏任务队列的内容,于是第三条信息宏任务 timers 阶段:timeout2 被输出,之后再去检查微任务队列,第四条信息微任务:promise2 被输出。

    于是在浏览器,这段代码的输出依次是:

    1. 宏任务 timers 阶段:timeout1
    2. 微任务:promise1
    3. 宏任务 timers 阶段:timeout2
    4. 微任务:promise2

    但到了 Node.js 11 之前的环境里,情况变得不太相同,在这里,这段代码的输出内容是:

    1. 宏任务 timers 阶段:timeout1
    2. 宏任务 timers 阶段:timeout2
    3. 微任务:promise1
    4. 微任务:promise2

    这是因为,在 Node.js 11 之前的环境里,事件循环会先将一个阶段的所有任务先清空,再去检查微任务队列的内容,在这里,代码的执行次序如下:

    1. 同步代码执行结束后,timers 阶段多出两条计时任务。
    2. 时间到了,两条计时任务的回调函数,先后被添加进 timers 阶段的待执行回调队列中。
    3. 事件循环来到 timers 阶段,发现任务队列不为空,于是,先执行第一条计时任务的回调函数:输出 timeout1,将输出 promise1 添加进微任务队列;再执行第二个计时任务的回调函数:输出 timeout2,将输出 promise2 添加进微任务队列。
    4. timers 阶段的回调队列被清空,往下个阶段进发之前,先去检查微任务队列,发现微任务队列不为空,按照顺序依次执行,promise1promise2 也相继被输出。

    于是输出的内容就成了上面描述的那样。这个偏差看起来不大,却会给编写程序的程序员带来不小影响,于是 Node.js 11 之后,事件循环的机制被调整,Node.js 端的输出内容,也开始跟浏览器端一致,也就是说,每个阶段的每个回调执行结束后,在继续执行下一个回调之前,事件循环相关函数会先去检查和执行微任务队列的内容,之后再回来。

    好嘞,以上就是本文的全部内容啦,感谢看到这里的大小可爱们,有机会再见!

    参考链接

    相关文章

      网友评论

          本文标题:JS 事件循环机制

          本文链接:https://www.haomeiwen.com/subject/sinykdtx.html