JS的宏任务、微任务和EventLoop

JS是一个单线程脚本语言,这是大家公知的。单线程的设计与其用途都是有关系的,因为有很多的场景需要保证同一时间只能做一件事情。比如DOM的创建与删除等等。

当然为了利用多核CPU的算力,HTML5 提出的Web Worker标准,允许JS脚本创建多个线程,但是子线程完全需要听从主线程的安排。所以本质上是没有改变JS单线程的事实。

任务队列

单线程只能保持同一时间只能做一件事情,那么所有的任务需要排队执行,前一个任务结束,下一个任务才能执行。如果前一个任务耗时很长时间,后一个任务就需要等着。

绝大多数,现在的计算机算力都是性能过剩的,也就是CPU很多的时候都是空闲的,但是由于受限于I/O,需要等待结果出来再执行。

JS设计者也意识到这个问题的发生,也就是主线程完全可以不管I/O设备,挂起等待的任务,先运行排在后面的任务。等到I/O返回了结果,在等到可以执行的时候,将挂起的任务再执行下去。

于是在JS中任务就分为:同步任务,异步任务。同步任务指的是:在主线程上排队执行的任务,它存在于任务栈中,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是:不进入主线程,而进入任务队列,只有任务队列通知主线程某个异步任务可以执行,那么这个异步任务才会进入主线程执行。

JS中我们可以将同步执行视为没有异步任务的异步执行,那么所有都归类到异步执行下,可以简单这么总结一下js执行任务的机制:

  1. 同步任务都是在主线程上的执行栈(stack)中执行
  2. 主线程以外的任务队列。只要异步任务有了运行结果,就会在任务队列中放置一个待执行事件
  3. 当执行栈中的同步任务都执行完毕,就会去执行任务队列的任务事件,此时任务队列的事件结束等待状态,调入执行栈开始执行。
  4. 主线程不断的执行上述三步

14.JS的宏任务、微任务和EventLoop - 图1

Event Loop

它是一个任务的执行模型,也可以称为“消息线程”,它用于主线程与“任务队列”进行通信,其目的就是为了减少多线程的等待时间,防止资源的浪费。主线程从“任务队列”中执行事件,这个过程是循环不断的,所以整个运行机制又称为Event Loop(事件循环)。在浏览器端和NodeJS环境下都有不同的实现方法。

  1. 浏览器的Event LoopHTML5规范中明确定义
  2. NodeJSEvent Loop是基于libuv实现的

请看下图。
14.JS的宏任务、微任务和EventLoop - 图2
上图中,主线程运行的时候,产生堆(heap)栈(stack),栈中的代码调用各种外部API,他们在任务队列中加入各种事件。当栈中的代码执行完毕后,主线程就回去读取任务队列,依次执行事件所对应的回调函数。

栈(stack)中的代码执行的是同步任务,总是在异步任务(读取任务队列的事件)之前执行。当然,异步任务也有执行先后顺序之分,分为宏任务与微任务,如图:
14.JS的宏任务、微任务和EventLoop - 图3

这里我们就能看到其实这张图就是对上一张图的补充,同步任务的执行栈中在执行完毕以后会执行回调函数等异步任务,但是此时的异步任务根据不同的关键词分成了不同的任务级别,从而决定了任务的执行顺序。这么做的目的就是为了让所以的异步任务在宏观上也是同步执行的。这里面就提出了两个概念宏任务与微任务

宏任务与微任务

执行过程

14.JS的宏任务、微任务和EventLoop - 图4
先执行微任务,再执行宏任务

宏任务

macroTask,也叫tasks,主要的工作如下:

  1. 创建主文档对象,解析HTML,执行主线或者全局的JS的代码,更改URL以及各种事件。
  2. 页面加载,网络事件,定时器等等。
    宏任务在浏览器环境下其实就是一个个离散的独立的工作单元,是比较大的任务集合。一些异步任务的回调会进入宏任务队列,这些异步函数包括:
  3. setTimeout
  4. setInterval
  5. setImmediate(node环境)
  6. requestAnimationFrame(浏览器)
  7. I/O
  8. UI rendering(浏览器)

微任务

microTask,也叫jobs,主要的工作如下:

  1. 微任务更新应用程序的状态,但是必须在浏览器任务继续执行其他任务之前执行
  2. 微任务使得我们能够在重新渲染UI之前执行指定的行为,避免不必要的UI的重绘,因为重绘会使得应用状态的不连续

微任务像是掺杂在各个宏任务之间的微小单元,是相对比较小的任务。一些异步任务的回调会进入微任务队列,这些异步函数包括:

  1. process.nextTick(node)
  2. Promise.then()
  3. catch
  4. finally
  5. Object.observe
  6. MutationObserver

这里我们需要注意点 new Promise(executor()).then(onResolved,onRejected),其中executor是同步执行函数,then中执行的回调函数onResolved,onRejected才是微任务,同时then的后面再接then的时候,第二个then的状态受制于第一个then返回的结果,在Promise的链式调用中,每执行一个方法执行完毕都会返回Promise

补充:NodeJS中的Event Loop

NodeJS也是单线程的Event Loop,但是它的运行机制不同于浏览器环境。
14.JS的宏任务、微任务和EventLoop - 图5
解释

  1. V8引擎解析JS脚本,并调用API
  2. libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
  3. V8再将结果返回给用户

在宏任务执行栈中,process.nextTick方法可以在当前”执行栈”的尾部—-下一次Event Loop(主线程读取”任务队列”)之前—-触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate方法则是在当前”任务队列”的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)很像。

  1. process.nextTick(function a(){
  2. console.log(1)
  3. process.nextTick(function b(){
  4. console.log(2)
  5. })
  6. })
  7. setTimeout(function c(){
  8. console.log('cccc')
  9. },0)
  10. // 1
  11. // 2
  12. // cccc

上述代码中,由于process.nextTick方法指定的回调函数,总是在当前“执行栈”的尾部触发,所以不仅函数asetTimeout指定的回调函数c先执行,而且函数b也比c先执行。这说明如果有多个process.nextTick语句(不管是否嵌套),将全部在当前的执行栈底执行,所以关于 process.nextTick,就只需要记住一点,那就是 process.nextTick 优先于其他的微任务执行
再看setImmediate

  1. setImmediate(function a() {
  2. console.log(1);
  3. setImmediate(function b(){console.log(2);});
  4. });
  5. setTimeout(function timeout() {
  6. console.log('cccc')
  7. }, 0)

setImmediatesetTimeout(fn,0)各自添加了一个回调函数ac,都是在下一次Event Loop触发。那么,哪个回调函数先执行呢?答案是不确定。运行结果可能是1–cccc–2,也可能是cccc–1–2。因为如果主进程中先注册了两个任务,然后执行的代码耗时超过XXs,而这时定时器已经处于可执行回调的状态了。所以会先执行定时器,而执行完定时器以后才是结束了一次Event Loop,这时才会执行setImmediate。

Node.js文档中称,setImmediate指定的回调函数,总是排在setTimeout前面。实际上,这种情况只发生在递归调用的时候。

  1. setImmediate(function (){
  2. setImmediate(function a() {
  3. console.log(1);
  4. setImmediate(function b(){console.log(2);});
  5. })
  6. setTimeout(function c() {
  7. console.log('cccc')
  8. }, 0)
  9. })
  10. // 1
  11. // TIMEOUT FIRED
  12. // 2

setImmediatesetTimeout被封装在一个setImmediate里面,它的运行结果总是1–cccc–2,这时函数a一定在c前面触发。至于2排在cccc的后面(即函数bc后面触发),是因为setImmediate总是将事件注册到下一轮Event Loop,所以函数ac是在同一轮Loop执行,而函数b在下一轮Loop执行。

事实上,现在要是你写出递归的process.nextTickNode.js会抛出一个警告,要求你改成setImmediate

关于 async/await 函数

async/await本质上还是基于Promise的一些封装,而Promise是属于微任务的一种。所以在使用await关键字与Promise.then效果类似

  1. setTimeout(() => console.log(4))
  2. async function main() {
  3. console.log(1)
  4. await Promise.resolve()
  5. console.log(3)
  6. }
  7. main()
  8. console.log(2)
  9. // 1,2,3,4

可以理解为,await 以前的代码,相当于与 new Promise 的同构代码,以后的代码相当于 Promise.then

JS的宏任务与微任务以及Event Loop机制可以让我在执行代码的时候更加清晰的知道其结果让我们能够更好的理解JS,当然关于这方面的面试题也是很多的,比如

  1. console.log('1');
  2. setTimeout(function() {
  3. console.log('2');
  4. process.nextTick(function() {
  5. console.log('3');
  6. })
  7. new Promise(function(resolve) {
  8. console.log('4');
  9. resolve();
  10. }).then(function() {
  11. console.log('5')
  12. })
  13. })
  14. process.nextTick(function() {
  15. console.log('6');
  16. })
  17. new Promise(function(resolve) {
  18. console.log('7');
  19. resolve();
  20. }).then(function() {
  21. console.log('8')
  22. })
  23. setTimeout(function() {
  24. console.log('9');
  25. process.nextTick(function() {
  26. console.log('10');
  27. })
  28. new Promise(function(resolve) {
  29. console.log('11');
  30. resolve();
  31. }).then(function() {
  32. console.log('12')
  33. })
  34. })

[相关参考:]

  1. 为什么javascript是单线程?
  2. JavaScript中的单线程运行,宏任务与微任务,EventLoop
  3. 微任务、宏任务与Event-Loop
  4. Tasks, microTasks, queues and schedules
  5. Understanding JS: The Event Loop