作为一门设计初衷为了处理浏览器网页交互(DOM
操作、UI
动画等)的语言,JavaScript
只能被设计为单线程(否则多个线程同时处理DOM
那将会造成混乱)。
可是写过JavaScript
代码的人都用过定时器、ajax
、事件绑定等异步处理函数。那么单线程的 JS 是怎样将等待异步请求返回过程中闲置的 CPU 利用起来的呢?我们一起来了解一下。
前言
其实,JavaScript
单线程指的是浏览器中负责解释和执行JavaScript
代码的只有一个线程——JavaScript
引擎线程。除了他之外浏览器还有其他四个线程:
- 事件触发线程
- 定时器触发线程
- 异步
http
请求线程 GUI
渲染线程
当遇到计时器、DOM事件监听或是网络请求时,JS
引擎会将其交给webapi
,也就是浏览器提供的相应线程。而JS引擎则继续后边的其他任务,以此方式实现异步非阻塞。
在此咱们不得不讲一下setTimeout
(setInterval
)函数。这东西没咱们想象的那么准,原因就在于当事件结束后他会将相应的回调函数(callback
)交还给 消息队列。而消息队列中排列着其他的任务,只有轮到它才会被执行。所以setTimeout
只能保证其在ms
毫秒 之后 执行。
事件循环与消息队列
什么是消息队列
众所周知JavaScript
中的存储区域分为堆区、栈区、还有消息队列区。
- 堆区存放用户创建的对象。(内训泄露定位的主要区域也在这里。)
- 栈区则是用来处理函数执行(所以又称为执行栈)。每嵌套一层向栈中推入函数信息,得到返回值后出栈。主代码块依次进入执行栈,依次执行。
- 而消息队列则是用来处理异步任务。每当出现异步调用事件时都会将其入队,执行完毕后再由任务队列通知主线程,让
JS
引擎接管此事件。
什么是事件循环
当执行栈为空时(JS引擎线程空闲),事件触发线程会从消息队列中取出一个任务(异步的回调函数)放入执行栈中执行。执行完毕后执行栈再次为空,事件触发线程会重复上一步操作,继续从消息队列中取出一个任务。此机制被称为事件循环(event loop
)机制。
我们不妨举个例子来看看其操作流程:
1 | console.log('script start') |
本例中我们先顺序执行同步代码,从消息队列中依次进入执行栈执行,依次打印script start
,script end
。
而两个setTimeout
作为异步代码,分别由定时器触发线程进行监控,时间到后再将其推入消息队列中。
当函数执行栈空时从消息队列中取任务执行。由于”timer 2 over
“先入队所以先被取出,timer 1 over
同理。
宏任务&微任务
上边这些个机制事实上在ES5已经够用了。但是ES6会有一些问题。因为其引入了新的异步机制——Promise
1 | console.log('script start') |
为什么 promise1
和 promise2
在 “timer over
”之前打印?
这里就要引入宏任务(macrotask
)和微任务(microotask
)。
- 上面提到的一切事件(同步代码块、
setTimeout
、setInterval
等)都是宏任务 - 而微任务则是
Promise
和process.nextTick
顺序的话还是同步事件优先级最高,而这些异步事件其次。机制同样是事件循环。
当执行宏任务时遇到Promise
等,会创建微任务(.then()
里的回调),并加入微任务队列队尾。
microtask必然是在某个宏任务执行的时候创建的,而在下一个宏任务开始之前,浏览器会对页面重新渲染(task
>> 渲染
>> 下一个task
(从任务队列中取一个))。同时,在上一个宏任务执行完成后,渲染页面之前,会执行当前微任务队列中的所有微任务。
在某一个
macrotask
执行完后,在重新渲染与开始下一个宏任务之前,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。
在node环境下,
process.nextTick
的优先级高于Promise
,也就是说:在宏任务结束后会先执行微任务队列中的nextTickQueue
,然后才会执行微任务中的Promise
。
Node 中的 Event Loop
先看一张图
如图我们可得到以下几点信息:
- 我们的
js
代码(APPLICATION
)会先进入 V8 引擎。V8 引擎中主要是一些setTimeout
之类的方法 - 其次若我们的代码中执行了
node API
,比如require('fs').read()
,node
就会交给libuv
库处理。这个libuv
库就是node
的事件环 libuv
库是通过单线程异步的方式来处理事件。我们可以看到work threads
是个多线程的队列,通过外面event loop
阻塞的方式来进行异步调用- 等到
work threads
队列中有执行完成的事件,就会通过EXECUTE CALLBACK
回调给EVENT QUEUE
队列,把他放入队列中 - 最后通过事件驱动的方式,取出
EVENT QUEUE
队列的事件,交给我们应用
Node 的 Event loop 分为6个阶段,它们会按照顺序反复运行
node-EventLoop-Status
timer
- timers 阶段会执行
setTimeout
和setInterval
。一个timer
指定的时间并不是准确时间,而是在达到这个时间后尽快执行回调,可能会因为系统正在执行别的事务而延迟。下限的时间有一个范围:[1, 2147483647]
,如果设定的时间不在这个范围,将被设置为1。 - 注意看上边那句!所以说
setTimeout(callback, 0)
会被自动转化为setTimeout(callback, 1)
I/O
- I/O 阶段会 执行除了 close 事件,定时器和
setImmediate
的回调。
idle, prepare
- idle, prepare 阶段内部实现,此阶段执行
process.nextTick()
的回调。
poll
- poll 阶段很重要,这一阶段中,系统会做两件事情
- 执行到点的定时器
- 执行 poll 队列中的事件
- 并且当 poll 中没有定时器的情况下,会发现以下两件事情
- 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者系统限制
- 如果 poll 队列为空,会有两件事发生
- 如果有
setImmediate
需要执行,poll 阶段会停止并且进入到 check 阶段执行setImmediate
- 如果有新的回调进入且其为定时器,会回到 timer 阶段执行回调。
- 如果有
check
- check 阶段执行
setImmediate()
。
close callbacks
close callbacks 阶段执行 close 事件。并且在 Node 中,有些情况下的定时器执行顺序是随机的
1
2
3
4
5
6
7
8
9
10setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
})
// 这里可能会输出 setTimeout,setImmediate
// 可能也会相反的输出,这取决于性能
// 因为可能进入 event loop 用了不到 1 毫秒,这时候会执行 setImmediate
// 否则会执行 setTimeout当然在这种情况下,执行顺序是相同的
1
2
3
4
5
6
7
8
9
10
11
12
13
14var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// 因为 readFile 的回调在 poll 中执行
// 发现有 setImmediate ,所以会立即跳到 check 阶段执行回调
// 再去 timer 阶段执行 setTimeout
// 所以以上输出一定是 setImmediate,setTimeout上面介绍的是
macrotask
的执行情况。被创建的microtask
会在以上宏任务完成后立即执行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
// 以上代码在浏览器和 node 中打印情况是不同的
// 浏览器中一定打印 timer1, promise1, timer2, promise2
// node 中可能打印 timer1, timer2, promise1, promise2
// 也可能打印 timer1, promise1, timer2, promise2
总结
上边的那张图看起来比较乱,总的来说 NODE 中的 event loop
可以用这一张图来表示。
- 很多人对于
process.nextTick()
和setImmediate()
的执行顺序有所疑惑,通过这张图就可以完全的理清了。另一方面我们在此着重说明一下二者的区别- 具体表现上
process.nextTick()
的回调保存在一个数组中,而setImmediate()
的结果则保存在链表中 - 行为上
process.nextTick()
在每轮循环中将会清空所有数组中的回调函数,而setImmediate()
则是在每轮循环中执行链表中的第一个回调函数
- 具体表现上
1 | process.nextTick(function() { |
其打印结果为
1 | 正常执行 |
第一个 setImmediate()
的回调执行后并未立即执行第二个,而是进入了下一轮循环,再次按 process.nextTick()
优先,setImmediate()
次后的顺序执行。
此打印结果会根据你电脑上的 NODEJS 版本不同而变化。比如我最初始时使用的是
v10.16.3
,其打印结果为
1
2
3
4
5
6
7 > 正常执行
> nextTick 延迟执行 1
> nextTick 延迟执行 2
> setImmediate 延迟执行 1
> setImmediate 延迟执行 2
> 强势插入
>
而在
v11
之后则如《深入浅出NODEJS》一书所述。具体文章 在此。各位可以通过安装 nvm 进行 NODEJS 版本的控制完成对此例的测试。
此例具体位置在 《深入浅出NODEJS》中 63 页的
setImmediate()
部分。(具体位于 3.5 事件驱动与高性能服务器)
嵌套例题
宏任务 & 微任务之间的嵌套
先给个简单的,关于宏任务和微任务之间的嵌套
1 | Promise.resolve().then(()=>{ |
- NODE 环境下有两种打印结果
Promise1 setTimeout1 Promise2 setTimeout2
Promise1 setTimeout1 setTimeout2 Promise2
解析
- 一开始执行栈的同步任务执行完毕,回去微任务队列找
- 清空微任务队列,输出
Promise1
,同时生成一个异步任务setTimeout1
- 去宏任务队列查看此时队列是
setTimeout1
在setTimeout2
之前,因为setTimeout1
执行栈一开始的时候就开始异步执行,所以输出setTimeout1
。在执行setTimeout1
时会生成Promise2
的一个微任务,放入微任务队列中 - 接着又是一个循环,去清空微任务队列,输出
Promise2
- 清空完微任务队列,就又去宏任务队列中取一个。这次取的是
setTimeout2
node环境下的嵌套+setTimeout+setImmediate+nextTick
引子
首先我们说一下这仨有啥区别
setTimeout
采用的是类似 IO 观察者。精度不高,可能有延迟执行的情况发生。动用了红黑树所以消耗资源大
setImmediate
采用的是check
观察者。消耗资源小,也不会造成阻塞,但效率最低
process.nextTick
采用的是idle
观察者。效率最高,消费资源小,但会阻塞 CPU 的后续调用三种观察者的优先级顺序是:idle观察者 >> IO观察者 > check观察者
前两者都会进入等待队列,而 process.nextTick
是一个比较特殊的存在
比如下面的代码
1 | A(); |
会有如下的顺序
所以 nextTick
的优先级要高于前两者(setTimeout
和 setImmediate
一样,都是进入等待队列,所以我就不写 setImmediate
了啊)
1 | setTimeout(function(){ |
但是这段代码的打印结果不确定,但是 setTimeout
在前的概率更大些,因为 IO 观察者的优先级要大于 check
观察者
例子
为了防止大家绕晕所以我们先把上面的 node event loop
那张图拿下来方便查看.
下面上代码
1 | setImmediate(()=>{ |
答案有两种:
1 | setImmediate1,setTimeout2,setTimeout1,nextTick1,setImmediate2 |
首先我们可以看到上面的代码先执行的是
setImmediate1
,此时event loop
在check队列然后
setImmediate1
从队列取出之后,输出setImmediate1
,然后会将setTimeout1
执行此时
event loop
执行完check队列之后,开始往下移动,接下来执行的是timers队列这里会有问题,我们都知道
setTimeout
设置延迟为0的话,其实还是有4ms
的延迟。那么这里就会有两种情况。- 第一种,
setTimeout1
已经执行完毕:- 根据node事件环的规则,我们会执行完所有的事件,即取出timers队列中的
setTimeout2,setTimeout1
- 此时根据队列先进先出规则,输出顺序为
setTimeout2,setTimeout1
,在取出setTimeout2
时,会将一个process.nextTick
执行(执行完了就会被放入微任务队列),再将一个setImmediate
执行(执行完了就会被放入check队列) - 到这一步,
event loop
会再去寻找下个事件队列,此时event loop
会发现微任务队列有事件process.nextTick
,就会去清空它,输出nextTick1
- 最后
event loop
找到下个有事件的队列check队列,执行setImmediate
,输出setImmediate2
- 根据node事件环的规则,我们会执行完所有的事件,即取出timers队列中的
- 第二种,
setTimeout1
还未执行完毕- 此时
event loop
找到timers队列,取出timers队列中的setTimeout2
,输出setTimeout2
,把process.nextTick
执行,再把setImmediate
执行 - 然后
event loop
需要去找下一个事件队列,这里大家要注意一下,这里会发生2步操作,- 1、
setTimeout1
执行完了,放入timers
队列。 - 2、找到微任务队列清空。
- 所以此时会先输出
nextTick1
- 1、
- 接下来
event loop
会找到check
队列,取出里面已经执行完的setImmediate2
- 最后
event loop
找到timers
队列,取出执行完的setTimeout1
。这种情况下 event loop 比上面要多切换一次
- 此时
- 第一种,
如果你把这个也搞懂了,那大概就真的懂 Event Loop
了8~。