JavaScript中的单线程运行,宏任务与微任务,EventLoop

在前端的面试中经常会问到关于代码执行顺序的问题,尤其是下面的一段代码

1
2
3
4
5
6
7
8
9
10
11
12
setTimeout( () => console.log(4))

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

Promise.resolve(5).then(() => console.log(5))

console.log(2)

问题是:在浏览器上面 1 2 3 4 5 的打印的顺序。

上面这个问题看起来对有的同学可能很简单,到有的同学可能会比较复杂。对你不管是复杂还是简单,这其中涉及到的只是点都是一样的。JavaScript单线程,宏任务与微任务,EventLoop。这些就是这个题目的考点,理解了这些,那么上面的这道题对你来说那就是信手拈来,游刃有余。

我猜你应该知道,JavaScript除了在浏览器环境中运行,还可以在Node环境中运行,虽说都是JavaScript代码,但是在这两种环境下面执行的结果是可能不一样的。所以,我们需要分两种情况来分析他们的EventLoop。

什么是EventLoop

EventLoop是一个执行模型,在不同的有不同的实现,浏览器和NodeJS基于不同的技术实现了各自的EventLoop。

  • 浏览器的EventLoop是在HTML5规范中明确定义了的
  • NodeJS的EventLoop是基于libuv实现的。可以在libuv官网NodeJS官网查看
  • libuv已经对NodeJS的EventLoop做出了实现,但是浏览器的HTML5规范只是定义了EventLoop的实现模型,具体的实现留给了浏览器厂商。

JavaScript中的单线程

JavaScript是单线程脚本语言。所以,在一行代码的执行过程过,必然不会执行另一行代码的,就行你在使用了alert(1)以后在后面疯狂的console.log(),如果执行到 alert(1),你没有关闭这个弹窗,后面的console.log()是永远都不会执行的,因为 alert() 这个任务还没有执行完成,下面的代码没法执行。通俗一点就是:如果你去食堂打饭,前面排了很长的队,如果你想要打到饭,那么你需要等前面的小可爱都能够顺利的打完饭才可以,你是不能够插队的。那什么是宏任务,什么又是微任务呢?

同样是打饭的例子,你要打饭这件事请就是宏任务。这是一个大的事件。当轮到你打饭的时候,事件执行到你这里了,这个时候阿姨开始给你打饭,后面的同学还在等待着。但是你去打饭不单单的就是打饭,你会询问每种菜是什么,价格是多少,有没有XXX菜,有没有汤一样,那这些询问可以比作是微任务。当你的宏任务与微任务都执行完成了,相当于你的这一轮时间执行完成,这个时候开始执行下一轮事件,也就是下一个同学开始打饭了。同样的,下面的一轮循环中也可能存在微任务。

通过上面的例子,如果能有大概的明白了什么是宏任务,什么是微任务了。

宏任务

macrotask,也叫 tasks,主要的工作如下

  • 创建主文档对象,解析HTML,执行主线或者全局的javascript的代码,更改url以及各种事件。
  • 页面加载,输入,网络事件,定时器。从浏览器角度看,宏任务是一个个离散的,独立的工作单元。
  • 运行完成后,浏览器可以继续其他调度,重新渲染页面的UI或者去执行垃圾回收

一些异步任务的回调会以此进入 macrotask queue(宏任务队列),等等后续被调用,这些异步函数包括:

  • setTimeout
  • setInterval
  • setImmediate (Node)
  • requestAnimationFrame (浏览器)
  • I/O
  • UI rendering (浏览器)

微任务

microtask,也叫 jobs,注意的工作如下

  • 微任务是更小的任务,微任务更新应用程序的状态,但是必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面的UI。
  • 微任务包括Promise的回调函数,DOM发生变化等,微任务需要尽可能快地,通过异步方式执行,同时不能产生全新的微任务。
  • 微任务能使得我们能够在重新渲染UI之前执行指定的行为,避免不必要的UI重绘,UI重绘会使得应用状态不连续

另一些异步回调会进入 microtask queue(微任务队列) ,等待后续被调用,这些异步函数包括:

  • process.nextTick (Node)
  • Promise.then()
  • catch
  • finally
  • Object.observe
  • MutationObserver

这里有一点需要注意的:Promise.then()new Promise(() => {}).then() 是不同的,前面的是一个微任务,后面的 new Promise() 这一部分是一个构造函数,这是一个同步任务,后面的 .then() 才是一个微任务,这一点是非常重要的。

浏览器中的EventLoop

关于宏任务与微任务我们看看下面的执行流程
JavaScript EventLoop

最开始有一个执行栈,当执行到带有异步操作的宏任务的时候,比如 setTimeout 的时候就会将这个异步任务存在背景线程里面,待本次的事件执行完成以后再去执行微任务。即图中 Stack --> Background Thread。但是需要注意到,从 Stack --> Microtask Queue 还有一条路线,意思就是在当前这轮的任务中还有执行微任务的操作。当前轮的微任务优先于宏任务异步操作先执行,执行完成到 loop 中,进入到下一轮。下一轮执行之前的宏任务的异步操作,比如 setTimeout 。此时,如果这个异步任务中还有微任务,那么就会执行完成这个微任务,在执行下一个异步任务。就这样一次的循环。

回到最开始的那道题上面

1
2
3
4
5
6
7
8
9
10
11
12
setTimeout( () => console.log(4))

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

Promise.resolve(5).then(() => console.log(5))

console.log(2)

整个这一串代码我们所在的层级我们看做一个任务,其中我们先执行同步代码。第一行的 setTimeout 是异步代码,跳过,来到了 new Promise(...) 这一段代码。前面提到过,这种方式是一个构造函数,是一个同步代码,所以执行同步代码里面的函数,即 console.log(1),接下来是一个 then 的异步,跳过。在往下,是一个Promise.then() 的异步,跳过。最后一个是一段同步代码 console.log(2)。所以,这一轮中我们知道打印了1, 2两个值。接下来进入下一步,即之前我们跳过的异步的代码。从上午下,第一个是 setTimeout,还有两个是 Promise.then()setTimeout 是宏任务的异步,Promise.then()是微任务的异步,微任务是优先于宏任务执行的,所以,此时会先跳过 setTimeout 任务,执行两个 Promise.then() 的微任务。所以此时会执行 console.log(3)console.log(5) 两个函数。最后就只剩下 setTimeout 函数没有执行,所以最后执行 console.log(4)

综上:最后的执行结果是 1, 2, 3, 5, 4

这只是我们的推测的结果,我们来看看在浏览器中的实际的打印结果是什么?
浏览器中的执行结果

从图中可以看到,实际的运行结果与我们推测的结果是一一致的。所以,我们上面的分析步骤是正确的。

但是有一个问题,什么呢?可以看到,在浏览器中,会有一个 undefined 的返回值。为什么呢?这是因为浏览器将上面的一整段代码当成一个函数,而这个函数执行完成以后返回了 undefined。那么?这就完了吗?没有。我们看看浏览器返回的截图中,3,5 两个数字其实是在 undefined 前面。3,5两个数是两个 Promise.then() 中的 console.log() 的打印值,而 undefined 在这里可以作为一轮任务的结束。这表明的意思就是,微任务会在下一轮任务开始前执行

这一切都是针对于浏览器的EventLoop。在NodeJS的环境中,可能就会有不同的结果。至于结果如何,我们暂时先不讨论,在来看一段代码。

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
setTimeout( () => {
new Promise(resolve => {
resolve()
console.log(4)
}).then(() => {
console.log(7)
})
})

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

setTimeout( () => {
Promise.resolve(6).then(() => console.log(6))
new Promise(resolve => {
resolve()
console.log(8)
}).then(() => {
console.log(9)
})
})

Promise.resolve(5).then(() => console.log(5))

console.log(2)

在浏览器中执行结果:

上面就是关于在浏览器中的EventLoop。附上浏览器上面的可视化操作

NodeJS中的EventLoop

虽然NodeJS中的JavaScript运行环境也是V8,也是单线程,但是,还是有一些与浏览器中的表现是不一样的。
NodeJS中的EventLoop

上面的图片的上半部分来自NodeJS官网。下面的图片来自互联网。

同样的两段代码,我们在node环境中执行一下,看看结果。
第一段代码的运行结构

从上面的图中可以看到,实际的运行结果与浏览器中的运行结果并无二致。

在来看看另一段代码

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
setTimeout( () => {
new Promise(resolve => {
resolve()
console.log(4)
}).then(() => {
console.log(7)
})
})

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

setTimeout( () => {
Promise.resolve(6).then(() => console.log(6))
new Promise(resolve => {
resolve()
console.log(8)
}).then(() => {
console.log(9)
})
})

Promise.resolve(5).then(() => console.log(5))

console.log(2)

他的执行结果是:1,2,3,5,4,8,7,6,9
与浏览器的1,2,3,5,4,7,8,6,9不同。

对比浏览器与NodeJS的不同

在大部分情况下,浏览器与NodeJS的运行没有区别,唯一有区别的是在第二轮事件执行的时候,如果有多个宏任务(setTimeout),浏览器会依次的执行宏任务,上一个宏任务执行完成了在执行下一个宏任务。在NodeJS中,则是相当于并行执行,相当于把所有的宏任务组合到一个宏任务中,再在这个组合后宏任务中,依次执行同步代码 --> 微任务 --> 宏任务

NodeJS中的process.nextTick

关于 process.nextTick,就只需要记住一点,那就是 process.nextTick 优先于其他的微任务执行

所以,下面的代码中:

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
console.log('1');

setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})

setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})

分析(以Node作为运行环境,因为process在node中才存在):

第一轮事件循环流程:

  • 整体的script代码作为第一个宏任务进入主线程,执行同步代码,遇到console.log(1),输出 1
  • 遇到setTimeout,其回调函数被分发到宏任务的 Event Queue 中,等待执行。这里标记为setTimeout1
  • 遇到process.nextTick,其回调函数被分发到微任务的 Event Queue 中,等待执行。
  • 遇到new Promise,这是一个构造函数,new Promise构造函数直接执行,遇到console.log(7),输出 7。接着Promise.then()函数被分发到微任务的 Event Queue 中,等待执行。
    遇到setTimeout,其回调函数被分发到宏任务的 Event Queue 中,等待执行。这里标记为setTimeout2

将上面的统计一下

宏任务Event Queue 微任务Event Queue
setTimeout1 process.nextTick
setTimeout2 Promise.then()

第一轮事件循环同步代码执行完成,接下来执行微任务

微任务有两个,一个是 process.nextTick ,里一个是 Promise.then()

前面说了,process.nextTick优先于其他的微任务执行,所以

  • 执行process.nextTick:输出 6
  • 执行Promise.then():输出 8

到此,第一轮事件循环结束,最终第一轮事件的输出为 1,7,6,8。开始执行第二轮事件循环(setTimeout)。

第二轮事件循环分析

  • setTimeout1setTimeout2 中先找同步代码
  • setTimeout1 中遇到 console.log(2),输出2
  • setTimeout1 中遇到 process.nextTick,放在第二轮的微任务的Event Queue中,等待执行。这里标记为process_1
  • setTimeout1 中遇到 new Promise ,执行同步代码,输出 4, Promise.then() 放到微任务的Event Queue中,等待执行。这里标记为Promise_1
  • setTimeout2 中遇到 console.log(9),输出9
  • setTimeout2 中遇到 process.nextTick,放在第二轮的微任务的Event Queue中,等待执行。这里标记为process_2
  • setTimeout2 中遇到 new Promise ,执行同步代码,输出 11, Promise.then() 放到微任务的Event Queue中,等待执行。这里标记为Promise_2

第二轮的统计

第二轮宏任务Event Queue 第二轮微任务Event Queue
process_1
Promise_1
process_2
Promise_2

第二轮没有事件循环中没有宏任务,有四个微任务。

四个微任务中,有两个 process

  • 依次执行 process_1process_2。输出:3, 10
  • 一次执行 Promise_1Promise_2。输出:5, 12

所以第二轮输出:2,4,9,11,3,10,5,12

最终的输出为:1,7,6,8,2,4,9,11,3,10,5,12

如果是在浏览器中,排除掉process的输出,结果为:1,7,8,2,4,5,9,11,12

NodeJS中 setImmediate 与 setTimeout 的区别

在官方文档中的定义,setImmediate 为一次Event Loop执行完毕后调用。setTimeout 则是通过计算一个延迟时间后进行执行。

但是同时还提到了如果在主进程中直接执行这两个操作,很难保证哪个会先触发。因为如果主进程中先注册了两个任务,然后执行的代码耗时超过XXs,而这时定时器已经处于可执行回调的状态了。所以会先执行定时器,而执行完定时器以后才是结束了一次Event Loop,这时才会执行setImmediate。

1
2
setTimeout(() => console.log('setTimeout'))
setImmediate(() => console.log('setImmediate'))

node环境下执行上面的代码,可以看到如下结果
执行结构

这两个console的结果是随机的。

我们可以通过一些处理,使得我们可以先执行 setTimeout 或者是 setImmediate

但是如果后续添加一些代码以后,就可以保证setTimeout一定会在setImmediate之前触发了:

1
2
3
4
5
6
setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

let countdown = 1e9

while(countdonn--) { } // 我们确保这个循环的执行速度会超过定时器的倒计时,导致这轮循环没有结束时,setTimeout已经可以执行回调了,所以会先执行`setTimeout`再结束这一轮循环,也就是说开始执行`setImmediate`

如果在另一个宏任务中,必然是setImmediate先执行:

1
2
3
4
5
6
require('fs').readFile(__dirname, _ => {
setTimeout(_ => console.log('timeout'))
setImmediate(_ => console.log('immediate'))
})

// 如果使用一个设置了延迟的setTimeout也可以实现相同的效果

上面的为什么有这样的解决方法,从上面的定义中就可以看出来。

关于 async/await 函数

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

1
2
3
4
5
6
7
8
9
10
11
setTimeout(() => console.log(4))

async function main() {
console.log(1)
await Promise.resolve()
console.log(3)
}

main()

console.log(2)

输出的结果是:1,2,3,4

可以理解为,await 以前的代码,相当于与 new Promise 的同构代码,以后的代码相当于 Promise.then。到await的时候就会执行await后面的函数(相当于和前面的代码同步执行)。

总结

之前了解过JavaScript单线程,也了解过JavaScript代码的执行顺序,但是宏任务与微任务也是最近才听说的,这对于一个从事两年前端的开发者真的是,我自己的过失。或需又是因为我是转行的,没有过相关的基础,没有接触到这方面的只是。不过现在我很高兴,因为我对JavaScript的执行有了更多的了解,相比于之前的只是,真的是了解了很多。学习永远都不晚,就怕你从来都不想去了解。在了解EventLoop,宏任务与微任务,JavaScript单线程的时候,参考了一些文档

文章作者: 踏浪
文章链接: https://blog.lyt007.cn/技术/JavaScript中的单线程运行.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 踏浪 - 前端技术分享
支付宝
微信打赏