作为一门设计初衷为了处理浏览器网页交互(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 setTimeout2Promise1 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~。