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

原文发自个人 GitHub blog,欢迎关注javascript

前言

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

程序中如今运行的部分和未来运行的部分之间的关系就是异步编程的核心。前端

咱们先看一个面试题:vue

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

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

image

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

若是我换一个例子git

console.log("A");

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

console.log("C");
复制代码

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

调用栈

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

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

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

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

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

JavaScript 引擎自己并无时间的概念,只是一个按需执行 JavaScript 任意代码片断的环境。“事件”( JavaScript 代码执行)调度老是由包含它的环境进行。

另外,从一个侧面能够反应出执行上下文栈与异步无关的 —— 执行上下文栈是写在 ECMA-262 的规范中,须要遵照它的是浏览器的 JavaScript 引擎,好比 V八、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。

参考

相关文章
相关标签/搜索