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