运行原理——EventLoop
Tao. Lv2

img

1.进程与线程

概念

相信大家经常会听到 JS 是单线程执行的,但是你是否疑惑过什么是线程?

百度百科:

进程

狭义定义:进程是正在运行的程序的实例(an instance of a computer program that is being executed)。

广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。

线程

是进程中的实际运作单位

讲到线程,那么肯定也得说一下进程。本质上来说,两个名词都是 CPU 工作时间片的一个描述。
进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中 的更小单位,描述了执行一段指令所需的时间。
把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线 程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请 求结束后,该线程可能就会被销毁。
上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程 是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安 全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间,没有锁的问题的好处。当然前面两点在服务端中更容易体现,对于锁的问题,形象的来说就是当我读取一个数 字 15 的时候,同时有两个操作对数字进行了加减,这时候结果就出现了错误。解决这个问题也不难,只需要在读 取的时候加锁,直到读取完毕之前都不能进行写入操作

多线程

在最新的HTML5中提出了Web-Worker,但javascript是单线程这一核心仍未改变。所以一切javascript版的”多线程”都是用单线程模拟出来的,一切javascript多线程都是纸老虎!

2.执行栈(Call stack)

可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。

执行栈也称为调用栈,调用栈是解释器(比如浏览器中的 JavaScript 解释器)追踪函数执行流的一种机制。当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数

举个栗子:

img

执行顺序:

首先会执行main函数,然后

  1. 忽略前面所有函数,直到bar()被调用
  2. 把bar()添加进调用栈列表
  3. 执行bar()
  4. 执行到foo()时,该函数被调用
  5. 把foo()添加进调用栈列表
  6. 执行foo()函数体中的代码,直到全部执行完毕

总结

  • 每调用一个函数,解释器就会把该函数添加进调用栈并开始执行。
  • 正在调用栈中执行的函数还调用了其它函数,那么新函数也将会被添加进调用栈,一旦这个函数被调用,便会立即执行。
  • 当前函数执行完毕后,解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码。
  • 当分配的调用栈空间被占满时,会引发“堆栈溢出”错误。

3.宏任务与微任务

区别

栗子

这个就像去银行办业务一样,先要取号进行排号。
一般上边都会印着类似:“您的号码为XX,前边还有XX人。”之类的字样。

因为柜员同时职能处理一个来办理业务的客户,这时每一个来办理业务的人就可以认为是银行柜员的一个宏任务来存在的,当柜员处理完当前客户的问题以后,选择接待下一位,广播报号,也就是下一个宏任务的开始。
所以多个宏任务合在一起就可以认为说有一个任务队列在这,里边是当前银行中所有排号的客户。
任务队列中的都是已经完成的异步操作,而不是说注册一个异步任务就会被放在这个任务队列中,就像在银行中排号,如果叫到你的时候你不在,那么你当前的号牌就作废了,柜员会选择直接跳过进行下一个客户的业务处理,等你回来以后还需要重新取号

而且一个宏任务在执行的过程中,是可以添加一些微任务的,就像在柜台办理业务,你前边的一位老大爷可能在存款,在存款这个业务办理完以后,柜员会问老大爷还有没有其他需要办理的业务,这时老大爷想了一下:“最近P2P爆雷有点儿多,是不是要选择稳一些的理财呢”,然后告诉柜员说,要办一些理财的业务,这时候柜员肯定不能告诉老大爷说:“您再上后边取个号去,重新排队”。
所以本来快轮到你来办理业务,会因为老大爷临时添加的“理财业务”而往后推。
也许老大爷在办完理财以后还想 再办一个信用卡?或者 再买点儿纪念币
无论是什么需求,只要是柜员能够帮她办理的,都会在处理你的业务之前来做这些事情,这些都可以认为是微任务。

这就说明:在当前的微任务没有执行完成时,是不会执行下一个宏任务的。

所以就有了那个经常在面试题、各种博客中的代码片段:

1
2
3
4
5
6
7
8
9
10
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

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

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)
+})

+console.log(2)

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)

当然了,实际情况下很少会有简单的这么调用Promise的,一般都会在里边有其他的异步操作,比如fetchfs.readFile之类的操作。
而这些其实就相当于注册了一个宏任务,而非是微任务。

P.S. 在Promise/A+的规范中,Promise的实现可以是微任务,也可以是宏任务,但是普遍的共识表示(至少Chrome是这么做的),Promise应该是属于微任务阵营的

其他区别

宏任务是由宿主(浏览器、Node)发起的,而微任务由 JS 自身发起

所以,明白哪些操作是宏任务、哪些是微任务就变得很关键,这是目前业界比较流行的说法:

宏任务

# 浏览器 Node
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame
script(整体代码块)

有些地方会列出来UI Rendering,说这个也是宏任务,可是在读了HTML规范文档以后,发现这很显然是和微任务平行的一个操作步骤
requestAnimationFrame姑且也算是宏任务吧,requestAnimationFrameMDN的定义为,下次页面重绘前所执行的操作,而重绘也是作为宏任务的一个步骤来存在的,且该步骤晚于微任务的执行

如何理解script(整体代码块)是个宏任务呢?

实际上如果同时存在两个 script 代码块,会首先在执行第一个 script 代码块中的同步代码,如果这个过程中创建了微任务并进入了微任务队列,第一个 script 同步代码执行完之后,会首先去清空微任务队列,再去开启第二个 script 代码块的执行。所以这里应该就可以理解 script(整体代码块)为什么会是宏任务。

微任务

# 浏览器 Node
process.nextTick
MutationObserver
Promise.then catch finally

4.EventLoop

EventLoop是一种防止js造成阻塞的程序执行机制

上边一直在讨论 宏任务、微任务,各种任务的执行。
但是回到现实,JavaScript是一个单进程的语言,同一时间不能处理多个任务,所以何时执行宏任务,何时执行微任务?我们需要有这样的一个判断逻辑存在。

每办理完一个业务,柜员就会问当前的客户,是否还有其他需要办理的业务。(检查还有没有微任务需要处理)
而客户明确告知说没有事情以后,柜员就去查看后边还有没有等着办理业务的人。(结束本次宏任务、检查还有没有宏任务需要处理)
这个检查的过程是持续进行的,每完成一个任务都会进行一次,而这样的操作就被称为Event Loop。**(这是个非常简易的描述了,实际上会复杂很多)**

浏览器

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
console.log('script start');

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

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

首先我们划分几个分类:

第一次执行:

1
2
3
4
5
6
Tasks:run script、 setTimeout callback

Microtasks:Promise then

JS stack: script
Log: script start、script end。

执行同步代码,将宏任务(Tasks)和微任务(Microtasks)划分到各自队列中。

第二次执行:

1
2
3
4
5
6
Tasks:run script、 setTimeout callback

Microtasks:Promise2 then

JS stack: Promise2 callback
Log: script start、script end、promise1、promise2

执行宏任务后,检测到微任务(Microtasks)队列中不为空,执行Promise1,执行完成Promise1后,调用Promise2.then,放入微任务(Microtasks)队列中,再执行Promise2.then

第三次执行:

1
2
3
4
5
6
Tasks:setTimeout callback

Microtasks:

JS stack: setTimeout callback
Log: script start、script end、promise1、promise2、setTimeout

当微任务(Microtasks)队列中为空时,执行宏任务(Tasks),执行setTimeout callback,打印日志。

第四次执行:

1
2
3
4
5
6
Tasks:setTimeout callback

Microtasks:

JS stack:
Log: script start、script end、promise1、promise2、setTimeout

清空Tasks队列和JS stack

以上执行帧动画可以查看Tasks, microtasks, queues and schedules
或许这张图也更好理解些。

img

执行步骤:
1.执行main函数,执行同步代码(注意异步代码是指事件回调中的那部分代码)

2.执行微任务(整个script脚本作为宏任务)

3.调用第一个宏任务,执行完后调用其微任务

4.再调用下一个宏任务,直至所有代码执行完毕

详细解析版:

  1. 执行全局Script同步代码,这些同步代码有一些是同步语句,有一些是异步语句(比如setTimeout等);
  2. 全局Script代码执行完毕后,调用栈Stack会清空;
  3. 从微队列microtask queue中取出位于队首的回调任务,放入调用栈Stack中执行,执行完后microtask queue长度减1;
  4. 继续取出位于队首的任务,放入调用栈Stack中执行,以此类推,直到直到把microtask queue中的所有任务都执行完毕。注意,如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行
  5. microtask queue中的所有任务都执行完毕,此时microtask queue为空队列,调用栈Stack也为空;
  6. 取出宏队列macrotask queue中位于队首的任务,放入Stack中执行;
  7. 执行完毕后,调用栈Stack为空;
  8. 重复第3-7个步骤;
  9. 重复第3-7个步骤;
  10. ……

自我检验

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')
})
})

输出结果:

1,7,6,8,2,4,3,5,9,11,10,12

该结果应在node环境下执行

Node

image-20220220160055189

Node中的Event Loop是基于libuv实现的,而libuvNode 的新跨平台抽象层,libuv使用异步,事件驱动的编程方式,核心是提供i/o的事件循环和异步回调。libuv的API包含有时间,非阻塞的网络,异步文件操作,子进程等等。 Event Loop就是在libuv中实现的。

image-20220220155938728

NodeEvent loop一共分为6个阶段,每个细节具体如下:

  • timers: 执行setTimeoutsetInterval中到期的callback
  • pending callback: 上一轮循环中少数的callback会放在这一阶段执行。
  • idle, prepare: 仅在内部使用。
  • poll: 最重要的阶段,执行pending callback,在适当的情况下回阻塞在这个阶段。
  • check: 执行setImmediate(setImmediate()是将事件插入到事件队列尾部,主线程和事件队列的函数执行完成之后立即执行setImmediate指定的回调函数)的callback
  • close callbacks: 执行close事件的callback,例如socket.on('close'[,fn])或者http.server.on('close, fn)

具体细节如下:

timers

执行setTimeoutsetInterval中到期的callback,执行这两者回调需要设置一个毫秒数,理论上来说,应该是时间一到就立即执行callback回调,但是由于system的调度可能会延时,达不到预期时间。
以下是官网文档解释的例子:

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
const fs = require('fs');

function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
const delay = Date.now() - timeoutScheduled;

console.log(`${delay}ms have passed since I was scheduled`);
}, 100);


// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();

// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});

当进入事件循环时,它有一个空队列(fs.readFile()尚未完成),因此定时器将等待剩余毫秒数,当到达95ms时,fs.readFile()完成读取文件并且其完成需要10毫秒的回调被添加到轮询队列并执行。
当回调结束时,队列中不再有回调,因此事件循环将看到已达到最快定时器的阈值,然后回到timers阶段以执行定时器的回调。

在此示例中,您将看到正在调度的计时器与正在执行的回调之间的总延迟将为105毫秒。

以下是我测试时间:

image-20220220155806573

pending callbacks

此阶段执行某些系统操作(例如TCP错误类型)的回调。 例如,如果TCP socket ECONNREFUSED在尝试connect时receives,则某些* nix系统希望等待报告错误。 这将在pending callbacks阶段执行。

poll

该poll阶段有两个主要功能:

  • 执行I/O回调。
  • 处理轮询队列中的事件。

当事件循环进入poll阶段并且在timers中没有可以执行定时器时,将发生以下两种情况之一

  • 如果poll队列不为空,则事件循环将遍历其同步执行它们的callback队列,直到队列为空,或者达到system-dependent(系统相关限制)。

如果poll队列为空,则会发生以下两种情况之一

  • 如果有setImmediate()回调需要执行,则会立即停止执行poll阶段并进入执行check阶段以执行回调。
  • 如果没有setImmediate()回到需要执行,poll阶段将等待callback被添加到队列中,然后立即执行。

当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。

check

此阶段允许人员在poll阶段完成后立即执行回调。
如果poll阶段闲置并且script已排队setImmediate(),则事件循环到达check阶段执行而不是继续等待。

setImmediate()实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。它使用libuv API来调度在poll阶段完成后执行的回调。

通常,当代码被执行时,事件循环最终将达到poll阶段,它将等待传入连接,请求等。
但是,如果已经调度了回调setImmediate(),并且轮询阶段变为空闲,则它将结束并且到达check阶段,而不是等待poll事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')

如果node版本为v11.x, 其结果与浏览器一致。

1
2
3
4
5
6
7
start
end
promise3
timer1
promise1
timer2
promise2

具体详情可以查看《又被node的eventloop坑了,这次是node的锅》。

如果v10版本上述结果存在两种情况:

  • 如果time2定时器已经在执行队列中了
1
2
3
4
5
6
7
start
end
promise3
timer1
timer2
promise1
promise2
  • 如果time2定时器没有在执行对列中,执行结果为
1
2
3
4
5
6
7
start
end
promise3
timer1
promise1
timer2
promise2

具体情况可以参考poll阶段的两种情况。

从下图可能更好理解:

动画

总结

为什么要有EventLoop?

因为JS是一门单线程语言,EventLoop是JS环境运行的执行机制,分为浏览器环境跟node环境,能让程序有序执行,避免阻塞。

在代码执行环境中,有执行栈,宏任务队列和微任务队列

宏任务队列存放的是setTimeout,setInterval,I/O等方法

微任务队列存放的是promise,process.nextTick等方法

而宏任务可包含着微任务,当执行完一个宏任务后,会检测该宏任务是否有微任务,有则执行微任务,无则执行下一个宏任务,这就是宏任务与微任务的执行顺序问题

当进入一个执行环境的时候,整个JS脚本相当于一个宏任务,首先会先执行同步代码以及微任务,例如promise的同步任务,然后再执行promise的异步任务也就是then后面的内容

然后会再执行宏任务,setTimeout

参考

MDN:执行栈 https://developer.mozilla.org/zh-CN/docs/Glossary/Call_stack

一次弄懂Event Loop(彻底解决此类面试问题):https://juejin.cn/post/6844903764202094606

前端面试之道:https://juejin.cn/book/6844733763675488269/section/6844733763805511693

微任务、宏任务与Event-Loop:https://juejin.cn/post/6844903657264136200

带你彻底弄懂Event Loop:https://segmentfault.com/a/1190000016278115

这一次,彻底弄懂 JavaScript 执行机制:https://juejin.cn/post/6844903512845860872