背景
无论是日常工作还是面试求职,经常会遇到这样的情况:给定几行代码,输出的结果。要想不被这样的问题困扰。就需要搞懂JavaScript的执行机制。
浏览器为什么需要Event Loop
原因: JavaScript 是单线程的。单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。为了协调事件、用户交互、脚本、渲染、网络请求等,必须使用Event Loop。
JavaScript 为什么被设计成单线程的?
作为浏览器脚本语言,js的主要用途是与用户交互,以及操作DOM, 这就决定了它只能是单线程的,否则就会有很复杂的异步问题。比如,假定js同时有两个线程,一个线程在某个DOM节点上添加内容,而另一个线程删除这个DOM节点,这时浏览器要以哪个操作为准呢?所以,为了避免复杂性,js被设计为单线程。
当然,现如今人们也意识到,单线程在保证了执行顺序的同时也限制了javascript的效率,因此开发出了web worker技术。这项技术号称让javascript成为一门多线程语言。
然而,使用web worker技术开的多线程有着诸多限制,例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程(由浏览器开辟)。另外,这些子线程并没有执行I/O操作的权限,只能为主线程分担一些诸如计算等任务。所以严格来讲这些线程并没有完整的功能,也因此这项技术并非改变了javascript语言的单线程本质。
浏览器中的Event Loop
先来通过一段代码来感受一下
setTimeout(function(){
console.log('定时器开始啦')
});
new Promise(function(resolve){
console.log('马上执行for循环啦');
for(var i = 0; i < 10000; i++){
i == 99 && resolve();
}
}).then(function(){
console.log('执行then函数啦')
});
console.log('代码执行结束');
结果:
马上执行for循环啦
代码执行结束
执行then函数啦
定时器开始啦
结果有没有超出你的想象呢? 面试过程中此类的问题出现频率很高,想要彻底解决这类问题,就必须搞懂js的运行机制了。
js的事件循环
既然js是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理js任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。那么问题来了,假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?因此聪明的程序员将任务分为两类:
- 同步任务
- 异步任务
当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。用导图来说明:
用文字来描述上图中的内容:
- 同步和异步任务分别进入不同的执行”场所”,同步的进入主线程,异步的进入Event Table并注册函数
- 当指定的事情完成时,Event Table会将这个函数移入Event Queue
- 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行
上述过程会不断重复,也就是常说的Event Loop(事件循环)。
我们不禁要问了,那怎么知道主线程执行栈为空啊?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。
用一段简单的代码来说明执行的顺序:
let data = [];
$.ajax({
url:www.javascript.com,
data:data,
success:() => {
console.log('发送成功!');
}
})
console.log('代码执行结束');
上面这段简单的代码的执行会经过下边这几个步骤:
- ajax进入event table中,并注册回调函数success
- 执行同步事件console.log()
- ajax事件完成,回调函数success进入event queue
- 主线程从事件队列中读取回调函数success,并执行
换一张图也许能帮助你更好的理解主线程的执行过程:
除了广义的同步任务和异步任务,我们对任务有更精细的定义:
- macro-task(宏任务):包括整体代码script,setTimeout,setInterval
- micro-task(微任务):Promise,process.nextTick
不同类型的任务会进入对应的Event Queue。事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。看下面这段代码:
setTimeout(function() {
console.log('setTimeout');
})
new Promise(function(resolve) {
console.log('promise');
}).then(function() {
console.log('then');
})
console.log('console');
//结果
promise
console
setTimeout
那么这段代码的执行顺序是这样的:
- 这段代码作为宏任务,进入主线程
- 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue
- 接下来遇到了Promise,new Promise立即执行,then函数分发到微任务Event Queue
- 遇到console.log()同步任务,立即执行
- 好啦,整体代码script作为第一个宏任务执行结束,看看有哪些微任务?我们发现了then在微任务Event Queue里面,执行
- ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行
- 结束
事件循环,宏任务,微任务的关系如图所示:
小试牛刀
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
接下来看一下setTimeOut
setTimeout
用的地方多了,问题也出现了,有时候明明写的延时3秒,实际却5,6秒才执行函数,这又咋回事啊?来看一下这个例子:
setTimeout(() => {
task();
},3000)
console.log('执行console');
//结果为
执行console
task();
这是没有问题的,但是,当把代码稍微改动一下,例如
setTimeout(() => {
task()
},3000)
sleep(10000000);
这个时候你就会发现执行task()
的时间远远的超过3秒,说好的三秒呢???这个时候我们就需要重新理解setTimeout的含义了。在此之前先来看看上面代码的执行顺序:
- task()进入Event Table进行注册,并开始计时
- 执行
sleep
函数,时间非常长,计时仍在继续 - 3秒时间到了,计时的timeout完成,task进入event queue,但是sleep这时还没执行完,主线程被占用,所以task只能等待
- .当sleep执行完毕,task才从事件队列进入主线程执行
说完代码的执行顺序,也就明白setTimeOut
这个函数的含义是在指定时间后,把要执行的任务加入到event queue中。经常会遇到setTimeout(fn,0)
的情况,这是立即执行吗???当然不是,setTimeout(fn,0)
的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。
再来看一下setInterval()
上面说完了setTimeout
,当然不能错过它的孪生兄弟setInterval
。他俩差不多,只不过后者是循环的执行。对于执行顺序来说,setInterval会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。
唯一需要注意的一点是,对于setInterval(fn,ms)
来说,我们已经知道不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。一旦setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了。这句话请读者仔细品味。
node中的事件循环
js的事件循环机制是其作为单线程语言能实现高效异步运行的核心基础。node出现后,js是运行环境就不再是单一的浏览器。同样在node环境中js也有对应的事件循环。
nodejs采用V8作为js的解析引擎。
先来感受一下事件循环在浏览器中和在node中的区别
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
浏览器中的运行结果:
timer1 - promise1 - timer2 - promise2
node环境的运行结果
timer1 - timer2 - promise1 - promise2
通过上一节的分析浏览器中的结果没有什么意外,那么Node环境的输出结果又是为什么捏?
Node.js采用V8作为js的解析引擎,而I/O处理方面使用自己设计的libuv
,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统的一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。
根据Node.js官方介绍,每次事件循环都包含了6个阶段,对应到 libuv 源码中的实现,如下图所示:
- timers阶段:这个阶段执行timer(setTimeOut, setInterval)的回调
- I/O callbacks阶段:执行一些系统调用错误,比如网络通信的错误回调
- idle, prepare阶段:仅node内部使用
- poll阶段:获取新的I/O事件,适当的条件下node将阻塞在这里
- check阶段:执行setImmediate()的回调
- colse callback阶段:执行socket的close事件回调
接下来我们重点看timers,poll,check这三个阶段,因为日常开发中的绝大部分任务都是在这三个阶段中处理的。
timers阶段
timers 是事件循环的第一个阶段,Node 会去检查有无已过期的timer,如果有则把它的回调压入timer的任务队列中等待执行,事实上,Node 并不能保证timer在预设时间到了就会立即执行,因为Node对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。比如下面的代码,setTimeout() 和 setImmediate() 的执行顺序是不确定的
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
但是把它们放到一个I/O回调里面,就一定是 setImmediate() 先执行,因为poll阶段后面就是check阶段。
poll阶段
poll 阶段主要有2个功能:
- 处理 poll 队列的事件
- 当有已超时的 timer,执行它的回调函数
even loop将同步执行poll队列里的回调,直到队列为空或执行的回调达到系统上限(上限具体多少未详),接下来even loop会去检查有无预设的setImmediate(),分两种情况:
- 若有预设的setImmediate(), event loop将结束poll阶段进入check阶段,并执行check阶段的任务队列
- 若没有预设的setImmediate(),event loop将阻塞在该阶段等待
注意一个细节,没有setImmediate()会导致event loop阻塞在poll阶段,这样之前设置的timer岂不是执行不了了?所以咧,在poll阶段event loop会有一个检查机制,检查timer队列是否为空,如果timer队列非空,event loop就开始下一轮事件循环,即重新进入到timer阶段。
check阶段
setImmediate()的回调会被加入check队列中, 从event loop的阶段图可以知道,check阶段的执行顺序在poll阶段之后。
小结
- event loop 的每个阶段都有一个任务队列
- 当 event loop 到达某个阶段时,将执行该阶段的任务队列,直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段
- 当所有阶段被顺序执行一次后,称 event loop 完成了一个 tick
现在,我们再来看Node.js 与浏览器的 Event Loop 差异:浏览器环境下,microtask 的任务队列是每个 macrotask 执行完之后执行。而在 Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask 队列的任务。
那么现在再来看这段代码在node环境的执行:
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
- 全局脚本(main())执行,将 2 个 timer 依次放入 timer 队列,main()执行完毕,调用栈空闲,任务队列开始执行;
- 首先进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise1.then 回调放入 microtask 队列,同样的步骤执行 timer2,打印 timer2,并将 promise2.then 回调放入 microtask 队列
- 至此,timer 阶段执行结束,event loop 进入下一个阶段之前,执行 microtask 队列的所有任务,依次打印 promise1、promise2
再来看一个例子加深一下理解:
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')
结果
start - end - promise3 - timer1 - timer2 - promise1 - promise2
- 一开始执行栈的同步任务(这属于宏任务)执行完毕后(依次打印出 start end,并将 2 个 timer 依次放入 timer 队列),会先去执行微任务(这点跟浏览器端的一样),所以打印出 promise3
- 然后进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise.then 回调放入 microtask 队列,同样的步骤执行 timer2,打印 timer2;这点跟浏览器端相差比较大,timers 阶段有几个 setTimeout/setInterval 都会依次执行,并不像浏览器端,每执行一个宏任务后就去执行一个微任务
注意⚠️
- setTimeout和setImmediate,二者非常类似,主要区别在于调用的时机不同
setImmediate
设计在 poll 阶段完成时执行,即 check 阶段;
setTimeout
设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行 - process.nextTick
这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。