JavaScript异步中的macrotask和microtask

前言

首先来看一个JavaScript的代码片段:

  1. console.log(1);
  2. setTimeout(() => {
  3. console.log(2);
  4. Promise.resolve().then(() => {
  5. console.log(3)
  6. });
  7. }, 0);
  8. new Promise((resolve, reject) => {
  9. console.log(4)
  10. resolve(5)
  11. }).then((data) => {
  12. console.log(data);
  13. })
  14. setTimeout(() => {
  15. console.log(6);
  16. }, 0)
  17. console.log(7);

如果你能知道正确的答案,那么后续的内容可以略过了;如果不能建议看看下面有关js异步的内容,百利无一害,😁😁。

任务队列

js的一大特点是单线程,即同一个时间只能做一件事,这样设计主要与其作为浏览器脚本语言有关,js主要用途是用户交互以及操作dom,这决定其是单线程设计,否则会带来复杂的同步问题。比如一个线程删除一个节点,而另一个线程要操作该节点,浏览器不知以哪个线程为准。
单线程意味着任务需要排队,如果前一个任务耗时长,那么就会阻塞后续任务的执行。为此js出现了同步和异步任务,二者都需要在主线程执行栈中执行;其中异步任务需要进入任务队列(task queue)进行排队,其具体运行机制如下:

  • 同步任务在主线程上执行,形成一个执行栈
  • js会将主线程执行栈中的异步任务置于任务队列排队
  • 一旦主线程执行栈同步任务执行完毕处于空闲状态时,就会将任务队列中任务入栈开始执行

还是先来看一个js片段:

  1. console.log('script start')
  2. setTimeout(function() {
  3. console.log('timeout')
  4. }, 0)
  5. console.log('script end')

这段代码在进入主线程执行时,当执行到setTimeout时会将其放置到异步任务队列中,即使设置时间为0也不会马上执行,必须等到主线程执行栈空闲时(执行完console.log(‘script end’)语句后)才会读取异步队列的任务执行。

macrotask与microtask

二者任务都会被放置于任务队列中等待某个时机被主线程入栈执行,其实任务队列分为宏任务队列和微任务队列,其中放置的分别为宏任务和微任务。

  • macrotask(宏任务)在浏览器端,其可以理解为该任务执行完后,在下一个macrotask执行开始前,浏览器可以进行页面渲染。触发macrotask任务的操作包括:
    • script(整体代码)
    • setTimeoutsetIntervalsetImmediate
    • I/OUI交互事件
    • postMessageMessageChannel
  • microtask(微任务)可以理解为在macrotask任务执行后,页面渲染前立即执行的任务。触发microtask任务的操作包括:
    • Promise.then
    • MutationObserver
    • process.nextTick(Node环境)

下面通过例子来看看二者的不同:

  1. console.log('script start');
  2. setTimeout(function() {
  3. console.log('timeout');
  4. }, 0);
  5. Promise.resolve().then(function() {
  6. console.log('promise1');
  7. }).then(function() {
  8. console.log('promise2');
  9. });
  10. console.log('script end');

上面一段代码输出结果为:

script start > script end > promise1 > promise2 > timeout

具体的可视化操作演示可以参考

上面代码运行到最后一句console后,生成的任务队列:
macrotasks:【setTimeout回调】
microtasks:【Promise.then回调1, Promise.then回调2】
两种不同的任务队列,为啥microtask的任务会先执行呢,这就要说说macrotask与microtask的运行机制[3]如下:

  • 执行一个macrotask(包括整体script代码),若js执行栈空闲则从任务队列中取
  • 执行过程中遇到microtask,则将其添加到micro task queue中;同样遇到macrotask则添加到macro task queue中
  • macrotask执行完毕后,立即按序执行micro task queue中的所有microtask;如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行
  • 所有microtask执行完毕后,浏览器开始渲染,GUI线程接管渲染
  • 渲染完毕,从macro task queue中取下一个macrotask开始执行

    Event loop

    在主线程执行栈空闲的情况下,从任务队列中读取任务入执行栈执行,这个过程是循环不断进行的,所以又称Event loop(事件循环)。
    Event loop是一个js实现异步的规范,在不同环境下有不同的实现机制,例如浏览器和NodeJS实现机制不同:

  • 浏览器的Event loop是按照html标准定义来实现,具体的实现留给各浏览器厂商

  • NodeJS中的Event loop是基于libuv实现

下面来说说浏览器环境下的Event loop,首先借用一幅图:
JavaScript异步中的macrotask和microtask - 图1
根据HTML Standard - event loop processing model对Event loop规范描述来简单说明事件循环模型:

  1. 按先进先出原则选择最新进入Event loop任务队列的一个macrotask,若没有则直接进入第6步的microtask
  2. 设置Event loop的当前任务为上面一步选择的任务
  3. 进栈运行所选的任务
  4. 运行完毕设置Event loop的当前任务为null
  5. 将第一步选择的任务从任务队列中删除
  6. 执行microtask:perform a microtask checkpoint,具体执行步骤参考这里
  7. 更新并进行UI渲染
  8. 返回第一步执行

    microtask的应用

    根据Event loop机制,macrotask的一个任务执行完后就进行UI渲染,然后进行另一个macrotask任务执行,macrotask任务的应用就不做过多介绍。下面来说说microtask任务的应用场景,我们以vue的异步更新DOM来做说明,先看官网的说明:
    1. Vue异步执行DOM更新,只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。
    也就是说,Vue绑定的数据发生变化时,页面视图不会立即重新更新,需要等到当前任务执行完毕时进行更新。例如下面代码:
    1. <template>
    2. <div>
    3. <div ref="test">{{test}}</div>
    4. <button @click="handleClick">tet</button>
    5. </div>
    6. </template>
    7. export default {
    8. data () {
    9. return {
    10. test: 'begin'
    11. };
    12. },
    13. methods () {
    14. handleClick () {
    15. this.test = 'end';
    16. console.log(this.$refs.test.innerText);//打印“begin”
    17. }
    18. }
    19. }
    上面代码在执行this.test = ‘end’后,页面视图绑定数据test发生变化,若按照同步执行代码,视图应该能马上获取到对应dom的内容,但是并没有获取到。这是因为Vue采用异步视图更新的。具体来说就是Vue在侦听到数据变化时,异步更新视图最终是通过nextTick来完成的,而该方法默认采用microtask任务来实现异步任务,具体的可以参考从Vue.js源码看nextTick机制;这样在 microtask 中就完成数据更新,task 结束就可以得到最新的 UI 了。上面代码如下:
    1. handleClick () {
    2. this.test = 'end';
    3. this.$nextTick(() => {
    4. console.log(this.$refs.test.innerText);//打印"end"
    5. });
    6. }
    按照HTML Standard描述,macrotask、microtask和UI 渲染的执行顺序:
    一个macrotask任务 —> 所有microtask任务 —> UI 渲染
    既然nextTick是按照microtask来实现异步的,那么microtask任务应该是在UI渲染前执行的,为什么表现的是microtask在UI 渲染之后执行的呢?可能有人对上面提出过质疑。猜测原因如下,具体原因可以参考这篇文章

    JS更新dom是同步完成的,但是UI渲染是异步的。

microtask跨浏览器实现

从Vue的nextTick方法的实现以及immediate的实现可以看出,怎么实现Event loop中的microtask实现呢?那就是借助js原生支持的Promise、MutationObserver(浏览器)、process.nextTick(nodejs环境)来实现,均不支持时使用setTimeout(fn, 0)来兜底降级实现。下面就来简单说说microtask的实现思路:

  • 浏览器是否原生实现Promise,有则使用Promise类似如下实现,否则走下一步。

    1. if (typeof Promise !== 'undefined' && isNative(Promise)) {
    2. const p = Promise.resolve()
    3. microTimerFunc = () => {
    4. p.then(handle)
    5. }
  • 浏览器环境是否原生支持MutationObserver,支持可以这么实现,否则走下一步。

    1. function microFun(handle) {
    2. var observer = new MutationObserver(handle);
    3. var element = document.createTextNode('');
    4. observer.observe(element, {
    5. characterData: true
    6. });
    7. return function () {
    8. element.data = blabla;
    9. };
    10. }
  • 浏览器是否支持onreadystatechange事件,支持则创建一个空的script标签,一旦插入到document中,其onreadystatechange事件将会异步地触发,比setTimeout(fn,0)快,否则走下一步

    1. function microFun(handle) {
    2. return function () {
    3. var scriptEl = document.createElement('script');
    4. scriptEl.onreadystatechange = function () {
    5. handle();
    6. scriptEl.onreadystatechange = null;
    7. scriptEl.parentNode.removeChild(scriptEl);
    8. scriptEl = null;
    9. };
    10. document.documentElement.appendChild(scriptEl);
    11. return handle;
    12. };
    13. };
  • 使用setTimeout(fn, 0)来兜底实现

下面看一下core-js模块中Promise中对microtask的模拟实现,具体可以参考源码:

  1. module.exports = function () {
  2. var head, last, notify;
  3. var flush = function () {
  4. var parent, fn;
  5. if (isNode && (parent = process.domain)) parent.exit();
  6. while (head) {
  7. fn = head.fn;
  8. head = head.next;
  9. try {
  10. fn();
  11. } catch (e) {
  12. if (head) notify();
  13. else last = undefined;
  14. throw e;
  15. }
  16. } last = undefined;
  17. if (parent) parent.enter();
  18. };
  19. // Node.js
  20. if (isNode) {
  21. notify = function () {
  22. process.nextTick(flush);
  23. };
  24. // browsers with MutationObserver
  25. } else if (Observer) {
  26. var toggle = true;
  27. var node = document.createTextNode('');
  28. new Observer(flush).observe(node, { characterData: true }); // eslint-disable-line no-new
  29. notify = function () {
  30. node.data = toggle = !toggle;
  31. };
  32. // environments with maybe non-completely correct, but existent Promise
  33. } else if (Promise && Promise.resolve) {
  34. var promise = Promise.resolve();
  35. notify = function () {
  36. promise.then(flush);
  37. };
  38. // for other environments - macrotask based on:
  39. // - setImmediate
  40. // - MessageChannel
  41. // - window.postMessag
  42. // - onreadystatechange
  43. // - setTimeout
  44. } else {
  45. notify = function () {
  46. // strange IE + webpack dev server bug - use .call(global)
  47. macrotask.call(global, flush);
  48. };
  49. }
  50. return function (fn) {
  51. var task = { fn: fn, next: undefined };
  52. if (last) last.next = task;
  53. if (!head) {
  54. head = task;
  55. notify();
  56. } last = task;
  57. };
  58. };

问题答案

对于文章开头的js代码,其最终输出内容为:

1 -> 4 -> 7 -> 5 -> 2 -> 3 -> 6

可以从以下几个步骤来简单分析,具体执行步骤如下图所示:
JavaScript异步中的macrotask和microtask - 图2

参考文献

一文读懂JavaScript的并发模型和事件循环机制

我们知道JS语言是串行执行、阻塞式、事件驱动的,那么它又是怎么支持并发处理数据的呢?

“单线程”语言

在浏览器实现中,每个单页都是一个独立进程,其中包含了JS引擎、GUI界面渲染、事件触发、定时触发器、异步HTTP请求等多个线程。 进程(Process)是操作系统CPU等资源分配的最小单位,是程序的执行实体,是线程的容器。线程(Thread)是操作系统能够进行运算调度的最小单位,一条线程指的是进程中一个单一顺序的控制流。 因此我们可以说JS是”单线程”式的语言,代码只能按照单一顺序进行串行执行,并在执行完成前阻塞其他代码。

JS数据结构

JavaScript异步中的macrotask和microtask - 图3
如上图所示为JS的几种重要数据结构:

  • 栈(Stack):用于JS的函数嵌套调用,后进先出,直到栈被清空。
  • 堆(Heap):用于存储大块数据的内存区域,如对象。
  • 队列(Queue):用于事件循环机制,先进先出,直到队列为空。

    事件循环

    我们的经验告诉我们JS是可以并发执行的,比如定时任务、并发AJAX请求,那这些是怎么完成的呢?其实这些都是JS在用单线程模拟多线程完成的。
    JavaScript异步中的macrotask和microtask - 图4
    如上图所示,JS串行执行主线程任务,当遇到异步任务如定时器时,将其放入事件队列中,在主线程任务执行完毕后,再去事件队列中遍历取出队首任务进行执行,直至队列为空。
    全部执行完成后,会有主监控进程,持续检测队列是否为空,如果不为空,则继续事件循环。

    setTimeout定时任务

    定时任务setTimeout(fn, timeout)会先被交给浏览器的定时器模块,等延迟时间到了,再将事件放入到事件队列里,等主线程执行结束后,如果队列中没有其他任务,则会被立即处理,而如果还有没有执行完成的任务,则需要等前面的任务都执行完成才会被执行。因此setTimeout的第2个参数是最少延迟时间,而非等待时间。
    当我们预期到一个操作会很繁重耗时又不想阻塞主线程的执行时,会使用立即执行任务:

    1. setTimeout(fn, 0);

    特殊场景1:最小延迟为1ms

    然而考虑这么一段代码会怎么执行:

    1. setTimeout(()=>{console.log(5)},5)
    2. setTimeout(()=>{console.log(4)},4)
    3. setTimeout(()=>{console.log(3)},3)
    4. setTimeout(()=>{console.log(2)},2)
    5. setTimeout(()=>{console.log(1)},1)
    6. setTimeout(()=>{console.log(0)},0)

    了解完事件队列机制,你的答案应该是0,1,2,3,4,5,然而答案却是1,0,2,3,4,5,这个是因为浏览器的实现机制是最小间隔为1ms。

    1. // https://github.com/nodejs/node/blob/v8.9.4/lib/timers.js#L456
    2. if (!(after >= 1 && after <= TIMEOUT_MAX))
    3. after = 1; // schedule on next tick, follows browser behavior

    浏览器以32位bit来存储延时,如果大于 2^32-1 ms(24.8天),导致溢出会立刻执行。

    特殊场景2:最小延迟为4ms

    定时器的嵌套调用超过4层时,会导致最小间隔为4ms:

    1. var i=0;
    2. function cb() {
    3. console.log(i, new Date().getMilliseconds());
    4. if (i < 20) setTimeout(cb, 0);
    5. i++;
    6. }
    7. setTimeout(cb, 0);

    可以看到前4层也不是标准的立刻执行,在第4层后间隔明显变大到4ms以上:

    1. 0 667
    2. 1 669
    3. 2 670
    4. 3 672
    5. 4 676
    6. 5 681
    7. 6 685

    Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.

    特殊场景3:浏览器节流

    为了优化后台tab的加载占用资源,浏览器对后台未激活的页面中定时器延迟限制为1s。
    对追踪型脚本,如谷歌分析等,在当前页面,依然是4ms的延时限制,而后台tabs为10s。

    setInterval定时任务

    此时,我们会知道,setInterval会在每个定时器延时时间到了后,将一个新的事件fn放入事件队列,如果前面的任务执行太久,我们会看到连续的fn事件被执行而感觉不到时间预设间隔。
    因此,我们要尽量避免使用setInterval,改用setTimeout来模拟循环定时任务。

    睡眠函数

    JS一直缺少休眠的语法,借助ES6新的语法,我们可以模拟这个功能,但是同样的这个方法因为借助了setTimeout也不能保证准确的睡眠延时:

    1. function sleep(ms) {
    2. return new Promise(resolve => {
    3. setTimeout(resolve, ms);
    4. })
    5. }
    6. // 使用
    7. async function test() {
    8. await sleep(3000);
    9. }

    async await机制

    async函数是Generator函数的语法糖,提供更方便的调用和语义,上面的使用可以替换为:

    1. function* test() {
    2. yield sleep(3000);
    3. }
    4. // 使用
    5. var g = test();
    6. test.next();

    但是调用使用更加复杂,因此一般我们使用async函数即可。但JS时如何实现睡眠函数的呢,其实就是提供一种执行时的中间状态暂停,然后将控制权移交出去,等控制权再次交回时,从上次的断点处继续执行。因此营造了一种睡眠的假象,其实JS主线程还可以在执行其他的任务。
    Generator函数调用后会返回一个内部指针,指向多个异步任务的暂停点,当调用next函数时,从上一个暂停点开始执行。

    协程

    协程(coroutine)是指多个线程互相协作,完成异步任务的一种多任务异步执行的解决方案。他的运行流程:

  • 协程A开始执行

  • 协程A执行到一半,进入暂停,执行权转移到协程B
  • 协程B在执行一段时间后,将执行权交换给A
  • 协程A恢复执行

可以看到这也就是Generator函数的实现方案。

宏任务和微任务

一个JS的任务可以定义为:在标准执行机制中,即将被调度执行的所有代码块。
我们上面介绍了JS如何使用单线程完成异步多任务调用,但我们知道JS的异步任务分很多种,如setTimeout定时器、Promise异步回调任务等,它们的执行优先级又一样吗?
答案是不。JS在异步任务上有更细致的划分,它分为两种:

  • 宏任务(macrotask)包含:
    • 执行的一段JS代码块,如控制台、script元素中包含的内容。
    • 事件绑定的回调函数,如点击事件。
    • 定时器创建的回调,如setTimeout和setInterval。
  • 微任务(microtask)包含:
    • Promise对象的thenable函数。
    • Nodejs中的process.nextTick函数。
    • JS专用的queueMicrotask()函数。

JavaScript异步中的macrotask和microtask - 图5
宏任务和微任务都有自身的事件循环机制,也拥有独立的事件队列(Event Queue),都会按照队列的顺序依次执行。但宏任务和微任务主要有两点区别:

  1. 宏任务执行完成,在控制权交还给主线程执行其他宏任务之前,会将微任务队列中的所有任务执行完成。
  2. 微任务创建的新的微任务,会在下一个宏任务执行之前被继续遍历执行,直到微任务队列为空。

    浏览器的进程和线程

    浏览器是多进程式的,每个页面和插件都是一个独立的进程,这样可以保证单页面崩溃或者插件崩溃不会影响到其他页面和浏览器整体的稳定运行。
    它主要包括:

  3. 主进程:负责浏览器界面显示和管理,如前进、后退,新增、关闭,网络资源的下载和管理。

  4. 第三方插件进程:当启用插件时,每个插件独立一个进程。
  5. GPU进程:全局唯一,用于3D图形绘制。
  6. Renderer渲染进程:每个页面一个进程,互不影响,执行事件处理、脚本执行、页面渲染。

    单页面线程

    浏览器的单个页面就是一个进程,指的就是Renderer进程,而进程中又包含有多个线程用于处理不同的任务,主要包括:

  7. GUI渲染线程:负责HTML和CSS的构建成DOM树,渲染页面,比如重绘。

  8. JS引擎线程:JS内核,如Chrome的V8引擎,负责解析执行JS代码。
  9. 事件触发线程:如点击等事件存在绑定回调时,触发后会被放入宏任务事件队列。
  10. 定时触发器线程:setTimeout和setInterval的定时计数器,在时间到达后放入宏任务事件队列。
  11. 异步HTTP请求线程:XMLHTTPRequest请求后新开一个线程,等待状态改变后,如果存在回调函数,就将其放入宏任务队列。

需要注意的是,GUI渲染进程和JS引擎进程互斥,两者只会同时执行一个。主要的原因是为了节流,因为JS的执行会可能多次改变页面,页面的改变也会多次调用JS,如resize。因此浏览器采用的策略是交替执行,每个宏任务执行完成后,执行GUI渲染,然后执行下一个宏任务。

Webworker线程

因为JS只有一个引擎线程,同时和GUI渲染线程互斥,因此在繁重任务执行时会导致页面卡住,所以在HTML5中支持了Webworker,它用于向浏览器申请一个新的子线程执行任务,并通过postMessage API来和worker线程通信。所以我们在繁重任务执行时,可以选择新开一个Worker线程来执行,并在执行结束后通信给主线程,这样不会影响页面的正常渲染和使用。

总结

  1. JS是单线程、阻塞式执行语言。
  2. JS通过事件循环机制来完成异步任务并发执行。
  3. JS将任务细分为宏任务和微任务来提供执行优先级。
  4. 浏览器单页面为一个进程,包含的JS引擎线程和GUI渲染线程互斥,可以通过新开Web Worker线程来完成繁重的计算任务。

JavaScript异步中的macrotask和microtask - 图6
最后给大家出一个考题,可以猜下执行的输出结果来验证学习成果:

  1. function sleep(ms) {
  2. console.log('before first microtask init');
  3. new Promise(resolve => {
  4. console.log('first microtask');
  5. resolve()
  6. })
  7. .then(() => {console.log('finish first microtask')});
  8. console.log('after first microtask init');
  9. return new Promise(resolve => {
  10. console.log('second microtask');
  11. setTimeout(resolve, ms);
  12. });
  13. }
  14. setTimeout(async () => {
  15. console.log('start task');
  16. await sleep(3000);
  17. console.log('end task');
  18. }, 0);
  19. setTimeout(() => console.log('add event'), 0);
  20. console.log('main thread');

输出为:

  1. main thread
  2. start task
  3. before first microtask init
  4. first microtask
  5. after first microtask init
  6. second microtask
  7. finish first microtask
  8. add event
  9. end task

参考资料

  1. 这一次,彻底弄懂 JavaScript 执行机制:https://juejin.im/post/59e85e…
  2. 并发模型与事件循环:https://developer.mozilla.org…
  3. http://www.alloyteam.com/2016/05/javascript-timer/
  4. https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers
  5. https://developer.mozilla.org/zh-CN/docs/Web/API/Window/setTimeout
  6. http://es6.ruanyifeng.com/#docs/generator-async#%E5%9F%BA%E6%9C%AC%E6%A6%82%E5%BF%B5
  7. https://juejin.im/post/5a6547d0f265da3e283a1df7#heading-7