Javascript的细节(一): Task,Microtask和其他

发表于更新于阅读时长 14 分钟

关于 JS 异步的一切

严格上说来这并非 JavaScript 相关而是宿主环境的内容

0. 前言

众所周知, JavaSript 是单线程非阻塞的语言, 这样的设计或许是因为Brendan Eich实现时偷懒, 但也是 JS 能在服务器开发中占据一席之地的原因. 为了实现非阻塞, JS 的做法是使用事件循环. 本文将对它进行重点讨论.

本文充满了无聊的标准和实现细节.

1. 事件循环

事件循环并非 JS 运行时所必须, 因此相关标准并非由TC39 而是由 WHATWG所规定. 接下来让我们读一读标准.(英文原文在前面的链接里)

事件循环的目的是为了协调事件, 用户交互, 脚本, 渲染, 网络请求等等. 事件循环分两种: 窗口事件循环(浏览器可以让同源的窗口共用一个事件循环)和 Worker 循环. 一个事件循环有一个或多个任务队列, 因此可以通过一个单独的高优先级队列处理用户交互. 而任务则包括以下几项: 事件, 代码解析, 回调, 非阻塞的网络请求, 响应 DOM 操作等等. 每一个任务都有其来源, 同源的任务必须被加入同一个任务队列中.

只要事件循环存在, 它就必须持续不断地按照以下步骤执行(跳过和计时相关的部分):

  1. 从任务队列中取那个队列中最老的任务. 如果没有, 跳转到 6
  2. 汇报用户代理没有执行这个事件循环的时间
  3. 把这个事件循环的当前任务设置成这个任务
  4. 运行这个任务
  5. 把这个事件循环的当前任务值置空
  6. 把这个任务从它的任务队列中移除
  7. 执行微任务检查点
  8. 把当前的高精度时间赋给now
  9. 汇报任务的持续时间
  10. 如果是窗口事件循环, 更新渲染
  11. 检查空闲任务
  12. 汇报更新渲染的时间
  13. 如果是 Worker 循环且该 Worker 不是SharedWokrer, 执行requestAnimationFrame注册的回调(可能在OffscreenCanvas中用到)
  14. 把事件循环结束时间设为当前高精度时间

每一个事件循环都有一个微任务队列(因此至少有两个队列). 微任务有两种: 单独回调微任务(solitary callback microtask)和混合微任务(compound microtask). 执行微任务检查点的流程如下:

  1. 把微任务检查点标志设为 true
  2. 当微任务队列不为空时
    1. 从事件循环的微任务队列中找到最老的微任务
    2. 把任务循环当前执行的任务设为该微任务
    3. 执行该微任务
    4. 把任务循环当前执行的任务设回 null
    5. 从微任务队列中移除它
  3. 向全局环境通报被拒绝的 Promise
  4. 清空 IndexedDB transactions
  5. 把微任务检查点标志设回 false

通俗地说, 事件循环每次从任务队列中选一个任务执行, 等它完成后依次执行所有的微任务, 等它们都完成后再执行下一个任务.

除了上文提到的以外, 另一个微任务检查点是执行完一个 script 时, 如标准8.1.3.4节所述, 尽管旧版本的 Firefox 并没有正确地实现这一点, Mozilla 甚至做了一个PPT来说明这一点.

常见的任务来源有如下几种: DOM 操作, 用户交互, 网络和 操作 hisorty API.

2. 定时器

定时器均由任务完成, 实现里常常有一个单独的线程来安排定时器任务, 以避免主线程阻塞, 然而该任务的执行仍在主线程之中.

2.1 setTimeout 和 setInterval

这两个都是非常简单的函数, 调用方式都是func(callback, time). 但是它们的设计中有不少旧时代的糟粕: 允许用 code string 当作函数, 允许给回调函数传参数. 另一个显著的设计缺陷则是返回值, 这个返回计时器编号的设计充满了 80 年代的气息. 它们都有对应的函数 clearTimeout 和 clearInterval 来取消定时器, 虽然这两个函数实际上没有区别, setTimeout 产生的定时器可以用 clearInterval 来取消, 反之亦然.

我们可以轻易地用这对函数中的一个来模拟另一个. 只要在 setnterval 第一次调用回调时 clearInterval 即是 setTimeout, 反之在 setTimeout 末尾递归调用 setTimeout 则是 setInterval. 注意后一种做法并不能真正做到完美模范 setInterval, 因为 setInterval 的调用间隔会尽量是 time, 而递归调用 setTimeout 的间隔实际上是 callback 执行时间加上 time. 当你的回调可能执行很长时间时, 可以考虑用这种方法代替 setInterval.

显而易见地, 由于 JS 的单线程本质, 我们没有任何方法保证回调函数一定在指定的时刻执行. 当然, 浏览器仍然会尽量保证回调尽快执行. 因此, 如果有东西阻塞了主线程, 那么回调就会在这个阻塞结束时执行. 一个有趣的特性可以在下面的代码中看出来

const start = performance.now()
function block(ms) {
  const start = performance.now()
  while (performance.now() - ms < start) {}
}

let times = 0
const id = setInterval(() => {
  console.log(performance.now() - start)
  times++
  if (times === 5) clearInterval(id)
}, 200)
block(1000)

可以看到, 浏览器并不会在 block 结束后执行五次回调, 而只会执行一次. 这是因为 setInterval 会在前一个回调完成之后才加上另一个回调. 标准里在 timer initialization steps 的第 15 步阐明了这一点.

另一个有趣的现象则可以通过把之前的 block 时间从 1000 改成 950(或是任何非 200 倍数) 而观察到. 在 Firefox 和 Safari 中, console 会输出950 1150 1350 1550 1750, 而在 Chrome 中会输出950 1000 1200 1400 1600. 换言之, 除了 Chrome 以外的浏览器在前一次回调完成后继续调用定时器并安排后续回调, 因此能尽量保证回调之间的间隔等于 timeout 参数; Chrome 的策略则是在每隔 timeout 时间检查一下之前的回调是否完成, 因为这样能保证和初始时间的间隔是 timeout 的整倍数.

一个常见的问题是定时器的最小延迟是多少. 很常见的误解是 4ms, 然而实际情况并非如此. 我们再看标准:

  1. If timeout is less than 0, then set timeout to 0.
  2. If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.

显然最小延迟需要大于 0, 但仅当嵌套次数大于 5 时才要求最小延迟为 4ms. 无论是 setInterval 调用自己或是递归调用 setTimeout 都会被算入嵌套层数. 因此以下代码

const start = performance.now()

let times = 0
const id = setInterval(() => {
  console.log(performance.now() - start)
  times++
  if (times === 5) clearInterval(id)
}, 1)

会输出1 2 3 4 8.

标准上也给实现留下了空间

  1. Optionally, wait a further user-agent defined length of time.

因此理论上永远不执行回调函数同样符合标准. 在实践上, 这一条常常被用于减少不活跃 tab 的 JS 执行以节省电量, 或是减少追踪脚本(比如 Google Analytics)的执行.

在 Chrome 中还有另一个小问题: timeout 的最小值是 1ms. 虽然不知道这样做的目的在哪里, 但这会使得如下代码

setTimeout(() => console.log(2), 2)
setTimeout(() => console.log(1), 1)
setTimeout(() => console.log(0), 0)

在 Chrome 中输出1 0 2, 而在别的浏览器中输出0 1 2

2.2 setImmediate

如前所述, setTimeout 有非常多的限制, 因此在需要"把这个函数插入任务队列的最前面"这一语义时, 可以使用 setImmediate. 然而非常奇妙的是这个函数仅在微软出品的浏览器里可以使用, 并且没有进入任何标准. 此时可以使用 postMessage 或是 MessageChannel 来完成类似的目的.

Node 中也实现了这个函数, 不过和浏览器中的语义并不相同.

3. 微任务相关 API

微任务是相对较新的 API, 诸如 IE 等老旧浏览器中并没有实现.

3.1 Promise 及周边

Promise 是 ES6 带来的美妙新特性之一, 可以认为它是一个半残的 Monad(因为 then 混合了 flat/unwarp 和 map 语义). 这是个比较新的 API, 因此设计上比起之前提到的那些要好不少. 它常常被用于包裹副作用以进行 I/O, 把 JS 从回调地狱的恶名中拯救了出来. 标准中并没有指定 Promise 的内部机制, 而是说:

This can be implemented with either a “macro-task” mechanism such as ..., or with a “micro-task” mechanism such as ...

但当前所有实现了 Promise 的浏览器都使用微任务, 更确切地说是前文提到的 solitary callback microtask.

而 ES7 带来的新美妙特性则是 async 和 await, 虽然它们在执行时实际上是调用了 Promise, 但它们可以让程序员像阻塞式代码一样按照执行顺序写非阻塞代码.事实上

async function Asy() {
  await p
  console.log('ok')
}
async function Prom() {
  return Prmoise.resolve(p).then(() => console.log('ok'))
}

这两个函数在最新版的浏览器中等价.Promise.resolve是这样一种函数: 如果传入的是 Promise, 那么直接返回; 如果传入的是 thenable, 那在 thenable resolve 时产生的 Promise resolve; 否则返回一个立即 resolve 的 Promise. 然而在旧版(73 之前)的 Chrome 中, 这两者并不等价. 考虑如下代码

async function async1() {
  await async2()
  console.log('async1')
}
async function async2() {
  console.log('async2')
}
async1()

new Promise(resolve => resolve())
  .then(() => console.log('promise1'))
  .then(() => console.log('promise2'))
  .then(() => console.log('promise3'))

会在旧版 Chrome 中产生async2 promise1 promise2 async1 promise3的输出结果, 而非我们预料中的先输出 async1. 这是因为在旧版 Chrome 中 await 并非使用Promise.resolve而是直接生成了新的 Prmoise 的缘故.

显而易见地, 如果想要向微任务队列中插入一个微任务, 可以使用Promise.resolve, 或是刚在 Chrome 73 中得到实现的 queueMicroask

3.2 MutationObserver

MutationObserver 也是新加入的 API, 是为了取代旧的 Mutation Event 而设计的, 尽管这两者的设计都很奇怪. 在旧版的标准中, 这个 API 使用的是 compound microtask, 而在新版标准中, 描述已经被改成了"queue a microtask".(顺带一提, 这使得整个标准中除了对 compound microtask 的定义以外没有其他相关内容)

这个 API 的使用方式比较奇特. 创建时传入回调函数, 通过 observe 方法观察 DOM 节点的变化(注意第二个参数即选项虽然写着可选, 但实际上不传入会报错), 取消观察时则使用 disconnect 方法. 如果要同步地取出记录, 则可以使用 takeRecords 方法. 虽然很少有人使用, 但这个方法可以用来观察节点的属性, 子节点的变化.

MutationObserver 的执行优先级和 Prmoise 完全相同, 只看触发时间决定先后.

4. 事件

浏览器用事件来处理用户交互, 也可以被用来在代码完成特定目的.

4.1 普通事件

显而易见地, 浏览器无法知道用户触发事件的时机, 因此除了异步地处理之外别无它法. 但这并不意味着分发事件是异步地. 在W3C 标准中, 清晰地说明了:

Events may be dispatched either synchronously or asynchronously 实际上, 除了少数如 onload, onerror 事件之外, 大多数都是同步分发. 不仅仅直接调用某个节点的事件是同步地, 事件冒泡和事件捕获都是同步地. 当然, 处理用户交互, 如前所述是通过任务来完成的.

前文中也提到过, 浏览器可以为用户交互实现单独的队列使得交互获得更快的响应. 目前似乎只有 Chrome 实现了这样的队列, 而其他浏览器则仅仅是优先执行一次用户交互的响应. 举个例子, 对如下的元素

加上事件回调

function sleep(time) {
  const start = performance.now()
  while (performance.now() - time < start) {}
}
let delay = true
const userInput = document.getElementById('test-user-input')
userInput.addEventListener('click', () => {
  console.log('clicked')
  if (delay) {
    delay = false
    setTimeout(() => console.log('timeout'))
    setTimeout(() => (delay = true), 1500)
    sleep(1000)
  }
})

如果快速点击的话, 仅在 Chrome 浏览器中会先输出所有的"clicked"在输出"timeout", 在其他浏览器中则是输出两次"clicked"就会输出"timeout"

事件循环第十步更新渲染中会触发一些事件, 具体说来这一步可以分解为如下几个步骤:

  1. 把和本事件循环相关的 Document 赋给docs
  2. 如果有浏览上下文没有渲染机会, 那么从docs移除相关的所有 Document. 有渲染机会指的是一个浏览上下文能被呈现给用户, 并且满足硬件和用户定义的刷新率限制.
  3. 如果有浏览上下文用户代理相信它的渲染不会造成视觉上的差别, 且它不包含注册了 requestAnimationFrame 回调的 Document, 那么把相关的 Document 从docs中移除
  4. 如果有浏览上下文用户代理认为跳过它是更好的, 那么从docs移除相关的所有 Document
  5. docs中的所有 Document, 执行 resize 步骤, 传入now(事件循环步骤 2 中那个)作为事件戳
  6. docs中的所有 Document, 执行 scroll 步骤, 传入now作为事件戳
  7. docs中的所有 Document, 对 media query 求值并汇报变化, 传入now作为事件戳
  8. docs中的所有 Document, 更新动画, 发送相关事件, 传入now作为事件戳
  9. docs中的所有 Document, 执行 fullscreen 步骤, 传入now作为事件戳
  10. docs中的所有 Document, 执行 animationFrame 回调, 传入now作为事件戳
  11. docs中的所有 Document, 执行更新 IntersectionObserver 步骤, 传入now作为事件戳
  12. docs中的所有 Document, 调用记录渲染时间渲染算法
  13. docs中的所有 Document, 更新渲染以反映状态变化

下图可以清晰地表达这一流程

timeline

可以看到和渲染相关的事件触发事件

4.2 requestAnimationFrame

上面的步骤中最常用的 API 是 requestAnimationFrame. 它的作用是注册一些回调, 它们会在浏览器开始渲染之前执行. 曾经对于 rAF 注册的回调是应该在当前帧还是下一帧执行有一些争议, 但似乎今天这个问题已经有了一致的答案: 在当前帧执行. 如果想要回调在下一帧执行, 可以在 rAF 内再调用一次 rAF.

在同一帧内通过 rAF 注册的回调会依次执行, 并且收到同一个时间戳作为参数, 它就是在事件循环中第二步提到的now. 所以回调收到的参数是当前任务开始处理的时间, 因此有可能会比回调函数实际执行的时间要早一些. 另外此处 Chrome 会传入帧开始时间作为参数, 因此可能会比回调函数执行早很多.

4.3 requestdleCallback

这是另一个常用 API, 它和 rAF 一起构成了旧版react fiber的核心算法. 它在事件循环的第十一步中执行, 确切说来仅当:

  1. 这是窗口事件循环
  2. 事件循环的各任务队列中没有和 Document 相联系的还在活跃
  3. 事件循环的微任务队列是空的
  4. 没有浏览上下文有渲染机会

时才会执行. 换言之, 仅当浏览器空闲时才会执行回调. 可以传入第二个参数, 使得回调在超过 timeout 毫秒还未被调用时得到调用. 一般来说 rIC 是先注册先调用, 但也有可能因为 timeout 参数而打乱调用顺序.

5. 例子

相关例子可以参考 Jake Archibald 的文章. 篇幅所限, 不再赘述.

6. one more thing

上文所述的事件循环均在浏览器环境中, 那么在服务端 JS 的事实标准 node.js 中, 事件循环又是怎么样的呢?

Node 官网上就有很好的文章讲述了 Node 中的事件循环, 可以用一张图说明

node

其中:

  • timers: 执行到期的 setTimeout 和 setInterval
  • pending: 执行被推迟到下一个周期的 I/O 回调
  • poll: I/O 回调主要在这里执行
  • check: setImmediate 在这里执行

而 process.nextTick 则是一个特殊的 API, 它会把回调注册进一个特殊的微任务队列, 这个队列里的微任务比其他微任务有更高的优先级.

其他部分则与浏览器较为相似, 尽管以前版本有所区别, 当代 Node.js 也会像浏览器那样, 执行一个任务, 完成之后再依次执行所有微任务, 都完成之后再执行下一个任务.

© 2016 - 2022Austaras Devas