Javascript的细节(一): Task,Microtask和其他
发表于更新于阅读时长 11 分钟关于 JS 异步的一切
严格上说来这并非 JavaScript 相关而是宿主环境的内容
0. 前言
众所周知,JavaScript 是单线程非阻塞的语言,这样的设计或许是因为 Brendan Eich 实现时偷懒,但也是 JS 能在服务器开发中占据一席之地的原因。为了实现非阻塞,JS 的做法是使用事件循环。本文将对它进行重点讨论。
本文充满了无聊的标准和实现细节。
1. 事件循环
事件循环并非 JS 运行时所必须,因此相关标准并非由 TC39 而是由 WHATWG 所规定。接下来让我们读一读标准。(英文原文在前面的链接里)
事件循环的目的是为了协调事件,用户交互,脚本,渲染,网络请求等等。事件循环分两种:窗口事件循环(浏览器可以让同源的窗口共用一个事件循环)和 Worker 循环。一个事件循环有一个或多个任务队列,因此可以通过一个单独的高优先级队列处理用户交互。而任务则包括以下几项:事件,代码解析,回调,非阻塞的网络请求,响应 DOM 操作等等。每一个任务都有其来源,同源的任务必须被加入同一个任务队列中。
只要事件循环存在,它就必须持续不断地按照以下步骤执行(跳过和计时相关的部分):
- 从任务队列中取那个队列中最老的任务。如果没有,跳转到 6
- 汇报用户代理没有执行这个事件循环的时间
- 把这个事件循环的当前任务设置成这个任务
- 运行这个任务
- 把这个事件循环的当前任务值置空
- 把这个任务从它的任务队列中移除
- 执行微任务检查点
- 把当前的高精度时间赋给now
- 汇报任务的持续时间
- 如果是窗口事件循环,更新渲染
- 检查空闲任务
- 汇报更新渲染的时间
- 如果是 Worker 循环且该 Worker 不是 SharedWorker,执行 requestAnimationFrame 注册的回调(可能在 OffscreenCanvas 中用到)
- 把事件循环结束时间设为当前高精度时间
每一个事件循环都有一个微任务队列(因此至少有两个队列)。微任务有两种: 单独回调微任务(solitary callback microtask)和混合微任务(compound microtask)。执行微任务检查点的流程如下:
-
把微任务检查点标志设为 true
-
当微任务队列不为空时
- 从事件循环的微任务队列中找到最老的微任务
- 把任务循环当前执行的任务设为该微任务
- 执行该微任务
- 把任务循环当前执行的任务设回 null
- 从微任务队列中移除它
-
向全局环境通报被拒绝的 Promise
-
清空 IndexedDB transactions
-
把微任务检查点标志设回 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 来取消,反之亦然。
我们可以轻易地用这对函数中的一个来模拟另一个。只要在 setInterval 第一次调用回调时 clearInterval 即是 setTimeout,反之在 setTimeout 末尾递归调用 setTimeout 则是 setInterval。注意后一种做法并不能真正做到完美模范 setInterval,因为 setInterval 的调用间隔会尽量是 time,而递归调用 setTimeout 的间隔实际上是 callback 执行时间加上 time。当你的回调可能执行很长时间时,可以考虑用这种方法代替 setInterval。
显而易见地,由于 JS 的单线程本质,我们没有任何方法保证回调函数一定在指定的时刻执行。当然,浏览器仍然会尽量保证回调尽快执行。因此,如果有东西阻塞了主线程,那么回调就会在这个阻塞结束时执行。一个有趣的特性可以在下面的代码中看出来
js
const start = performance.now()function block(ms) {const start = performance.now()while (performance.now() - ms < start) {}}let times = 0const 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,然而实际情况并非如此。我们再看标准:
- If timeout is less than 0, then set timeout to 0.
- If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
显然最小延迟需要大于 0,但仅当嵌套次数大于 5 时才要求最小延迟为 4ms。无论是 setInterval 调用自己或是递归调用 setTimeout 都会被算入嵌套层数。因此以下代码
js
const start = performance.now()let times = 0const id = setInterval(() => {console.log(performance.now() - start)times++if (times === 5) clearInterval(id)}, 1)
会输出 1 2 3 4 8.
标准上也给实现留下了空间
- Optionally, wait a further user-agent defined length of time.
因此理论上永远不执行回调函数同样符合标准。在实践上,这一条常常被用于减少不活跃 tab 的 JS 执行以节省电量,或是减少追踪脚本(比如 Google Analytics)的执行。
在 Chrome 中还有另一个小问题: timeout 的最小值是 1ms。虽然不知道这样做的目的在哪里,但这会使得如下代码
js
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,但它们可以让程序员像阻塞式代码一样按照执行顺序写非阻塞代码。事实上
js
async function Asy() {await pconsole.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 中,这两者并不等价。考虑如下代码
js
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 实现了这样的队列,而其他浏览器则仅仅是优先执行一次用户交互的响应。举个例子,对如下的元素
加上事件回调
js
function sleep(time) {const start = performance.now()while (performance.now() - time < start) {}}let delay = trueconst userInput = document.getElementById('test-user-input')userInput.addEventListener('click', () => {console.log('clicked')if (delay) {delay = falsesetTimeout(() => console.log('timeout'))setTimeout(() => (delay = true), 1500)sleep(1000)}})
如果快速点击的话,仅在 Chrome 浏览器中会先输出所有的"clicked"在输出"timeout",在其他浏览器中则是输出两次"clicked"就会输出"timeout"
事件循环第十步更新渲染中会触发一些事件,具体说来这一步可以分解为如下几个步骤:
- 把和本事件循环相关的 Document 赋给docs
- 如果有浏览上下文没有渲染机会,那么从docs移除相关的所有 Document。有渲染机会指的是一个浏览上下文能被呈现给用户,并且满足硬件和用户定义的刷新率限制.
- 如果有浏览上下文用户代理相信它的渲染不会造成视觉上的差别,且它不包含注册了 requestAnimationFrame 回调的 Document,那么把相关的 Document 从docs中移除
- 如果有浏览上下文用户代理认为跳过它是更好的,那么从docs移除相关的所有 Document
- 对docs中的所有 Document,执行 resize 步骤,传入now(事件循环步骤 2 中那个)作为事件戳
- 对docs中的所有 Document,执行 scroll 步骤,传入now作为事件戳
- 对docs中的所有 Document,对 media query 求值并汇报变化,传入now作为事件戳
- 对docs中的所有 Document,更新动画,发送相关事件,传入*now作为事件戳
- 对docs中的所有 Document,执行 fullscreen 步骤,传入now作为事件戳
- 对docs中的所有 Document,执行 animationFrame 回调,传入now作为事件戳
- 对docs中的所有 Document,执行更新 IntersectionObserver 步骤,传入now作为事件戳
- 对docs中的所有 Document,调用记录渲染时间渲染算法
- 对docs中的所有 Document,更新渲染以反映状态变化
下图可以清晰地表达这一流程
可以看到和渲染相关的事件触发事件
4.2 requestAnimationFrame
上面的步骤中最常用的 API 是 requestAnimationFrame。它的作用是注册一些回调,它们会在浏览器开始渲染之前执行。曾经对于 rAF 注册的回调是应该在当前帧还是下一帧执行有一些争议,但似乎今天这个问题已经有了一致的答案: 在当前帧执行。如果想要回调在下一帧执行,可以在 rAF 内再调用一次 rAF。
在同一帧内通过 rAF 注册的回调会依次执行,并且收到同一个时间戳作为参数,它就是在事件循环中第二步提到的now。所以回调收到的参数是当前任务开始处理的时间,因此有可能会比回调函数实际执行的时间要早一些。另外此处 Chrome 会传入帧开始时间作为参数,因此可能会比回调函数执行早很多。
4.3 requestIdleCallback
这是另一个常用 API,它和 rAF 一起构成了旧版 react fiber 的核心算法。它在事件循环的第十一步中执行,确切说来仅当:
- 这是窗口事件循环
- 事件循环的各任务队列中没有和 Document 相联系的还在活跃
- 事件循环的微任务队列是空的
- 没有浏览上下文有渲染机会
时才会执行。换言之,仅当浏览器空闲时才会执行回调。可以传入第二个参数,使得回调在超过 timeout 毫秒还未被调用时得到调用。一般来说 rIC 是先注册先调用,但也有可能因为 timeout 参数而打乱调用顺序。
5. 例子
相关例子可以参考 Jake Archibald 的文章。篇幅所限,不再赘述。
6. one more thing
上文所述的事件循环均在浏览器环境中,那么在服务端 JS 的事实标准 node.js 中,事件循环又是怎么样的呢?
Node 官网上就有很好的文章讲述了 Node 中的事件循环,可以用一张图说明
其中:
- timers: 执行到期的 setTimeout 和 setInterval
- pending: 执行被推迟到下一个周期的 I/O 回调
- poll: I/O 回调主要在这里执行
- check: setImmediate 在这里执行
而 process.nextTick 则是一个特殊的 API,它会把回调注册进一个特殊的微任务队列,这个队列里的微任务比其他微任务有更高的优先级。
其他部分则与浏览器较为相似,尽管以前版本有所区别,当代 Node.js 也会像浏览器那样,执行一个任务,完成之后再依次执行所有微任务,都完成之后再执行下一个任务。