JS 事件轮询机制,以及宏任务队列与微任务队列

  • 2020-09-18
  • 0
  • 0

先看一道经典的面试题:

//请写出输出内容
async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
	console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');

/*
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/

这道题主要考察的是事件循环中函数执行顺序的问题,其中包括async ,await,setTimeout,Promise函数。下面来说一下本题中涉及到的知识点。

笔记结尾会分析该道题的逻辑,我们先复习一下题目涉及的知识点。

一、为什么JavaScript是单线程?

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

二、事件轮询(Event Loop)

JS主线程不断的循环往复的从任务队列中读取任务,执行任务,其中运行机制称为事件循环(event loop)。

Javascript的宿主环境中共通的一个“线程”,他们都有一种机制:在每次调用JS引擎时,可以随着时间的推移执行你的程序的多个代码块儿,这称为“事件轮询(Event Loop)”。

换句话说,JS引擎对 时间 没有天生的感觉,反而是一个任意JS代码段的按需执行环境。是它周围的环境在不停地安排“事件”(JS代码的执行)。

js实现异步的具体解决方案:

  1. 同步代码直接执行
  2. 异步函数到了指定时间再放到异步队列
  3. 同步执行完毕,异步队列轮询执行。

什么叫轮询?

精简版:当第一个异步函数执行完之后,再到异步队列监视。一直不断循环往复,所以叫事件轮询。

详细版:js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码…,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的原因。

事实上,事件轮询与宏任务和微任务密切相关。

三、宏任务和微任务

在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈…如此反复,进入循环。

我们只需记住当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。

在当前的微任务没有执行完成时,是不会执行下一个宏任务的。
所以就有了那个经常在面试题、各种博客中的代码片段:

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
})

console.log(2)

setTimeout就是作为宏任务来存在的,而Promise.then则是具有代表性的微任务,上述代码的执行顺序就是按照序号来输出的。
所有会进入的异步都是指的事件回调中的那部分代码
也就是说new Promise在实例化的过程中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。
在同步代码执行完成后才回去检查是否有异步任务完成,并执行对应的回调,而微任务又会在宏任务之前执行。
所以就得到了上述的输出结论1、2、3、4。

+部分表示同步执行的代码

+setTimeout(_ => {
-  console.log(4)
+})

+new Promise(resolve => {
+  resolve()
+  console.log(1)
+}).then(_ => {
-  console.log(3)
+})

+console.log(2)

本来setTimeout已经先设置了定时器(相当于取号),然后在当前进程中又添加了一些Promise的处理(临时添加业务)。

所以进阶的,即便我们继续在Promise中实例化Promise,其输出依然会早于setTimeout的宏任务:如示例二EXP2。

宏任务

常见宏任务 API:script(整体代码)、setTimeout、setInterval、I/O、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境)

特性:

  1. 宏任务所处的队列就是宏任务队列
  2. 第一个宏任务队列中只有一个任务:执行主线程上的JS代码;如果遇到上方表格中的异步任务,会创建出一个新的宏任务队列,存放这些异步函数执行完成后的回调函数。
  3. 宏任务队列可以有多个
  4. 宏任务中可以创建微任务,但是在宏任务中创建的微任务不会影响当前宏任务的执行。(示例EXP3)
  5. 当一个宏任务队列中的任务全部执行完后,会查看是否有微任务队列,如果有就会优先执行微任务队列中的所有任务,如果没有就查看是否有宏任务队列

微任务

常见微任务:Promise.then/catch/finally、MutaionObserver、process.nextTick(Node.js 环境)

特性:

  1. 微任务所处的队列就是微任务队列
  2. 在上一个宏任务队列执行完毕后,如果有微任务队列就会执行微任务队列中的所有任务
  3. new promise((resolve)=>{ 这里的函数在当前队列直接执行 }).then( 这里的函数放在微任务队列中执行 )
  4. 微任务队列上创建的微任务,仍会阻碍后方将要执行的宏任务队列 (示例二EXP2)
  5. 由微任务创建的宏任务,会被丢在异步宏任务队列中执行 (示例四EXP4)

四、举例说明示例(重要)

EXP1: 在主线程上添加宏任务与微任务

执行顺序:主线程 => 主线程上创建的微任务 => 主线程上创建的宏任务

console.log('-------start--------');

setTimeout(() => {
  console.log('setTimeout');  // 将回调代码放入另一个宏任务队列
}, 0);

new Promise((resolve, reject) => {
  for (let i = 0; i < 5; i++) {
    console.log(i);
  }
  resolve()
}).then(()=>{
  console.log('Promise实例成功回调执行'); // 将回调代码放入微任务队列
})

console.log('-------end--------');

结果:

-------start--------
0
1
2
3
4
-------end--------
Promise实例成功回调执行
setTimeout

由EXP1,我们可以看出,当JS执行完主线程上的代码,会去检查在主线程上创建的微任务队列,执行完微任务队列之后才会执行宏任务队列上的代码。

EXP2: 在微任务中创建微任务

执行顺序:主线程 => 主线程上创建的微任务1 => 微任务1上创建的微任务2 => 主线程上创建的宏任务

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
  Promise.resolve().then(_ => {
    console.log('before timeout')
  }).then(_ => {
    Promise.resolve().then(_ => {
      console.log('also before timeout')
    })
  })
})

console.log(2)

结果:

1
2
3
before timeout
also before timeout
4

由EXP1,我们可以看出,在微任务队列执行时创建的微任务,还是会排在主线程上创建出的宏任务之前执行。

EXP3: 宏任务中创建微任务

执行顺序:主线程 => 主线程上的宏任务队列1 => 宏任务队列1中创建的微任务

// 宏任务队列 1
setTimeout(() => {
  // 宏任务队列 2.1
  console.log('timer_1');
  setTimeout(() => {
    // 宏任务队列 3
    console.log('timer_3')
  }, 0)
  new Promise(resolve => {
    resolve()
    console.log('new promise')
  }).then(() => {
    // 微任务队列 1
    console.log('promise then')
  })
}, 0)

setTimeout(() => {
  // 宏任务队列 2.2
  console.log('timer_2')
}, 0)

console.log('========== Sync queue ==========')

// 执行顺序:主线程(宏任务队列 1)=> 宏任务队列 2 => 微任务队列 1 => 宏任务队列 3

结果:

========== Sync queue ==========
timer_1
new promise
promise then
timer_2
timer_3

EXP4:微任务队列中创建的宏任务

执行顺序:主线程 => 主线程上创建的微任务 => 主线程上创建的宏任务 => 微任务中创建的宏任务

异步宏任务队列只有一个,当在微任务中创建一个宏任务之后,他会被追加到异步宏任务队列上(跟主线程创建的异步宏任务队列是同一个队列)

// 宏任务1
new Promise((resolve) => {
  console.log('new Promise(macro task 1)');
  resolve();
}).then(() => {
  // 微任务1
  console.log('micro task 1');
  setTimeout(() => {
    // 宏任务3
    console.log('macro task 3');
  }, 0)
})

setTimeout(() => {
  // 宏任务2
  console.log('macro task 2');
}, 1000)

console.log('========== Sync queue(macro task 1) ==========');

结果:

========== Sync queue(macro task 1) ==========
micro task 1
macro task 3
macro task 2

五、总结

微任务队列优先于宏任务队列执行,微任务队列上创建的宏任务会被后添加到当前宏任务队列的尾端,微任务队列中创建的微任务会被添加到微任务队列的尾端。只要微任务队列中还有任务,宏任务队列就只会等待微任务队列执行完毕后再执行。

最后上一张几乎涵盖基本情况的例图和例子:

console.log('======== main task start ========');
new Promise(resolve => {
  console.log('create micro task 1');
  resolve();
}).then(() => {
  console.log('micro task 1 callback');
  setTimeout(() => {
    console.log('macro task 3 callback');
  }, 0);
})

console.log('create macro task 2');
setTimeout(() => {
  console.log('macro task 2 callback');
  new Promise(resolve => {
    console.log('create micro task 3');
    resolve();
  }).then(() => {
    console.log('micro task 3 callback');
  })
  console.log('create macro task 4');
  setTimeout(() => {
    console.log('macro task 4 callback');
  }, 0);
}, 0);

new Promise(resolve => {
  console.log('create micro task 2');
  resolve();
}).then(() => {
  console.log('micro task 2 callback');
})

console.log('======== main task end ========');

结果:

======== main task start ========
create micro task 1
create macro task 2
create micro task 2
======== main task end ========
micro task 1 callback
micro task 2 callback
macro task 2 callback
create micro task 3
create macro task 4
micro task 3 callback
macro task 3 callback
create macro task 4

面试题解析:

//请写出输出内容
async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');

/*
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/

执行结果解析:

  1. JS 主线程开始执行同步代码,首先执行 console.log('script start');
  2. setTimeout 为宏任务,所以将其回调函数放到宏任务队列中(先进先出);
  3. 执行异步函数 async1 中的同步语句 console.log('async1 start');
  4. await是一个让出线程的标志。await后面的表达式会先执行一遍,将await下一行开始至后面的代码加入到微任务中,然后就会跳出整个async1函数来执行后面的代码。
  5. 通过await async2();执行了console.log('async2');
  6. 因为 await 跳出了 async1 ,故紧接着执行 new Promise 中的同步语句 console.log('promise1'); 同时将 then 的回调函数加入到微任务队列;
  7. 执行最后一句同步语句 console.log('script end');
  8. 接着执行微任务队列中的回调:按照先进先出的顺序;
  9. 微任务队列清空之后接着执行宏任务队列中的回调函数;

提示:由于因为async await 本身就是promise+generator的语法糖。所以await后面的代码是微任务。await 等待的是一个表达式,这个表达式的返回值可以是一个promise对象也可以是其他值。所以对于本题中的:

async function async1() {
	console.log('async1 start');
	await async2();
	console.log('async1 end');
}

等价于:

async function async1() {
	console.log('async1 start');
	Promise.resolve(async2()).then(() => {
                console.log('async1 end');
        })
}

推荐阅读:

阮一峰:JavaScript 运行机制详解:再谈Event Loop

https://blog.csdn.net/u012925833/article/details/89306184
https://github.com/Advanced-Frontend/Daily-Interview-Question/issues/7
http://www.ruanyifeng.com/blog/2014/10/event-loop.html

评论

还没有任何评论,你来说两句吧