Snofly
2628 words
13 minutes
JavaScript 事件循环深入
2023-02-21

前言#

作者述#

笔者看了一个关于 JS 事件循环的视频——菲利普·罗伯茨:到底什么是 Event Loop 呢? | 欧洲 JSConf 2014 。观后深有感悟,结合另一篇文章——浏览器是如何工作的:Chrome V8 让你更懂 JavaScript,整理思路、输出本文。

预备知识#

  • 具备 JS 基础理解
  • 了解 JS 异步队列

注意:本文不适合零基础的读者。

本文目标#

通过观察 JavaScript 代码在浏览器中的执行过程,理解 event loop ,顺便了解一下 JS 引擎的设计思路。

JS 引擎与 JS 代码#

JS 引擎#

众所周知啊,计算机的 CPU 只能执行其指令集所包含的机器指令或汇编指令,所以高级语言到机器语言的编译过程是不能省去的。

指令集的差异是客观存在的,为了使 JS 代码在不同环境中执行,就有了 JS 引擎去兼容各种指令集,然后编译出相应的汇编代码。

Google V8#

浏览器中包含了渲染引擎和 JS 引擎,因为浏览器厂商很多,所以 JS 引擎也多种多样。

当然,优秀的技术和设计总是趋同的,这里介绍一款 JS 引擎:Google V8 。

V8 (Google),用 C++ 编写,开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分,也用于 Node.js。

V8 最早被开发用以嵌入到 Google 的开源浏览器 Chrome 中,第一个版本随着第一版 Chrome 于 2008 年 9 月 2 日发布。

V8 率先引入了即时编译(JIT)的双轮驱动的设计(混合使用编译器和解释器的技术),这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。

为了优化 JS 代码的执行,V8 中还设计了更多的机制。感兴趣可以查看本文首段的那篇文章。

解释型语言#

先引用《百度百科》的一段话:

解释型语言:程序不需要编译,程序在运行时才翻译成机器语言,每执行一次都要翻译一次。

相对于编译型语言存在的,源代码不是直接翻译成机器语言,而是先翻译成中间代码,再由解释器对中间代码进行解释运行。比如 Python/JavaScript / Perl /Shell 等都是解释型语言。

这种说法是很模糊的,举个例子:

对 JS 代码中的函数,V8 只会在函数调用时,编译一次函数然后执行。如果这个函数被多次调用,那么 V8 会缓存编译结果,以后就不用再编译了。

可以说,现代的 JS 均衡地混合了解释型语言和编译型语言的特点。

event loop#

单线程#

JavaScript 是单线程语言,它是不支持多线程并行执行代码的。

我们看到的异步代码的执行,比如处理 http 请求的同时执行其他代码,这个过程似乎是多线程的并行执行。

实际上不是的,因为支持多线程的是浏览器,而不是 JS 引擎。

JS 引擎只是依次处理执行栈中的帧,这个栈帧可能来自 JS 代码,也可能来自任务队列。

eventLoop

(图片来自另一篇文章,其描述的事件循环过程和前文视频中相同。也可以看看这篇译文。)

执行过程#

一个有趣的事情:

setTimeout() 方法不是 ecmascript 规范定义的内容,而是属于 BOM 提供的功能。

我们可以在浏览器控制台中直接使用 setTimeout() 。

下面描述了一段示例代码的执行过程:(建议查看视频的第 12 分 40 秒)

1、开始执行:

eventLoop

2、入栈当前任务(假设是 main ):

eventLoop

3、入栈第一句代码,执行、出栈:

eventLoop

4、入栈第二句代码,执行:

eventLoop

5、第二句代码主体执行完成,定时器给 webapi 执行:

eventLoop

6、第二句代码出栈:

eventLoop

7、第三句代码入栈,执行、出栈:

eventLoop

8、当前任务执行完成,main() 出栈:

eventLoop

9、计时器执行完成,回调函数转入任务队列:

eventLoop

10、计时器函数清除:

eventLoop

11、当前任务——回调函数 cb 入栈:

eventLoop

12、第一句代码入栈,执行、出栈:

eventLoop

13、当前任务 cb 执行完成,出栈:

eventLoop

优先级考虑#

上面的步骤就是这段代码的执行过程了。其中,值得注意的地方有:

1、定时器是 webapi 处理的,也就是说,定时器的等待时间是指 cb 在 webapi 中的等待时间,而 cb 再回到栈中执行的时间是不确定的。

2、当 main() 执行完成时,先查询任务队列。此时任务队列为空,于是 JS 引擎会再去取新的任务(比如 main2())执行。

3、当栈为空,且任务队列中有等待执行的任务时,会取任务队列中的任务入栈执行。

4、任务队列内部的任务,存在优先级(也就是插队)的机制。

宏任务和微任务#

理论#

JS 中的任务可以分为同步和异步。按照上图,同步代码会先于异步代码执行,栈空之后,才有可能执行异步代码。

这里有个问题:如果有段异步代码在 webpai 中处理很慢,但是它很重要,处理完后需要优先执行,于是就需要插队。

目前的设计是:将任务队列就分为两个子队列,分别存放需要优先执行的任务(微任务),和不需要优先执行的任务(宏任务)。

这两个队列就是微任务队列和宏任务队列,只有微任务队列清空时,才会执行宏任务队列。

这样,就能更细粒度地控制任务队列的执行顺序。

常见的划分: 宏任务:常见的定时器,用户交互事件等。 微任务:Promise 相关任务, MutationObserver 等。

代码#

结合上面的理论,下面看一段代码的执行顺序。

function app() {
  setTimeout(() => {
    console.log("1-1");
    Promise.resolve().then(() => {
      console.log("2-1");
    });
  });
  console.log("1-2");
  Promise.resolve().then(() => {
    console.log("1-3");
    setTimeout(() => {
      console.log("3-1");
    });
  });
}
app();

它的输出是:

1-2
1-3
1-1
2-1
3-1

可以自己推导一下。

下面笔者推导一下执行过程:

1、执行同步代码,打印 1-2;

2、检查到同步代码全部执行完毕,开始访问任务队列。

3、优先访问微任务队列,发现有一个任务。

4、微任务入栈,开始执行。

5、微任务执行第一句,打印 1-3;

6、微任务执行第二句,创建计时器,给 webapi;

7、微任务执行完成,再次访问任务队列。

8、优先访问微任务队列,为空。

9、最后访问宏任务队列,存在两个宏任务。

10、第一个宏任务入栈,开始执行。

11、执行宏任务第一句,打印 1-1;

12、执行宏任务第二句,创建一个微任务给 webapi 。

13、该宏任务执行完成再次访问任务队列。

14、优先访问微任务队列,发现有一个任务。

15、微任务入栈,开始执行。

16、执行微任务第一句,打印 2-1 。

17、该微任务执行完成,再次访问任务队列。

18、优先访问微任务队列,为空。

19、最后访问宏任务队列,发现有一个任务。

20、该宏任务入栈,开始执行。

21、执行宏任务第一句,打印 3-1 。

22、该宏任务执行完成,再次访问任务队列。

23、任务队列为空,持续等待新任务。

到此,当前代码全部执行完成。

结束语#

技术更迭日新月异,希望本文能对你有所帮助。

如果谬误,欢迎指正。

后记#

额外的讨论(2024.06.20 补充)#

下面是一段是未经实际验证的问答(符合我的理解和预期):

笔者提问:

关于宏任务与微任务的执行顺序,现在有个场景需要明确。当浏览器 API 执行 io 任务完成后,生成了一个新的微任务,假设为任务 3,此时调用栈中在不断产生新的微任务,假设为任务 1、2、4、5。这五个微任务产生的时间依次为:1、2、3、4、5;那么请问,实际微任务的执行顺序是 1、2、4、5、3 还是 1、2、3、4、5 ?

chatGPT 3.5 答:

在 JavaScript 中,事件循环(Event Loop)处理宏任务(macro task)和微任务(micro task)时,遵循一定的顺序。以下是详细的解释:

  • 宏任务包括:setTimeout、setInterval、I/O 操作等。
  • 微任务包括:Promise 的 .then 回调、MutationObserver 等。

事件循环的执行顺序是:

  • 从宏任务队列中取出第一个宏任务执行。
  • 执行完当前宏任务后,处理所有的微任务队列,直到微任务队列为空。
  • 然后,再从宏任务队列中取出下一个宏任务执行。
  • 重复上述步骤。

在你描述的场景中:

  • 任务 1 和 任务 2 在 I/O 操作完成前产生。
  • 浏览器 API 执行 I/O 任务完成后生成任务 3(这是一个新的微任务)。
  • 随后,调用栈中又产生了任务 4 和 任务 5。

根据事件循环的机制和微任务的特性:

  • I/O 操作完成生成任务 3 后,该任务会立即进入微任务队列,并且会在下一个事件循环开始前的微任务队列中排队。

  • 因为任务 4 和 任务 5 是在任务 3 之后产生的,所以它们会排在任务 3 之后。 具体执行顺序如下:

  • I/O 操作完成后,任务 3 进入微任务队列。

  • 由于任务 1 和 任务 2 已经在微任务队列中,因此先执行任务 1 和 任务 2。

  • 接下来执行任务 3。

  • 然后执行任务 4 和 任务 5。

因此,微任务的执行顺序是 1、2、3、4、5。

总结来说,在这个场景下,微任务的执行顺序是 1、2、3、4、5。

回答完毕

验证思路大概如下(这个没有成功验证,还需要控制浏览器 API 将微任务推到微任务队列的时机):

const fs = require("node:fs");

function app() {
  fs.readFile("./test.txt", (err, file) => {
    Promise.resolve().then(() => {
      console.log("3");
    });
    if (err) {
      console.error(err);
    } else {
      console.log("read complete:", file.toString());
    }
  });

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

app();
JavaScript 事件循环深入
http://www.snofly.cn/posts/frontend-basic/event-loop/
Author
snofly
Published at
2023-02-21