javascript中的事件循环和任务队列

1. 从一道面试题说起

相信大部人都遇到过下面这个题目:

1
2
3
4
5
6
// Q:请问这段代码会输出什么
for(var i=0;i<5;i++){
setTimeout(function(){
console.log(i);
}, 0);
}

这道题目有两个知识点:1.变量作用域 2.setTimeout的执行时机。

因为setTimeout内的函数会在for循环完成时再执行,且console.log(i)中的i在自己的函数作用域中未定义,只能使用上级作用域中的i。在经过几次循环之后i值是5,所以这段代码输出的结果是5 5 5 5 5

今天我们就来讨论一下javascript中类似setTimeout这种异步函数的执行时机问题。另外由于浏览器引擎和node的javascript执行机制并不完全相同,且不同版本的node之间也存在着差异,所以本文章所有内容基于当前最新的浏览器JS引擎(V8 8.4.371.23)。

2. js的任务执行过程

我们都知道javascript是单线程,一次只能同时执行一个任务。那么js处理任务的流程是什么样的呢。这里借用其他文章[1]看到例子,很好的解释了js中的代码是如何执行的。如果我们要执行下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function bar() {
console.log(1);
}

function foo() {
console.log(2);
bar();
}

setTimeout(() => {
console.log(3)
});

foo();

这段代码在栈上入栈、出栈的过程,如下图:

以上代码及执行流程图来源于引用文章

js引擎将创建一个栈作为代码执行空间,然后解析代码将当前需要执行的任务入栈,任务执行完毕后则出栈并将下一个需要执行的任务入栈。如此循环,直到当前所有代码执行完毕,此时执行任务的栈也会被清空。但是当我们遇到一些异步函数的时候,如何区分任务入栈的顺序呢,即如何区分任务的执行顺序呢。

3. 任务队列

在js中,有两类任务:宏任务(macro tasks)和微任务(micro tasks)。

宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering
微任务:process.nextTick, Promise, Object.observer, MutationObserver

宏任务中有立即执行的全局任务script,还有异步函数如setTimeout、setInterval等,异步任务不会立即执行,将会被添加到宏任务队列(macro task queue)中,代码中的微任务也不会立即执行会被添加到微任务队列(micro task queue)中。微任务队列只有一个而宏任务队列有多个,且微任务队列总是在宏任务队列的前执行,只有当微任务队列被清空时才会执行宏任务队列。

4. 事件循环

js引擎解析一遍代码,然后将代码分为宏任务和微任务,又将其中的任务分别添加到宏任务队列和微任务队列中。javascript是以事件循环(eventLoop)的机制执行任务,结合上面了解到的任务队列知识,我将事件循环执行的流程大致概括如下:

  1. JS引擎解析代码,并将代码中的任务分别添加到宏任务队列和微任务队列中。
  2. 首先将当前代码中的立即执行宏任务添加到执行栈中执行。
  3. 待当前执行栈中的任务队列清空后,判断当前的微任务队列是否有待执行的任务,如果有,则优先执行微任务队列中的任务。
  4. 直到当前微任务队列中的任务执行完毕并清空执行栈之后,才获取下一个宏任务队列开始执行。
  5. 当前执行栈中的宏任务执行完毕后,再次优先判断微任务队列中是否有任务。
  6. 如果有则优先执行微任务队列,如果无,则获取下一个宏任务队列执行。

通过上面的流程,我们可以分析一下代码的执行流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
setTimeout(function(){ // 添加到宏任务队列
console.log('定时器开始啦');
});

new Promise(function(resolve){ // 立即执行宏任务
console.log('马上执行for循环啦');
for(var i = 0; i < 10000; i++){
i == 99 && resolve();
}
}).then(function(){ // 添加到微任务队列
console.log('执行then函数啦');
});

console.log('代码执行结束'); // 立即执行宏任务
  1. JS引擎依次解析代码,将其中的任务分别添加到宏任务队列和微任务队列中。
  2. 首先执行当前代码中的立即执行任务new Promise和console.log(‘代码执行结束’),所以依次输出”马上执行for循环啦”和”代码执行结束”。
  3. 当前执行栈中的任务执行完毕之后寻找微任务队列中是否有存在的任务。
  4. 执行微任务队列中的任务,即执行console.log(‘执行then函数啦’),输出”执行then函数啦”
  5. 微任务队列清空后将宏任务队列中的任务添加到执行栈中执行,即setTimeout执行,输出”定时器开始啦”
  6. 每一个宏任务队列执行前都要检查是否有微任务可以执行,直到全部任务执行完毕。

故上面这个代码的输出依次为:马上执行for循环啦 -> 代码执行结束 -> 执行then函数啦 -> 定时器开始啦。

我们再看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
console.log('script start'); // 立即执行宏任务

// 添加到微任务队列
Promise.resolve().then(() => {
console.log('p 1');
});

// 添加到宏任务队列
setTimeout(() => {
console.log('setTimeout');
}, 0);

var s = new Date();
while(new Date() - s < 50); // 人为阻塞50ms

// 添加到微任务队列
Promise.resolve().then(() => {
console.log('p 2');
});

// 立即执行宏任务
console.log('script end');

上述代码的执行顺序依次为:script start -> script end -> p 1 -> p 2-> setTimeout。

再来个更复杂的例子练习一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 添加到宏任务队列
setTimeout(() => {
console.log('setTimeout1')
}, 0);

// 添加到宏任务队列
setTimeout(() => {
// 宏任务队列执行后的立即执行任务
console.log('setTimeout2');
// 宏任务队列执行后添加到微任务队列
Promise.resolve().then(() => {
// 立即执行函数
console.log('promise3');
// 添加到微任务队列
Promise.resolve().then(() => {
console.log('promise4');
});
// 立即执行宏任务
console.log(5)
});

// 宏任务队列
setTimeout(() => {
console.log('setTimeout4')
}, 0); //4宏任务
}, 0);

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

// 微任务队列
Promise.resolve().then(() => {
console.log('promise1');
});

该段代码依次输出:promise1 -> setTimeout1 -> setTimeout2 -> promise3 -> 5 -> promise4 -> setTimeout3 -> setTimeout4。

整个代码的任务执行流程应该是:

  1. JS引擎依次解析代码,发现整个代码分成四个异步函数,三个setTimeout添加到宏任务队列,一个Promise.resolve添加到微任务队列,没有立即执行任务。
  2. 优先执行微任务队列,所以最先输出”promise1”,并清空此时的微任务队列。
  3. 执行宏任务队列中的任务,即第一个setTimeout,输出”setTimeout1”。
  4. 第一个setTimeout执行完毕后,判断微任务队列是否有任务,此时是没有的,所以继续执行下一个宏任务。
  5. 执行下一个setTimeout函数,立即执行console输出”setTimeout2”,并将promise.resolve添加到微任务队列中,将setTimeout添加到宏任务队列中。
  6. 该宏任务执行完毕后微任务队列不为空,此时需要优先执行微任务,注意该微任务中还有一个promise.resolve微任务,会被添加到微任务队列中立即执行,最终该段代码依次执行并输出”promise3” -> “5” -> “promise4”。
  7. 最后整个任务队列里没有微任务,还有两个宏任务,依次执行并输出”setTimeout3”和”setTimeout4”。

5. async和await

async和await是ES7中新增的语法糖,可以使用同步代码的方式来实现异步任务。async函数会返回一个promise对象,并运行执行异步函数的结果(resolve/reject)。await只能在async函数内部执行,用于等待一个任意值或promise对象,返回promise对象的处理结果。如果等待的不是promise对象,则把该值转换成已正常处理的promise并返回。如果代码中包括async和await,javascript的执行顺序又会是什么样的呢。我们先看一个简单的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
async function fn1() {
console.log('fn1 start');
await fn2();
console.log('fn1 end');
}

async function fn2() {
console.log('fn2 start');
}

console.log('script start');
fn1();
console.log('script end');

先说结果,执行代码依次输出:script start -> fn1 start -> fn2 start -> script end -> fn1 end。因为在函数fn1中遇到await fn2,代码会让出当前线程优先执行fn2函数,async fn2执行完毕后返回的是一个Promise对象,fn1内部在await之后的代码需要等到该promise对象fullfilled之后才会执行。加上我们之前了解到的任务队列的知识就可以得出代码执行顺序。

接着看个稍微复杂些的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function fn1() {
console.log("fn1 start"); // 立即执行任务
await fn2(); // fn2理解执行任务 后面的任务等同于promise.resolve 需要添加到微任务队列
console.log("fn1 end"); // 微任务队列
}

async function fn2() { // 立即执行任务
console.log("fn2 start");
}

console.log("script start"); // 立即执行任务

setTimeout(function() {
console.log("setTimeout"); // 宏任务队列
}, 0);

fn1(); // 立即执行任务

new Promise(function(resolve) {
console.log("promise1"); // 立即执行任务
resolve();
}).then(function() { // 微任务队列
console.log("promise2");
});

console.log("script end"); // 立即执行任务

这个例子中同时有async/await/promise,但是我们只要记得await得到的是一个经过promise处理后的结果,然后再结合之前学习的任务队列执行知识就可以判断出代码执行顺序:script start -> fn1 start -> fn2 start -> promise1 -> script end -> fn1 end -> promise2 -> setTimeout。

网络上有一些文章会将fn1 end和promise2的执行顺序搞反,经过理论分析和实际验证正确的执行流程应该是上面的结果。


参考:


原文链接:https://tech.gtxlab.com/javascript-evenLoop.html

作者简介: 宫晨光,人和未来大数据前端工程师。