解析 event loops

Liu Bowen

Liu Bowen / 2018, 三月, 08

事件循环定义

单线程的实现方式就是事件循环(event loop)。

存在两种 event loopsW3C),即一种在 browsing context 下的事件循环,一种是在 web workers 下的循环。本文讨论在 browsing context 下的事件循环。

依据标准中对进程模型的流程描述(来源)可得出,一个事件循环始终以一个宏任务(如有)开始,待 execution context stack 为空 时将执行 perform a microtask checkpoint,即执行 microtask queue 中的 microtasks。待 microtask queue 清空后,将进入渲染进程,此刻浏览器应该判断是否有必要进入 repaint 流程。经历渲染步骤之后,一个事件循环结束。

任务源

  • 宏任务(macrotask):

    1. script

      • 整体代码(来源),即代码执行的基准执行上下文(拓展阅读

      • 该宏任务的目的在于,将整体代码段(或理解为模块)推入执行上下文栈(execution context stack)中。

        • 执行上下文栈初始会设置 script当前正在运行执行上下文running execution context),这期间可能因执行而创建新的执行上下文,那么就会依据模块内的代码不断的设置 当前正在运行执行上下文running execution context),这样模块内的代码就会依次得以执行(此处主要是执行上下文Running execution context 的更替 的实际应用)。

        • 比如设置一些事件监听程序,一些声明,执行一些初始任务。在执行完成该任务时,会建立词法作用域等一系列相关运行参数。

    2. setTimeout,setInterval,setImmediate(服务端 API)

    3. I/O

      • 可拓展至 Web API(来源):

        1. DOM 操作

        2. 网络任务

          • Ajax 请求
        3. history traversal

          • history.back()
        4. 用户交互

          • 其中包括常见 DOM2(addEventListener)和 DOM0(onHandle)级事件监听回调函数。如 click 事件回调函数等。

          • 特别地,事件需要冒泡到 document 对象之后并且事件回调执行完成后,才算该宏任务执行完成。否则一直存在于执行上下文栈中,等待事件冒泡并事件回调完成(来源:Jake Archibald blog - level 1 boss fight)。

    • UI rendering
  • 微任务(microtask):

    1. process.nextTick(Node.js

    2. Promise 原型方法(即 thencatchfinally)中被调用的回调函数

    3. MutationObserver(DOM Standard

      • 用于监听节点是否发生变化
    4. Object.observe(已废弃)

  • 特别注明:在 ECMAScript 中称 microtaskjobs来源,其中 EnqueueJob 即指添加一个 microtask)。

macrotaskmicrotask 中的每一项都称之为一个 任务源

以上分类中,每一项执行时均占用当前正在运行执行上下文running execution context)(线程)。如,可理解为浏览器渲染线程与 JS 执行共用一个线程。

依据标准拓展

  • W3CWHATWG 中除非特别指明,否则 task 即是指 macrotask

  • 根据 W3C来源)关于 microtask 的描述,只有两种微任务类型:单独的回调函数微任务(solitary callback microtasks),复合微任务(compound microtasks)。那么即在 W3C 规范中所有单独的回调函数都是微任务类型。

    • solitary callback:Promise 原型的原型方法,即 thencatchfinally 能够调用单独的回调函数的方法。

    • compound microtask:

      1. MutationObserver(DOM Standard - 4.3.2 步骤 5

      2. process.nextTick(Only for Node.js

        • all callbacks passed to process.nextTick() will be resolved before the event loop continues.

  • 特别指明,Web API (event loops 章节在标准中是属于 Web API 大类)是属于宏任务类型,如 Ajax 属于 I/O(来源:using a resource),但 Ajax 调用的 Promise 类型回调函数都是微任务类型。

任务队列 task queue

任务队列分为 宏任务队列微任务队列。一个事件循环中可能有一个或多个任务队列。因为在执行一个宏任务时,可能产生微任务调用,即产生新的微任务队列。

相同类型的任务源的任务被调用时进入相同的任务队列,反之进入不同的任务队列。

标准(W3C and WHATWG)中的队列模型

  • 依据标准描述,除非特别指明是 microtask queue,那么我们一般常说的任务队列(task queue)都是指 宏任务队列macrotask queue)。

  • 每个事件循环都有一个 当前执行中的任务currently running task),用于轮询队列中的任务(handle reentrancy)。

  • 每个事件循环都有一个 已执行 microtask 检查点标志performing a microtask checkpoint flag)(初始值一定为 false)表示已经执行了 microtask 检查点,用于阻止执行 microtask checkpoint 算法的可重入调用。

    1. 可重入调用(reentrant invocation)是指,算法在执行过程中意外中断时,在当前调用未完成的情况下被再次从头开始执行。一旦可重入执行完成,上一次被中断的调用将会恢复执行。

    2. 设置该检查点的原因是:

      • 执行微任务时,可能会调用其他回调函数,当其他回调函数时,并在弹出执行上下文栈时,会断言当前执行上下文栈是否为空,若为空时,那么就会再一次执行 microtask checkpoint(来源:perform a microtask checkpoint - step 2.3clean up after running script),若没有设置检查点执行标志的话就会再次进入 microtask queue 重复执行 microtask

来源

  1. browsing context 事件循环的情况下(与第 8 步并列),选择当前 task queue最早加入的 task。如果没有任务被选中(即当前 task queue 为空),那么直接跳转到第 6 步 Microtasks

    • Ajax 请求返回数据时,若当前 task queue 为空时,将直接跳转执行回调函数微任务。
  2. 设置当前事件循环的 当前执行中的任务 为第 1 步被选出的 task。

  3. Run:执行当前被选出的 task(即 task 进入最上层执行上下文栈 execution context stack)。

  4. 重置当前事件循环的 当前执行中的任务 为默认值 null。

  5. 从当前的 task queue 中移除在第 3 步执行过的任务。

  6. Microtasks:执行 microtask 检查点。

    • 已执行 microtask 检查点标志 为 false 时:

      1. 设置 已执行 microtask 检查点标志 为 true。

      2. 操作(handling) microtask 队列:在当前 microtask queue 为空时,跳转到步骤 Done 之后。

      3. 选中 microtask queue 中最早加入的 microtask

      4. 设置当前事件循环的 当前执行中的任务 值为上一步选中的 microtask

      5. Run:执行选中的 microtask(进入最上层执行上下文栈(来源 1:HTML Standard EnqueueJob 7.6、来源 2:ECMAScript EnqueueJob 步骤 4))。

      6. 重置置当前事件循环的 当前执行中的任务 值为 null。

      7. microtask queue 中移除第 5 步 Run 被执行的 microtask,回到第 3 步 操作(handling) microtask 队列

        • 重点:为在一个事件循环中,总是要清空当前事件循环中的微任务队列才会进行重渲染Vue.js 的 DOM 更新原理)。
      8. Done:对于每一个 responsible event loop 是当前事件循环的环境设置对象(environment setting object),向它(环境设置对象)告知关于 rejected 状态的 Promise 对象的信息。

        • 个人理解为触发浏览器 uncaught 事件,并抛出 unhandled promise rejections 错误(W3C)。

        • 此步骤主要是向开发者告知存在未被捕获的 rejected 状态的 Promise

      9. 执行并清空 Indexed Database(用于本地存储数据的 API) 的修改请求。

      10. 重置 已执行 microtask 检查点标志 为 false。

    • 当一个复合微任务(compound microtask)执行时,客户端必须去执行一系列的复合微任务的子任务(subtask)

      1. 设置 parent 为当前事件循环的 当前执行中的任务

      2. 设置 子任务 为一个由一系列给定步骤组成的新 microtask。

      3. 设置 当前执行中的任务子任务。这种微任务的任务源是微任务类型的任务源。这是一个复合微任务的 子任务

      4. 执行 子任务(进入执行上下文栈)。

      5. 重置当前事件循环的 当前执行中的任务 为 parent。

  7. 更新 DOM 渲染。

    • 一个宏任务 task 至此整体执行结束(包含调用,执行,重渲染),也是一个事件循环结束
  8. (与第 1 步并列)如果当前的事件循环是 web works 的事件循环,并且在当前事件循环中的 task queue 为空,并且 WorkerGlobalScope 对象的 closing 为 true,那么将摧毁当前事件循环,并取消以上的事件循环步骤,并恢复执行一个 web worker 的步骤。

  9. 回到第 1 步执行下一个事件循环。

示例

以一个示例讲解事件循环:

// script
// 1
console.log('I am from script beginning')

// 2
setTimeout(() => {
  // 该匿名函数称为匿名函数a
  console.log('I am from setTimeout')
}, 1000)

// 3
const ins = new Promise((resolve, reject) => {
  console.log('I am from internal part')
  resolve()
})

// 4
ins
  .then(() => console.log('I am from 1st ins.then()'))
  .then(() => console.log('I am from 2nd ins.then()'))

// 5
console.log('I am from script bottom')

以上整个代码段即是,macro-task 中的 script 任务源。

执行原理(依据 Chrome 66 的 V8 实现)如下:

  1. 整个代码段 script 进入执行上下文栈(亦称调用栈,call stack来源)),执行 1 处代码调用 console.log 函数,该函数进入调用栈,之前 script 执行上下文执行暂停(冻结),转交执行权给 console.logconsole.log成为当前执行中的执行上下文running execution context)。console.log 执行完成立即弹出调用栈,script 恢复执行。

  2. setTimeout 是一个任务分发器,该函数本身会立即执行,延迟执行的是其中传入的参数(匿名函数 a)。script 暂停执行,内部建立一个 1 秒计时器。script 恢复执行接下来的代码。1 秒后,再将匿名函数 a 插入宏任务队列(根据宏任务队列是否有之前加入的宏任务,可能不会立即执行)。

  3. 声明恒定变量 ins,并初始化为 Promise 实例。特别地,Promise 内部代码会在本轮事件循环立即执行。那么此时, script 冻结,开始执行 console.logconsole.log 弹出调用栈后,resolve() 进入调用栈,将 Promise 状态 resolved,并之后弹出调用栈,此时恢复 script 执行。

  4. 因为第 3 步,已经在本轮宏任务完成前 resolved ,否则,将跳过第 4 步向本轮事件循环的微任务队列添加回调函数(来源)。调用 insthen 方法,将第一个 then 中回调添加到 微任务队列,继续执行,将第二个 then 中回调添加到 微任务队列

  5. 如同 1 时的执行原理。

  6. script 宏任务执行完成,弹出执行上下文栈。此时,微任务队列中有两个 then 加入的回调函数等待执行。另外,若距 2 超过 1 秒钟,那么宏任务队列中有一个匿名函数 a 等待执行,否则,此时宏任务队列为空。

  7. 在当前宏任务执行完成并弹出调用栈后,开始清空因宏任务执行而产生的微任务队列。首先执行 console.log('I am from 1st ins.then()'),之后执行 console.log('I am from 2nd ins.then()')

  8. 微任务队列清空后,开始调用下一宏任务(即进入下一个事件循环)或等待下一宏任务加入任务队列。此时,在 2 中计时 1 秒后,加入匿名函数 a 至宏任务队列,此时,因之前宏任务 script 执行完成而清空,那么将匿名函数 a 加入调用栈执行,输出 I am from setTimeout

JavaScript 中在某一函数内部调用另一函数时,会暂停(冻结)当前函数的执行,并将当前函数的执行权转移给新的被调用的函数(具体解析见拓展阅读)。

示例总结:

  1. 在一个代码段(或理解为一个模块)中,所有的代码都是基于一个 script 宏任务进行的。

  2. 在当前宏任务执行完成后,必须要清空因执行宏任务而产生的微任务队列

  3. 只有当前微任务队列清空后,才会调用下一个宏任务队列中的任务。即进入下一个事件循环。

  4. new Promise 时,Promise 参数中的匿名函数是立即执行的。被添加进微任务队列的是 then 中的回调函数。

    • 特别地,只有 Promise 中的状态为 resolvedrejected 后(Promise 标准),才会调用 Promise 的原型方法(即 thencatch(因为是 then语法糖,所以与 then 同理)、finallyonfinally触发)),才会将回调函数到添加微任务队列中。
  5. setTimeout 是作为任务分发器的存在,他自身执行会创建一个计时器,只有待计时器结束后,才会将 setTimeout 中的第一参数函数添加至宏任务队列。换一种方式理解,setTimeout 中的函数一定不是在当前事件循环中被调用。

以下是在客户端(Node.js 可能有不同结果)的输入结果:

I am from script beginning
I am from internal part
I am from script bottom
I am from 1st ins.then()
I am from 2nd ins.then()
I am from setTimeout

事件循环拓展应用 —— 异步操作

  1. 定时任务:setTimeout,setInterval

  2. 请求数据:Ajax 请求,图片加载

  3. 事件绑定

一般地,在 JS 开发过程中,凡是可能造成代码阻塞的地方都可根据实际情况考虑使用异步操作。比如,数据获取等等。

参考

JavaScript 语言精粹(修订版)

w3c Event loop

HTML5 Standard

ECMA Jobs and Job Queues

Tasks, microtasks, queues and schedules

Great talk at JSConf 2014 on the event loop