JavaScript 中的 task queues

JavaScript 是单线程的,所有的任务都放在 “任务队列” 中
从而衍生了 event loops 机制
我们这次要讨论的,则是 event loop 的背后 —— task queues 和异步任务 API 的执行细节

前端开发 QQ 群:377786580

从 event loop 说起

众所周知,JavaScript 是单线程的,所有的任务都视为事件,放在 “任务队列” 中,循环读取并执行这个事件队列,这就是 Event loops (事件循环) 机制。

在浏览器中,有两种 event loop,分别是 brwosing-context(浏览器上下文) 和 Web workers。前者是浏览器中 JavaScript 的 event loop,后者应用在 JavaScript 的 web worker 多线程。

event loop

在 JavaScript 函数的执行中,会生成 stack(栈) 和 heap (堆),同时还有一个 task queues(任务队列),函数中所调用的外部函数都放在 task queues 中,当函数主线程执行完毕,就会从 task queues 中依次取出对应的任务并执行。

在一个 event loop 中,会有一个或多个task queues(任务队列)。

我们这篇文章要讲的,就是讲 task queues 和它里面的东西,受 task queues 最大影响的,就是异步 API 的调用时机 (例如 setTimeout/process.nextTick/promise.then)。

task queues 任务队列

在每个 event loop 下会有一个或多个 task queues(任务队列),而每个任务都会有一个对应的任务源 target source(任务源),每个任务都放到对应任务源的任务队列中。

每个任务源根据不同浏览器或其他引擎 (例如 nodejs) 的实现,自己安排不同的优先级,从而调控某类任务的执行顺序。

例如浏览器认为鼠标事件频率很高,所以可以把鼠标事件的任务源优先级设的更高,而 nodejs 中 i/o 优先级更高,就针对此类任务源可以提升执行优先级。

macrotask(宏任务) 和 microtask(微任务) 就是这些针对这些任务不同的执行时机分类而出。

macrotask 和 microtask

纵观 WHATWG 和 ECMAScript 规范,都没有明确指出 macrotask(宏任务) 和 microtask(微任务),检索 V8/nodejs 源码没有找到 macrotask(只找到 microtask)。

macrotask 和 microtask 是对任务的行为作出的分类,许多相关文章都提及了 macrotask(宏任务) 是一个大任务,而 microtask(微任务) 则是每个 event loop 中快速处理的小任务。

microtask queues 是一种快速任务队列,设计的初衷是在某个 task 执行后快速执行的一个队列。

macrotask 和 microtask 它们只是一个抽象概念,具体随便引擎怎么实现。

笔者个人觉得这 macrotask 这个概念还是有争议的

WHATWG 中的 task queues

在浏览器环境下, WHATWG 规范中描述了event loop 的 task queues(任务队列)。

而每个 event loop 中,还有一个 microtask queues(微任务队列),该队列将任务按照不同的任务源 (类型) 进行分类,每个任务的任务源都被叫做 microtask task source(微任务源)。

  • 在 event loop 中,会有一个或多个 task queues(任务队列)
  • 每个 event loop 中,都有一个 microtask queues(微任务队列)

然后我们看下 WHATWG 规范中的 event loop 执行模型:

  1. 从 task queues 取出一个 task ,如果没有 task 则直接跳转到 6 (执行 microtasks 队列)

  2. 将取出来的 task 标记正在运行

  3. 运行这个 task

  4. 去掉这个 task 正在运行的标记

  5. 在 task queues 移除这个 task

  6. 运行 microtask 队列

    • a. 标记正在运行 microtask 队列
    • b. 如果 microtask 队列不为空,则取出来循环执行
    • c. 标记 microtask 队列运行完毕
    • d. 销毁这个 microtask 队列
  7. 引擎自行任务处理 (例如浏览器会进行 DOM 渲染更新)

  8. 销毁这个 task

  9. 循环执行第 1 步

task queues

上面的步骤简化来说就是:

  1. 从 task queues 任务队列中取出 task 并执行
  2. 然后执行 microtasks 队列

ECMAScript 中的 jobs 和 job queues

前面我们看到的 task queues 是 WHATWG 规范下的任务队列,而在 ECMAScript 规范 下,则提出了 jobs 和 job quques 的概念:

job 是一个抽象操作,当前没有正在执行的任务的时候,会从 job queues 队列中取出一个 job 执行。

job queues 同样根据不同的任务源进行分类,规范规定每个 ECMAScript 实现必须至少实现两个 job queues:

名称 描述
ScriptJobs 运行一般 ECMAScript 脚本和 module 的任务队列
PromiseJobs 处理 Promise 任务的异步队列

在这里,不同的引擎也可以针对 job queues 进行分类,然后重排优先级。

可以看到,这个 job queues 和前面 WHATWG 规范的 task queues 有异曲同工之妙。

nodejs 中的 tick

在 nodejs 中,每个 event loop 称之为 tick。官方贴出这样一个 event loop 任务执行顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
  • timers:检查 setTimeout/setInterval
  • I/O callbacks:大部分回调函数都在这里执行,除了 close/timers/setImmediate 的回调
  • idle, prepare:引擎内部使用
  • poll:检索新的 I/O 事件,等待到达阈值的轮询任务队列,例如 setTimeout/setInterval 在 timer 阶段检查到即将到达阈值,则会在 poll 阶段等待阈值并执行
  • check:调用 setImmediate
  • close callbacks:关闭类回调,例如 socket.on('close', ...) 的回调

在每个阶段运行中,如果存在新的队列任务,则会由内核进行重新排队。

nodejs tick

setImmediate() VS setTimeout()

setImmediate 是一个特殊的计时器,它和普通计时器 (setTimeout/setInterval) 不同的是它在独特的任务周期内执行:

  • setImmediate() 在 check 阶段完成后执行
  • setTimeout() 在到达指定毫秒后(尽可能)执行

在普通的主流程 (main module) 中它们的行为不可预测 (取决于进程性能):

1
2
3
4
5
6
7
8
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout')
}, 0);

setImmediate(() => {
console.log('immediate')
})
1
2
3
4
5
6
7
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

但如果在 I/O 的生命周期内调用这两个函数,就可以看到区别:

1
2
3
4
5
6
7
8
9
10
11
12
// timeout_vs_immediate.js
const fs = require('fs')

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)

setImmediate(() => {
console.log('immediate')
})
})
1
2
3
4
5
6
7
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

因为在 I/O 生命周期中,下一个阶段是 check,check 完成后会调用 setImmediate()。

值的一提的是 setTimeout() 有最低 4ms 执行延迟,这是各大引擎约定成俗的。

process.nextTick()

在异步 API 中,process.nextTick() 是个异类,即使它是异步 API 的一部分,但是也没有出现在上面的图中。因为 process.nextTick() 在技术上不属于 event loop,而是挂载在 nextTickQueue 中。

从 node event loop 的流程图中可以看到,nextTickQueue 贯穿了整个 event loop,nextTickQueue 会在每个阶段之后执行 (microtask)。

在每次 event loop 任意阶段结束后,都会保证 nextTickQueue 一定被清空了。

1
2
3
4
5
6
7
8
9
10
11
12
// setImmediate_vs_process.nextTick.js
const fs = require('fs')

fs.readFile(__filename, () => {
setImmediate(() => {
console.log('setImmediate')
}, 0)

process.nextTick(() => {
console.log('nextTick')
})
})
1
2
3
$ node setImmediate_vs_process.nextTick.js
nextTick
setImmediate

process.nextTick 和 setImmediate 名字其实是应该是对调的,因为 nodejs 早期对 api 命名错误导致了这个问题,随着 nodejs 使用者很多,为了避免大规模出现问题,这两个 API 的名字不会被修正(互相调换)。

异步 API 的执行顺序

前面说了那么多,还提到什么 macrotask/microtask,那么我们怎么来验证呢?很简单,把所有异步代码在同一个任务中跑一遍,再结合上面所提到的执行顺序,就可以得到答案。

可以以 setTimeout() 作为分割点,setTimeout() 之前的都是 microtask,而 setTimeout() 和之后的则都是 task(大家也可以认为叫做 macrotask)。

值的注意的是,考虑到 setImmediate 在主流程 (main module) 的执行时机不可预测,我们将测试代码都放到 I/O 中,从而保证所有异步代码都在同一个 event loop 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// test.js
const fs = require('fs')

// 保证所有 API 测试都在同一个 task 内
fs.readFile(__filename, () => {
console.log('start')

setImmediate(() => {
console.log('setImmediate')
})

// setTimeout1
setTimeout(() => {
console.log('setTimeout1')
}, 0)

const myInterval = setInterval(() => {
console.log('setInterval')
}, 0)

// setTimeout2
setTimeout(() => {
console.log('setTimeout2')
// promise3
Promise.resolve().then(() => {
console.log('promise3')
})

// setTimeout3
setTimeout(() => {
console.log('setTimeout3')
clearInterval(myInterval)
}, 0)
}, 0)

process.nextTick(() => {
console.log('nextTick')
});

// promise1
Promise.resolve()
.then(() => {
console.log('promise1')
}).then(() => {
console.log('promise2')
})
console.log('end')
})
1
2
3
4
5
6
7
8
9
10
11
12
$ node test.js
start
end
nextTick
promise1
promise2
setImmediate
setTimeout1
setInterval
setTimeout2
promise3
setInterval

大概说下流程 (在 I/O 中):

  1. 第一圈 event loop,首先整体 script 是一个 task,所以率先打印了 start 和 end

  2. 发现 setTimeout/setImmediate,将它们的回调函数推到 event loop 下一个任务队列中 (task)

  3. process.nextTick/Promise.resolve 在上一阶段结束后被执行并输出,它们都是 microtask

  4. 进入第一圈 event loop 的 check 阶段,所以输出 setImmediate

  5. 进入第二圈 event loop 的 timer 阶段,依次输出 setTimeout1、setInterval、setTimeout2。同时在 setTimeout2 中:

    • 将 promise3 推到本次 event loop 的 microtask 中
    • 将 setInterval 再次推到下一个 event loop 的任务队列中
    • 将 setTimeout3 推到下一个 event loop 的任务队列中
  6. 执行本次 event loop 的 microtask,输出 promise3

  7. 进入第三圈 event loop,依次输出 setInterval 和 setTimeout3

    • 同时,在 setTimeout3 中清除了 setInterval
  8. 至此,结束任务

图片流程如下 (黑箭头表示任务流程,蓝虚线表示进入下一个 event loop,绿虚线表示当前 event loop 的下一阶段):

demo event loop

最后我们再总结下:

task 或 macrotasks API :

  • setTimeout
  • setInterval
  • setImmediate
  • requestAnimationFrame
  • I/O
  • UI 渲染

microtasks API:

  • process.nextTick
  • Promises
  • Object.observe
  • MutationObserver

各个异步 API 优先级:

process.nextTick > promise.then > setImmediate > setTimeout = setInterval

前端开发 QQ 群:377786580

参考和引用


JavaScript 中的 task queues
https://tasaid.com/posts/d4134bcf/
作者
linkfly
发布于
2018年1月18日
更新于
2018年8月27日
许可协议