一、背景

作为一枚“前端打字员”, 无论是新手还是老鸟,都会遇到一个令人深省的问题,既然js 是单线程执行的,是按照语句出现的顺序执行的,那么异步的代码 js 是怎么处理的呢?下面的代码是如何进行输出的?

console.log(1);
setTimeout(function() {
    console.log(2);
}, 0);
new Promise(function(resolve) {
    console.log(3);
    resolve(Date.now());
}).then(function() {
    console.log(4);
});
console.log(5);
setTimeout(function() {
    new Promise(function(resolve) {
        console.log(6);
        resolve(Date.now());
    }).then(function() {
        console.log(7);
    });
}, 0);

好了,小伙子们可以尽情发挥你的想象力,如果你的答案不是”1, 3, 5, 4, 2, 6, 7“,那你就要认认真真接着往下看了。什么?你竟然答对了。那你更要接着往下看,后面还有好看的彩蛋哦~


二、浏览器中的事件循环

1.同步和异步

首先通过一张导图来看一下javascript事件循环中,同步和异步的关系:

同步和异步
  • 同步和异步任务分别进入不同的执行 process,同步的进入主线程,异步的进入Event Table并注册函数。
  • 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)

那么还有一个问题:
那怎么知道主线程执行栈为空啊?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数

2.宏任务和微任务、事件循环执行机制

然而,仅仅理解到此,当然是还不够的: 除了广义的同步任务和异步任务,我们对任务有更精细的定义:宏任务和微任务

事件循环,宏任务,微任务的关系如图所示:

宏任务和微任务

事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务

回到我们上面说的实例代码:

console.log(1);
setTimeout(function() {
    console.log(2);
}, 0);
new Promise(function(resolve) {
    console.log(3);
    resolve(Date.now());
}).then(function() {
    console.log(4);
});
console.log(5);
setTimeout(function() {
    new Promise(function(resolve) {
        console.log(6);
        resolve(Date.now());
    }).then(function() {
        console.log(7);
    });
}, 0);

执行步骤如下:

  1. 执行 log(1),输出 1;
  2. 遇到 setTimeout,将回调的代码 log(2)添加到宏任务中等待执行;
  3. 执行 console.log(3),将 then 中的 log(4)添加到微任务中;
  4. 执行 log(5),输出 5;
  5. 遇到 setTimeout,将回调的代码 log(6, 7)添加到宏任务中;
  6. 宏任务的一个任务执行完毕,查看微任务队列中是否存在任务,存在一个微任务 log(4)(在步骤 3 中添加的),执行输出 4;
  7. 取出下一个宏任务 log(2)执行,输出 2;
  8. 宏任务的一个任务执行完毕,查看微任务队列中是否存在任务,不存在;
  9. 取出下一个宏任务执行,执行 log(6),将 then 中的 log(7)添加到微任务中;
  10. 宏任务执行完毕,存在一个微任务 log(7)(在步骤 9 中添加的),执行输出 7;

因此,最终的输出顺序为:1, 3, 5, 4, 2, 6, 7;

当然,你可以在Promise.then实现一个稍微耗时的操作(比如 1~1000的for循环),这个步骤看起来会更加地明显。马上会输出1,稍等一会儿才会输出3,然后再输出2。不论等待多长时间输出3,2一定会在3的后面输出。这也就印证了eventloop中的第3步操作,必须等所有的微任务执行完毕后,才开始下一个宏任务

3.宏任务、微任务概念及其常见的任务类型

3.1 宏任务

进入任务栈等待主线程执行的主代码块,包括从异步队列里加入到栈的,如setTimeout()、setInterval()的回调,其中不含异步队列中的微任务如Promise.then回调

  • 普通消息队列和延迟队列中的任务。渲染主线程采用一个for循环,不断地从这些任务队列中取出任务并执行任务
  • 每个宏任务都关联了一个微任务队列
  • 宏任务的时间粒度比较大,执行的时间间隔不能精确控制,对一些高实时性的需求就不太符合,比如监听DOM变化

macro-task(宏任务)常见类型:

  • 宿主环境(即整体代码script脚本),作为js运行的载体,常见的有浏览器、node.js等
  • setTimeout、setInterval
  • setImmediate(node.js)
  • 渲染任务(如解析DOM、计算布局、绘制)、用戶交互事件(如鼠标点击、滚动⻚面、放大缩小等)
  • JavaScript脚本执行事件
  • IO操作(网络请求、文件读写)

3.2 微任务

微任务: 一个需要异步执行的函数

  • 执行时机是在主函数执行结束之后、当前宏任务结束之前及当前事件循环结束之前
  • 微任务队列与每一个宏任务关联

在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行

常见类型:

  • Promise.resolve()
  • MutationObserver(web) 浏览器监听DOM变化
  • process.nextTick(node.js)
  • queueMicrotask()
  • Async/Await(实际就是promise)

MutationObserver采用了“异步+微任务”的策略:

  • 异步操作解决了同步操作的性能问题:每次DOM节点发生变化的时候,渲染引擎将变化记录封装成微任务,并 将微任务添加进当前的微任务队列中
  • 微任务解决了实时性的问题: 当执行到检查点的时候,V8引擎就会按照顺序执行微任务

监听DOM变化技术方案的演化史:从轮询到Mutation Event再到最新使用的 MutationObserver

3.3 requestAnimationFrame(web)

也属于异步执行的方法,但该方法既不属于宏任务,也不属于微任务, 具体参考MDN中的定义

告诉浏览器——你想在浏览器下次重绘之前继续更新下一帧动画


三、node 中的事件循环

node 11后,事件循环的一些原理发生变化,这里以新的标准为主,再加上变化点进行比对。

事件循环是node处理『非阻塞I/O 操作』的机制,依靠libuv引擎实现。

1.常见的宏任务和微任务

macro-task大概包括:

  • setTimeout
  • setInterval
  • setImmediate
  • script(整体代码)
  • I/O 操作

micro-task 大概包括:

  • process.nextTick(与普通微任务有区别,在微任务队列执行之前执行)
  • new Promise().then(回调)等

2.node事件循环整体理解和阶段概述

2.1 整体理解

Node的事件循环图

注意:每个框被称为事件循环机制的一个阶段。

  • 每个阶段都有一个 FIFO队列来执行回调。
  • 每个阶段都是特殊的,但通常,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。
  • 当该队列已用尽或达到回调限制,事件循环将移动到下一阶段

因此,可分析出node的事件循环的阶段顺序为:

  1. 输入数据阶段(incoming data)
  2. 轮询阶段(poll)
  3. 检查阶段(check)
  4. 关闭事件回调阶段(close callback)
  5. 定时器检测阶段(timers)
  6. I/O事件回调阶段(I/O callbacks)
  7. 闲置阶段(idle, prepare)->回到第2步继续新一轮『轮询阶段』…

2.2 阶段概述

  • 定时器检测阶段(timers):本阶段执行 timer 的回调,即 setTimeout、setInterval 里面的回调函数。
  • 待定回调:I/O事件回调阶段(I/O callbacks),执行延迟到下一个循环迭代的 I/O 回调,即上一轮循环中未被执行的一些I/O回调。
  • 闲置阶段(idle, prepare):仅系统内部使用。
  • 轮询阶段(poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
  • 检查阶段(check):setImmediate() 回调函数在这里执行
  • 关闭事件回调阶段(close callback):一些关闭的回调函数,如:socket.on(‘close’, …)。

2.3 三大重点阶段

开发中的绝大部分异步任务都是在 poll、check、timers 这3个阶段处理的

  • timers
  • poll
  • check

timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。同样,在 Node中定时器指定的时间也不是准确时间,只能是尽快执行。

poll阶段执行逻辑流程如下:

poll执行逻辑

  • 如果当前已经存在定时器,而且有定时器到时间了,拿出来执行,eventLoop 将回到 timers 阶段。
  • 如果没有定时器, 会去看回调函数队列
    • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
    • 如果 poll 队列为空时,会有两件事发生
      • 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
      • 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去,一段时间后自动进入 check 阶段。

check 阶段。这是一个比较简单的阶段,直接执行 setImmdiate 的回调

3.process.nextTick

process.nextTick 是一个独立于 eventLoop 的任务队列

每一个『eventLoop阶段』完成后会去检查nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行

看一个例子:

setImmediate(() => {
    console.log('timeout1')
    Promise.resolve().then(() =>console.log('promise resolve'))
    process.nextTick(() =>console.log('next tick1'))
});
setImmediate(() => {
    console.log('timeout2')
    process.nextTick(() =>console.log('next tick2'))
});
setImmediate(() =>console.log('timeout3'));
setImmediate(() =>console.log('timeout4'));
  • node11前,每一个eventLoop阶段后去检查nextTick队列
    • nextTick队列有任务,会优先于微任务执行。即以上代码进入check阶段,执行『所有setImmediate』,完成后执行nextTick队列,最后执行微任务队列,输出为:timeout1=>timeout2=>timeout3=>timeout4=>next tick1=>next tick2=>promise resolve
  • node11后,process.nextTick是微任务的一种。
    • 即代码进入check 阶段,执行『一个setImmediate宏任务』,然后执行『其微任务队列』,再执行『下一个宏任务及其微任务』,因此输出为timeout1=>next tick1=>promise resolve=>timeout2=>next tick2=>timeout3=>timeout4

4.node 版本差异 & 和浏览器eventLoop的差异

node 版本差异:

  • node11后一些特性向浏览器看齐,总的来说,如果node11 版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate),就立刻执行对应的微任务队列
  • node11后,process.nextTick是微任务的一种,先进入check阶段执行,再执行相应的微任务队列。

和浏览器eventLoop的差异:

  • 浏览器中的微任务是在每个相应的宏任务中执行的
  • nodejs中的微任务是在不同阶段之间执行的

总结

  • javascript是一门单线程语言
  • Event Loop是 javascript的执行机制

参考


最后, 希望大家早日实现:成为编程高手的伟大梦想!
欢迎交流~

微信公众号

本文版权归原作者曜灵所有!未经允许,严禁转载!对非法转载者, 原作者保留采用法律手段追究的权利!
若需转载,请联系微信公众号:连先生有猫病,可添加作者微信!