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)。

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

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

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

笔者个人觉得这 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 queuesWHATWG 规范下的任务队列,而在 ECMAScript 规范 下,则提出了 jobsjob 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/setIntervaltimer 阶段检查到即将到达阈值,则会在 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 生命周期中,下一个阶段是 checkcheck 完成后会调用 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,所以率先打印了 startend

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

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

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

  5. 进入第二圈 event looptimer 阶段,依次输出 setTimeout1setIntervalsetTimeout2。同时在 setTimeout2 中:

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

  7. 进入第三圈 event loop,依次输出 setIntervalsetTimeout3

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

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

demo event loop

最后我们再总结下:

taskmacrotasks 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日
许可协议