JavaScript 运行机制Event Loop(浏览器与node环境的不同)

前言

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

  • 同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
  • 异步任务:不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行

具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。

(3)一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步。

主线程从”任务队列”中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。但是浏览器的Event loop和Node的Event loop是两个概念。

在JavaScript中,“任务队列”任务队列被分为MacroTask(宏任务)和MicroTask(微任务)两种。它们分别包含以下内容:

  • MacroTask: script(整体代码), setTimeout, setInterval, setImmediate(node独有), I/O, UI rendering

  • MicroTask: process.nextTick(node独有), Promises, Object.observe(废弃), MutationObserver

浏览器中

在浏览器中”执行栈”中的所有同步任务执行完毕,去“任务队列”找要执行的任务,首先找MicroTask(微任务)队列中是否有要执行的任务,有则全部执行,然后再找MacroTask(宏任务)队列中是否有要执行的任务,如果有则执行MacroTask(宏任务)队列的第一个,再去执行MicroTask(微任务)队列中的所有微任务。

同步代码—>MicroTask—>MacroTask

每执行完一个Macrotask都会检查Microtask队列是否为空(执行完一个Macrotask的具体标志是函数执行栈为空),如果不为空则会一次性执行完所有Microtask。然后再进入下一个循环去MacroTask(宏任务)队列中取下一个MacroTask(宏任务)执行,以此类推。

  • 代码 1.1
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
console.log('开始')

setTimeout(() => {
 console.log('timer1')
 
 Promise.resolve().then(function () {
   console.log('promise2')
 })
}, 0)

setTimeout(() => {
 console.log('timer2')
}, 0)

Promise.resolve().then(function () {
   console.log('promise1')
 })

/*

开始
promise1
timer1
promise2
timer2

*/

解析:

1、首先主线程的执行栈开始工作,从上到下执行,输出“1、开始”,遇到第一个setTimeout,将其推到“任务队列”的“宏任务”队列中。遇到第二个setTimeout,也将其推到“任务队列”的“宏任务”队列中排在第二位。遇到Promise,将其推到“任务队列”的“微任务”队列中。至此主线程执行完毕,去“任务队列”找任务。

2、“任务队列”中“微任务”队列中有任务,先执行,输出“2、promise1”。微任务”队列中所有任务执行完,再去找“宏任务”队列。

3、执行“宏任务”队列的第一个任务(第一个setTimeout),输出“3、timer1”,将Promise推到“微任务”队列。执行完一个“宏任务”,执行所有“微任务”,输出“4、promise2”。

4、执行完所有“微任务后”,再去检查“宏任务”,执行第二个setTimeout,输出“5、timer2”。全部任务结束。

Node环境中

nodejs的event loop分为6个阶段,它们会按照顺序反复运行,分别如下:

  1. timers:执行setTimeout() 和 setInterval()中到期的callback。
  2. I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行
  3. idle, prepare:队列的移动,仅内部使用
  4. poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
  5. check:执行setImmediate的callback
  6. close callbacks:执行close事件的callback,例如socket.on(“close”,func)

不同于浏览器的是,在每个阶段完成所有阶段任务后(而不是MacroTask任务完成后,每个阶段可能会有好多任务,可以把6个阶段看做宏任务(MacroTask)的排序)microTask队列中的所有任务就会被执行。这就导致了同样的代码在不同的上下文环境下会出现不同的结果。

如上面代码1.1,在node环境下运行结果为。

1
2
3
4
5
6
7
8
9
/*

开始
promise1
timer1
timer2
promise2

*/

解析:

1、首先主线程的执行栈开始工作,从上到下执行,输出“1、开始”,遇到第一个setTimeout,将其推到“任务队列”的“timer阶段”队列中。遇到第二个setTimeout,也将其推到“任务队列”的“timer阶段”队列中排在第二位。遇到Promise,将其推到“任务队列”的“微任务”队列中。至此主线程执行完毕,去“任务队列”找任务。

2、“任务队列”中“微任务”队列中有任务,先执行,输出“2、promise1”。微任务”队列中所有任务执行完,再去找“timer阶段”队列。

3、执行“timer阶段”队列的第一个任务(第一个setTimeout),输出“3、timer1”,将Promise推到“微任务”队列。执行“timer阶段”队列的第二个任务(即第二个setTimeout),输出“4、timer2”。至此“timer阶段”的所有任务执行完毕。

4、执行“微任务”队列的任务,输出“5、promise2”全部任务结束。

  • 代码1.2
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
37
38
39
console.log('开始');
setTimeout(() => {
console.log('setTimeout');

process.nextTick(()=>{
console.log('我是setTimeout内部的process.nextTick');

})

setImmediate(()=>{
console.log('内部setImmediate');

})

}, 0);

process.nextTick(()=>{
console.log('我是外部的process.nextTick');

})

setImmediate(()=>{
console.log('setImmediate');

process.nextTick(()=>{
console.log('我是setImmediate内部的process.nextTick');

})

})
/*
开始
我是外部的process.nextTick
setTimeout
我是setTimeout内部的process.nextTick
setImmediate
内部setImmediate
我是setImmediate内部的process.nextTick
*/

解析:

(1) 先执行同步 输出“1、开始”,将setTimeout推到timer阶段的队列中,将process.nextTick推到micro-task(微任务),将setImmediate推到check 阶段的队列中

(2) 同步执行完毕,执行micro-task,执行process.nextTick,输出“2、我是外部的process.nextTick”,此时微服务队列中没有任务了。

(3)按6个阶段顺序执行,执行timer阶段队列中的任务,此时队列中只有setTimeout一个任务执行,输出“3、setTimeout”,将其内部的process.nextTick推送到micro-task(微任务)队列中,将其内部的setImmediate推送到check阶段的队列中(注意:此时check队列中有两个任务,一个是外部的setImmediate,一个是内部的setImmediate)。setTimeout(timer阶段)执行完毕,执行micro-task(微任务)队列,输出“4、我是setTimeout内部的process.nextTick”。micro-task(微任务)队列执行完毕。

(4)进入下一个阶段,由于没有i/o读写等操作,直接到check阶段,执行check阶段队列中的任务,执行外部的setImmediate,输出“5、setImmediate”,并将其内部的process.nextTick推送到micro-task(微任务)队列中,执行check阶段队列中的第二个任务setImmediate(内部的setImmediate),输出“6、内部的setImmediate”。check阶段队列中没有任务了,执行micro-task(微任务)队列中的任务,输出“7、我是setImmediate内部的process.nextTick”

总结

  1. 同一个上下文下,MicroTask会比MacroTask先运行
  2. 然后浏览器按照一个MacroTask任务,所有MicroTask的顺序运行,Node按照六个阶段的顺序运行,并在每个阶段后面都会运行MicroTask队列
  3. 同个MicroTask队列下process.tick()会优于Promise

写在最后

以上仅是我个人在学习过程中总结的自己的认识,难免会有错误,有任何问题还望不吝指出。

参考资料:

Xiuming Lee wechat
欢迎您扫一扫上面的微信公众号订阅,更多惊喜等着您哦!
-------------本文结束感谢您的阅读-------------