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
多线程。
在 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 执行模型:
从
task queues
取出一个task
,如果没有task
则直接跳转到 6 (执行microtasks
队列)将取出来的
task
标记正在运行运行这个
task
去掉这个
task
正在运行的标记在
task queues
移除这个task
运行
microtask
队列- a. 标记正在运行
microtask
队列 - b. 如果
microtask
队列不为空,则取出来循环执行 - c. 标记
microtask
队列运行完毕 - d. 销毁这个
microtask
队列
- a. 标记正在运行
引擎自行任务处理 (例如浏览器会进行 DOM 渲染更新)
销毁这个
task
循环执行第 1 步
上面的步骤简化来说就是:
- 从
task queues
任务队列中取出task
并执行 - 然后执行
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 |
|
- 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', ...)
的回调
在每个阶段运行中,如果存在新的队列任务,则会由内核进行重新排队。
setImmediate() VS setTimeout()
setImmediate
是一个特殊的计时器,它和普通计时器 (setTimeout/setInterval
) 不同的是它在独特的任务周期内执行:
setImmediate()
在 check 阶段完成后执行setTimeout()
在到达指定毫秒后(尽可能)执行
在普通的主流程 (main module) 中它们的行为不可预测 (取决于进程性能):
1 |
|
1 |
|
但如果在 I/O 的生命周期内调用这两个函数,就可以看到区别:
1 |
|
1 |
|
因为在 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 |
|
1 |
|
process.nextTick 和 setImmediate 名字其实是应该是对调的,因为 nodejs 早期对 api 命名错误导致了这个问题,随着 nodejs 使用者很多,为了避免大规模出现问题,这两个 API 的名字不会被修正(互相调换)。
异步 API 的执行顺序
前面说了那么多,还提到什么 macrotask/microtask
,那么我们怎么来验证呢?很简单,把所有异步代码在同一个任务中跑一遍,再结合上面所提到的执行顺序,就可以得到答案。
可以以 setTimeout() 作为分割点,setTimeout() 之前的都是 microtask
,而 setTimeout() 和之后的则都是 task
(大家也可以认为叫做 macrotask
)。
值的注意的是,考虑到 setImmediate
在主流程 (main module) 的执行时机不可预测,我们将测试代码都放到 I/O 中,从而保证所有异步代码都在同一个 event loop 中:
1 |
|
1 |
|
大概说下流程 (在 I/O 中):
第一圈 event loop,首先整体 script 是一个
task
,所以率先打印了start
和end
发现
setTimeout/setImmediate
,将它们的回调函数推到 event loop 下一个任务队列中 (task)process.nextTick/Promise.resolve
在上一阶段结束后被执行并输出,它们都是microtask
进入第一圈 event loop 的 check 阶段,所以输出
setImmediate
进入第二圈 event loop 的 timer 阶段,依次输出
setTimeout1
、setInterval
、setTimeout2
。同时在setTimeout2
中:- 将
promise3
推到本次 event loop 的microtask
中 - 将
setInterval
再次推到下一个 event loop 的任务队列中 - 将
setTimeout3
推到下一个 event loop 的任务队列中
- 将
执行本次 event loop 的
microtask
,输出promise3
进入第三圈 event loop,依次输出
setInterval
和setTimeout3
- 同时,在
setTimeout3
中清除了setInterval
- 同时,在
至此,结束任务
图片流程如下 (黑箭头表示任务流程,蓝虚线表示进入下一个 event loop,绿虚线表示当前 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
参考和引用
- WHATWG - event loops
- ECMAScript® 2015 Language Specification
- 理解事件循环二(macrotask和microtask)
- The JavaScript Event Loop: Explained
- The Node.js Event Loop, Timers, and process.nextTick()
- Tasks, microtasks, queues and schedules
- Promise的队列与setTimeout的队列有何关联?
- MDN - 并发模型与事件循环
- StackOverflow - Difference between microtask and macrotask within an event loop context