JS中的单线程运行,宏任务、微任务、EventLoop详解

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

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 5 4 的打印的顺序。

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

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

1.什么是EventLoop

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

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

2.JavaScript中的单线程

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

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

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

3.宏任务

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

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

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

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

4.微任务

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() 才是一个微任务,这一点是非常重要的。

5.浏览器中的EventLoop

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

在这里插入图片描述
最开始有一个执行栈,当执行到带有异步操作的宏任务的时候,比如 setTimeout 的时候就会将这个异步任务丢到背景线程里面,待本次执行栈的任务执行完成以后再去执行宏任务。即图中 Stack --> Background Thread。但是需要注意到,也有一条路线 Stack --> Microtask Queue,意思就是在当前轮的执行栈的任务中也有些微任务,那么这些微任务会添加到微任务队列中,当该轮执行栈为执行完后就会执行微任务队列。也就是说从执行栈中丢到微任务队列中的微任务会优先于宏任务异步操作先执行,执行完成到loop中,等待下一轮执行栈的执行。直接被执行栈丢到微任务队列中的任务执行完后,就该执行宏任务队列中的异步操作,比如setTimeout。此时,如果这个异步的宏任务中还有微任务,那么就会直接执行完成这个微任务,再去执行宏任务队列中下一个异步操作。当宏任务队列清空后,一轮执行就结束了,开始进入下一轮循环。

回到最开始的那道题上面:

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两个值。接下来进入下一步,执行被我们丢到微任务队列的代码,因为微任务是优先于宏任务执行的,所以,此时执行两个 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的环境中,可能就会有不同的结果。至于结果如何,我们暂时先不讨论,在来看一段代码。

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)

执行结果:

在这里插入图片描述

6.NodeJS中的EventLoop

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

在这里插入图片描述
上面的图片的上半部分来自NodeJS官网。下面的图片来自互联网。

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

在这里插入图片描述

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

再来看看另一段代码:

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 优先于其他的微任务执行

所以,下面的代码中:

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

第一轮统计:

在这里插入图片描述
第一轮事件循环同步代码执行完成,接下来执行微任务

微任务有两个,一个是 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

第二轮统计:

在这里插入图片描述
第二轮没有事件循环中没有宏任务,有四个微任务。

四个微任务中,有两个 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。

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

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

在这里插入图片描述
这两个console的结果是随机的。

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

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

setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

let countdown = 1e9

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

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

require('fs').readFile(__dirname, _ => {
    
    
  setTimeout(_ => console.log('timeout'))
  setImmediate(_ => console.log('immediate'))
})

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

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

关于 async/await 函数

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

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

转载于云社区的踏浪。

猜你喜欢

转载自blog.csdn.net/weixin_43334673/article/details/109627290