跟着 Event loop 规范理解浏览器中的异步机制

3,221 阅读17分钟

原文发自我的 GitHub blog,欢迎关注

前言

我们都知道 JavaScript 是一门单线程语言,这意味着同一事件只能执行一个任务,结束了才能去执行下一个。如果前面的任务没有执行完,后面的任务就会一直等待。试想,有一个耗时很长的网络请求,如果所有任务都需要等待这个请求完成才能继续,显然是不合理的并且我们在浏览器中也没有体验过这种情况(除非你要同步请求 Ajax),究其原因,是 JavaScript 借助异步机制来实现了任务的调度。

程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。

我们先看一个面试题:

try {
  setTimeout(() => {
    throw new Error("Error - from try statement");
  }, 0);
} catch (e) {
  console.error(e);
}

上面这个例子会输出什么?答案是:

image

说明并没有 catch 到丢出来的 error,这个例子可能理解起来费劲一点。

如果我换一个例子

console.log("A");

setTimeout(() => {
  console.log("B");
}, 100);

console.log("C");

稍微了解一点浏览器中异步机制的同学都能答出会输出 “A C B”,本文会通过分析 event loop 来对浏览器中的异步进行梳理,并搞清上面的问题。

调用栈

函数调用栈其实就是执行上下文栈(Execution Context Stack),每当调用一个函数时就会产生一个新的执行上下文,同时新产生的这个执行上下文就会被压入执行上下文栈中。

全局上下文最先入栈,并且在离开页面时开会出栈,JavaScript 引擎不断的执行上下文栈中栈顶的那个执行上下文,在它执行完毕后将它出栈,直到整个执行栈为空。关于执行栈有五点比较关键:

  1. 单线程(这是由 JavaScript 引擎决定的)。
  2. 同步执行(它会一直同步执行栈顶的函数)。
  3. 只有一个全局上下文。
  4. 可有无数个函数上下文(理论是函数上下文没有限制,但是太多了会爆栈)。
  5. 每个函数调用都会创建一个新的 执行上下文,哪怕是递归调用。

这里首先要明确一个问题,函数上下文执行栈是与 JavaScript 引擎(Engine)相关的概念,而异步/回调是与运行环境(Runtime)相关的概念。

如果执行栈与异步机制完全无关,我们写了无数遍的点击触发回调是如何做到的呢?是运行环境(浏览器/Node)来完成的, 在浏览器中,异步机制是借助 event loop 来实现的,event loop 是异步的一种实现机制。JavaScript 引擎只是“傻傻”的一直执行栈顶的函数,而运行环境负责管理在什么时候压入执行上下文栈什么函数来让引擎执行。

JavaScript 引擎本身并没有时间的概念,只是一个按需执行 JavaScript 任意代码片段的环境。“事件”( JavaScript 代码执行)调度总是由包含它的环境进行。

另外,从一个侧面可以反应出执行上下文栈与异步无关的 —— 执行上下文栈是写在 ECMA-262 的规范中,需要遵守它的是浏览器的 JavaScript 引擎,比如 V8、Quantum 等。event loop 的是写在 HTML 的规范中,需要遵守它的是各个浏览器,比如 Chrome、Firefox 等。

event loop

定义

我们通过 HTML5规范 的定义来看 event loop 的定义来看模型,本章节所有引用的部分都是翻译自规范。

为了协调时间,用户交互,脚本,界面渲染,网络等等,用户代理必须使用下一节描述的 event loops。event loops 分为两种:浏览器环境及为 Web Worker 服务的。

本文只关注浏览器部分,所以忽略 Web Worker。JavaScript 引擎并不是独立运行的,它需要运行在宿主环境中, 所以其实用户代理(user agent)在这个情境下更好的翻译应该是运行环境或者宿主环境,也就是浏览器。

每个用户代理必须至少有一个 browsing context event loop,但每个 unit of related similar-origin browsing contexts 最多只能有一个。

关于 unit of related similar-origin browsing contexts,节选一部分规范的介绍:

Each unit of related browsing contexts is then further divided into the smallest number of groups such that every member of each group has an active document with an origin that, through appropriate manipulation of the document.domain attribute, could be made to be same origin-domain with other members of the group, but could not be made the same as members of any other group. Each such group is a unit of related similar-origin browsing contexts.

简而言之就是一个浏览器环境(unit of related similar-origin browsing contexts.),只能有一个事件循环(event loop)。

event loop 又是干什么的呢?

每个 event loop 都有一个或多个 task queues. 一个 task queue 是 tasks 的有序的列表, 是用来响应如下如下工作的算法:

  • 事件

    EventTarget 触发的时候发布一个事件 Event 对象,这通常由一个专属的 task 完成。

    注意:并不是所有的事件都从是 task queue 中发布,也有很多是来自其他的 tasks。

  • 解析

    HTML 解析器 令牌化然后产生 token 的过程,是一个典型的 task。

  • 回调函数

    一般使用一个特定的 task 来调用一个回调函数。

  • 使用资源(译者注:其实就是网络)

    当算法 获取 到了资源,如果获取资源的过程是非阻塞的,那么一旦获取了部分或者全部的内容将由 task 来执行这个过程。

  • 响应 DOM 的操作

    有一些元素会对 DOM 的操作产生 task,比如当元素被 插入到 document 时

可以看到,一个页面只有一个 event loop,但是一个 event loop 可以有多个 task queues。

每个来自相同 task source 并由相同 event loop(比如,Document 的计时器产生的回调函数,Document 的鼠标移动产生的事件,Document 的解析器产生的 tasks) 管理的 task 都必须加入到同一个 task queue 中,可是来自不同 task sourcestasks 可能会被排入到不同的 task queues 中。

来自相同的 task source 的 task 将会被排入相同的 task queue,但是规范说来自不同 task sourcestasks 可能会被排入到不同的 task queues 中,也就是说一个 task queue 中可能排列着来自不同 task sources 的 tasks,但是具体什么 task source 对应什么 task queue,规范并没有具体说明。

但是规范对 task source 进行了分类:

如下 task sources 被大量应用于本规范或其他规范无关的特性中:

一般我们看个各个文章中对于 task queue 的描述都是只有一个,不论是网络,用户时间内还是计时器都会被 Web APIs 排入到用一个 task queue 中,但事实上规范中明确表示了是有多个 task queues,并举例说明了这样设计的意义:

举例来说,一个用户代理可以有一个处理键盘鼠标事件的 task queue(来自 user interaction task source),还有一个 task queue 来处理所有其他的。用户代理可以以 75% 的几率先处理鼠标和键盘的事件,这样既不会彻底不执行其他 task queues 的前提下保证用户界面的响应, 而且不会让来自同一个 task source 的事件顺序错乱。

接着看。

用户代理将要排入任务时,必须将任务排入相关的 event looptask queues

这句话很关键,是用户代理(宿主环境/运行环境/浏览器)来控制任务的调度,这里就引出了下一章的 Web APIs。

接下来我么来看看 event loop 是如何执行 task 的。

处理模型

我们可以形象的理解 event loop 为如下形式的存在:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

event loop 会在整个页面存在时不停的将 task queues 中的函数拿出来执行,具体的规则如下:

一个 event loop 在它存在的必须不断的重复一下的步骤:

  1. 从 task queues 中取出 event loop 的最先添加的 task,如果没有可以选择的 task,那么跳到第 Microtasks 步。
  2. 设定 event loop 当前执行的 task 为上一步中选择的 task。
  3. 执行:执行选中的 task。
  4. 将 event loop 的当前执行 task 设为 null。
  5. 从 task queue 中将刚刚执行的 task 移除。
  6. Microtasks执行 microtask 检查点的任务
  7. 更新渲染,如果是浏览器环境中的 event loop(相对来说就是 Worker 中的 event loop)那么执行以下步骤:
  8. 如果是 Worker 环境中的 event loop(例如,在 WorkerGlobalScope 中运行),可是在 event loop 的 task queues 中没有 tasks 并且 WorkerGlobalScope 对象为关闭的标志,那么销毁 event loop,终止这些步骤的执行,恢复到 run a worker 的步骤。
  9. 回到第 1 步。

microtask

规范引出了 microtask,

每个 event loop 都有一个 microtask queue。microtask 是一种要排入 microtask queue 的而不是 task queue 的任务。有两种 microtasks:solitary callback microtasks 和 compound microtasks。

规范只介绍了 solitary callback microtasks,compound microtasks 可以先忽略掉。

当一个 microtask 要被排入的时候,它必须被排如相关 event loopmicrotask queuemicrotasktask source 是 microtask task source.

microtasks 检查点

当用户代理执行到了 microtasks 检查点的时候,如果 performing a microtask checkpoint flag 为 false,则用户代理必须运行下面的步骤:

  1. performing a microtask checkpoint flag 置为 true。

  2. 处理 microtask queue:如果 event loop 的 microtask queue 是空的,直接跳到 Done 步。

  3. 选择 event loop 的 microtask queue 中最老的 microtask。

  4. 设定 event loop 当前执行的 task 为上一步中选择的 task。

  5. 执行:执行选中的 task。

注意:这有可能包含执行含有 clean up after running script 步骤的脚本,然后会导致再次 执行 microtask 检查点的任务,这就是我们要使用 performing a microtask checkpoint flag 的原因。

  1. 将 event loop 的当前执行 task 设为 null。

  2. 将上一步中执行的 microtask 从 microtask queue 中移除,然后返回 处理 microtask queue 步骤。

  3. 完成: 对每一个 responsible event loop 就是当前的 event loop 的 environment settings object,给 environment settings object 发一个 rejected promises 的通知。

  4. 清理 IndexedDB 的事务

  5. performing a microtask checkpoint flag 设为 false。

整个流程如下图:

task & microTask

task

task 主要包含:

  • script(整体代码)
  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

microtask

microtask 主要包含:

  • process.nextTick(Node.js 环境)
  • Promises(这里指浏览器实现的原生 Promise)
  • Object.observe(已被 MutationObserver 替代)
  • MutationObserver
  • postMessage

Web APIs

在上一章讲讲到了用户代理(宿主环境/运行环境/浏览器)来控制任务的调度,task queues 只是一个队列,它并不知道什么时候有新的任务推入,也不知道什么时候任务出队。event loop 会根据规则不断将任务出队,那谁来将任务入队呢?答案是 Web APIs。

我们都知道 JavaScript 的执行是单线程的,但是浏览器并不是单线程的,Web APIs 就是一些额外的线程,它们通常由 C++ 来实现,用来处理非同步事件比如 DOM 事件,http 请求,setTimeout 等。他们是浏览器实现并发的入口,对于 Node.JavaScript 来说,就是一些 C++ 的 APIs。

WebAPIs 本身并不能直接将回调函数放在函数调用栈中来执行,否则它会随机在整个程序的运行过程中出现。每个 WebAPIs 会在其执行完毕的时候将回调函数推入到对应的任务队列中,然后由 event loop 按照规则在函数调用栈为空的时候将回调函数推入执行栈中执行。event loop 的基本作用就是检查函数调用栈和任务队列,并在函数调用栈为空时将任务队列中的的第一个任务推入执行栈中,每一个任务都在下一个任务执行前执行完毕。

WebAPIs 提供了多线程来执行异步函数,在回调发生的时候,它们会将回调函数和推入任务队列中并传递返回值。

流程

至此,我们已经了解了执行上下文栈,event loop 及 WebAPIs,它们的关系可以用下图来表示(图片来自网络,原始出处已无法考证),一轮 event loop 的文字版流程如下:

首先执行一个 task,如果整个第一轮 event loop,那么整体的 script 就是一个 task,同步执行的代码会直接放进 call stack(调用栈)中,诸如 setTimeout、fetch、ajax 或者事件的回调函数会由 Web APIs 进行管理,然后 call stack 继续执行栈顶的函数。当网络请求获取到了响应或者 timer 的时间到了,Web APIs 就会将对应的回调函数推入对应的 task queues 中。event loop 不断执行,一旦 event loop 中的 current task 为 null,它就回去扫 task queues 有没有 task,然后按照一定规则拿出 task queues 中一个最早入队的回调函数(比如上面提到的以 75% 的几率优先执行鼠标键盘的回调函数所在的队列,但是具体规则我还没找到),取出的回调函数放入上下文执行栈就开始同步执行了,执行完之后检查 event loop 中的 microtask queue 中的 microtask,按照规则将它们全部同步执行掉,最后完成 UI 的重渲染,然后再执行下一轮的 event loop...

68747470733a2f2f63646e2d696d616765732d312e6d656469756d2e636f6d2f6d61782f313630302f312a2d4d4d42484b795f5a7843726f7565635271767342672e706e67

应用

setTimeout 的不准确性

JavaScript 引擎并不是独立运行的,它运行在宿主环境中

了解了上面 Web APIs,我们知道浏览器中有一个 Timers 的 Web API 用来管理 setTimeout 和 setInterval 等计时器,在同步执行了 setTimeout 后,浏览器并没有把你的回调函数挂在事件循环队列中。 它所做的是设定一个定时器。 当定时器到时后, 浏览器会把你的回调函数放在事件循环中, 这样, 在未来某个时刻的 tick 会摘下并执行这个回调。

但是如果定时器的任务队列中已经被添加了其他的任务,后面的回调就要等待。

let t1, t2

t1 = new Date().getTime();

// 1
setTimeout(()=>{
    let i = 0;
    while (i < 50000000) {i++}
    console.log('block finished')
}
, 300)

// 2
setTimeout(()=>{
    t2 = new Date().getTime();
    console.log(t2 - t1)
}
, 300)

这个例子中,打印出来的时间戳就不会等于 300,虽然两个 setTimeout 的函数都会在时间到了时被 Web API 排入任务队列,然后 event loop 取出第一个 setTimeout 的回调开始执行,但是这个回调函数会同步阻塞一段时间,导致只有它执行完毕 event loop 才能执行第二个 setTimeout 的回调函数。

进入调用栈的时机

例1

try {
  setTimeout(() => {
    throw new Error("Error - from try statement");
  }, 0);
} catch (e) {
  console.error(e);
}

回到最开始的那个问题,整个过程是这样的:执行到 setTimeout 时先同步地将回调函数注册给 Web APIs 的 timer,要清楚此时 setTimeout 的回调函数此时根本没有入调用栈甚至连 task queue 都没有进入,所以 try 的这个代码块就执行结束了,没有抛出任何 error,catch 也被直接跳过,同步执行完毕。

等到 timer 的计时到了(要注意并不一定是下一个 event loop,因为 setTimeout 在每个浏览器中的最短时间是不确定的,在 Chrome 中执行几次也会发现每次时间都不同,0 ms ~ 2 ms 都有),会将 setTimeout 中的回调放入 task queue 中,此时 event loop 中的 current task 为 null,就将这个回调函数设为 current task 并开始同步执行,此时调用栈中只有一个全局上下文,try catch 已经结束了,就会直接将这个 error 丢出。

例2

for (var i = 0; i < 5; i++) {
  setTimeout((function(i) {
    console.log(i);
  })(i), i * 1000);
}

正确答案是立即输出 “0 1 2 3 4”,setTime 的第一个参数接受的是一个函数或者字符串,这里第一个参数是一个立即执行函数,返回值为 undefined,并且在立即执行的过程中就输出了 "0 1 2 3 4",timer 没有接收任何回调函数,就与 event loop 跟无关了。

例3

new Promise(resolve => {
    resolve(1);
    Promise.resolve().then(() => console.log(2));
    console.log(4)
}).then(t => console.log(t)); // a
console.log(3);

是阮老师推特上的一道题,首先 Promise 构造函数中的对象同步执行(不了解 Promise 的同学可以先看下 这篇文章),碰到 resolve(1),将当前 Promise 标记为 resolve,但是注意它 then 的回调函数还没有被注册,因为还没有执行到 a 处。继续执行又碰到一个 Promise,然后也立刻被 resolved 了,并且执行它的 then 注册,将第二个 then 的回调函数推入空的 microtaskQueue 中。继续执行输出一个 4,然后 a 处的 then 现在才开始注册,将第一个 Promise 的 then 回调函数推入 microtaskQueue 中。继续执行输出一个 3。现在 task queue 中的任务已经执行完毕,到了 microtask checkpoint flag,发现有两个 microtask,按照添加的顺序执行,第一个输出一个 2,第二个输出一个 1,最后再更新一下 UI 然后这一轮 event loop 就结束了,最终的输出是"4 3 2 1"

Vue

笔者本人并没有使用过 Vue,但是稍微知道一点 Vue 的 DOM 更新中有批量更新,缓冲在同一事件循环中的数据变化,即 DOM 只会被修改一次。

关于这点 顾轶灵 大佬在知乎上有过 回答

为啥要用 microtask?根据HTML Standard,在每个 task 运行完以后,UI 都会重渲染,那么在 microtask 中就完成数据更新,当前 task 结束就可以得到最新的 UI 了。反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。

在 event loop 那章的规范中明确的写到,在 event loop 的一轮中会按照 task -> microTask -> UI render 的顺序。用户的代码可能会多次修改数据,而这些修改中后面的修改可能会覆盖掉前面的修改,再加上 DOM 的操作是很昂贵的,一定要尽量减少,所以要将用户的修改 thunk 起来然后只修改一次 DOM,所以需要使用 microTask 在 UI 更新渲染前执行,就算有多次修改,也会只修改一次 DOM,然后进行渲染。

更新一下,现在 Vue 的 nextTick 实现移除了 MutationObserver 的方式(兼容性原因),取而代之的是使用 MessageChannel。

其实用什么具体的 API 不是最关键的,重要的是使用 microTask 在 在 UI render 前进行 thunk。

参考