本文通过看阮一峰大神博客后模拟总结,原文链接(http://www.ruanyifeng.com/blog/2018/02/node-event-loop.html)
javascript是一门单线程语言,javascript是一门单线程语言,javascript是一门单线程语言,重要的事情说三遍。
接下来我们就来看一下js的执行顺序:
- 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。
- 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
- 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
- 上述过程会不断重复,也就是常说的Event Loop(事件循环)。
除了广义的同步任务和异步任务,我们对任务有更精细的定义:
- macro-task(宏任务):包括整体代码script,setTimeout,setInterval
- micro-task(微任务):Promise,process.nextTick
不同类型的任务会进入对应的Event Queue,比如setTimeout和setInterval会进入相同的Event Queue。
事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。
setTimeout(function() {
console.log('setTimeout');
})
new Promise(function(resolve) {
console.log('promise');
}).then(function() {
console.log('then');
})
console.log('console');
- 这段代码作为宏任务,进入主线程。
- 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述)
- 接下来遇到了Promise,new Promise立即执行,then函数分发到微任务Event Queue。
- 遇到console.log(),立即执行。
- 好啦,整体代码script作为第一个宏任务执行结束,看看有哪些微任务?我们发现了then在微任务Event Queue里面,执行。
- ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行。
- 结束。
事件循环,宏任务,微任务的关系如图所示:
单独讲一下setTimeout
用一个例子来说明:
let o = new Date().getTime()
let n;
setTimeout(() => {
m = new Date().getTime()
console.log("计时1:",m - o)
sleep(2000)
n = new Date().getTime()
console.log("计时2:",n - o)
}, 1000)
sleep(3000)
function sleep(ms) {
let oldDate = new Date().getTime()
let newDate = oldDate
while (newDate - oldDate < ms){
newDate = new Date().getTime()
}
}
//计时1: 3001
//计时2: 5006
- setTimeout回调函数进入Event Table并注册,计时开始。
- 执行sleep函数,很慢,非常慢,计时仍在继续。
- 1秒到了,计时事件timeout完成,setTimeout回调进入Event Queue,但是sleep也太慢了吧,还没执行完,只好等着。
- sleep终于执行完了,setTimeout回调终于从Event Queue进入了主线程执行。
下边讲一下异步如何实现
客户端异步实现
我们经常在写客户端js的时候用到异步操作,实现异步基本上包含
- setTimeout
- setInterval
- ajax
Node端异步实现
node中实现异步包含
- setImmediate
- process.nextTick
定时器用法差不多
看下边的例子:
setTimeout(()=>{
console.log(1);
});
setImmediate(()=>{
console.log(2);
});
process.nextTick(()=>{
console.log(3);
});
Promise.resolve().then(()=>{
console.log(4);
});
(()=>console.log(5))();
输出结果如下:
5
3
4
1
2
下边来分析一下结果
同步和异步任务
同步任务要比异步任务更早执行,因此首先输出5
本轮循环和次轮循环
异步任务分为两种:
- 追加在本轮循环的异步任务
- 追加在次轮循环的异步任务
本轮循环任务要优先于次轮循环任务
Node规定,process.nextTick、Promise的回调函数,追加在本轮循环任务中,即同步任务完成后就开始执行。而setTimeout、setInterval、setImmediate的回调函数追加在次轮循环任务中。因此
// 下面两行,次轮循环执行
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
// 下面两行,本轮循环执行
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
process.nextTick()
process.nextTick任务在本轮循环中执行,而且是所有异步任务里执行最快的。如果希望快速执行异步任务就使用此函数。
微任务
Promise对象的回调函数会进入异步任务里边的“微任务”队列,微任务队列追加在process.nextTick队列后边,也属于本轮循环。
注意:只有前一个队列全部清空后才会执行下一个队列。
看下边的例子:
process.nextTick(() => console.log(1));
Promise.resolve().then(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
// 1
// 3
// 2
// 4
全部process.nextTick的回调函数会早于Promise
事件循环的概念
js只有一个主线程,事件循环是在主线程上完成的。
Node开始执行脚本时,先对事件循环进行初始化,但这时还没有执行事件循环,先进行以下操作。
- 同步任务
- 发出异步请求
- 规划定时器生效时间
- 执行本轮循环任务等等
上边事情都干完之后才进行事件循环。
事件循环的六个阶段
事件循环是无限次的执行,只有当异步任务回调函数队列都清空了,才会停止执行。
每一轮循环分为以下六个阶段
- timers
- I/O callbacks
- idle,prepare
- poll
- check
- close callbacks
每个阶段都有一个先进先出的队列,只有该队列都清空或者该执行的回调函数都执行了,时间循环才进入下一个阶段。
(1) timers
这是一个定时器阶段,处理setTimeout
,setInterval
的回调函数,进入这个阶段,主线程会检查一下当前时间,是否满足定时器条件。如果满足就执行回调函数,否则调到下一个阶段。
(2) I/O callbacks
除了以下操作的回调函数,其他的回调函数都在这个阶段执行。
- setTimeout()和setInterval()的回调函数
- setImmediate()的回调函数
- 关于关闭请求的回调函数,例如socket.on(“close”,…)
本文中例子执行的是读取文件后的回调
(3) idle, prepare
该阶段只供 libuv 内部调用,这里可以忽略。
(4) Poll
这个阶段是轮询时间,用于等待未返回的I/O事件,比如服务器的回应,文件的读取,用户鼠标移动等等。
这个阶段的时间比较长,如果没有其他异步要处理(比如到期的定时器),会一直停留在这个阶段,等待I/O的响应。
(5) check
该阶段执行setImmediate()的回调函数。
(6) close callbacks
该阶段执行关闭请求的回调函数,比如socket.on(‘close’, …)。
事件循环的示例
分析一个官方案例:
const fs = require('fs');
const timeoutScheduled = Date.now();
// 异步任务一:100ms 后执行的定时器
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms`);
}, 100);
// 异步任务二:文件读取后,有一个 200ms 的回调函数
fs.readFile('test.js', () => {
const startCallback = Date.now();
while (Date.now() - startCallback < 200) {
// 什么也不做
}
});
第一轮事件循环,没有到期的定时器,也没有可以进行的I/O回调操作,所以会键入Poll阶段,等待内核返回文件读取结果,由于文件过小,读取时间不会超过100ms,所以在定时器到期之前Poll阶段就会返回结果。继续往下执行。
第二轮事件循环,依然没有到期的定期器,因此会执行I/O的回调函数,进入I/O的callbacks阶段,执行fs.readFile的回调函数,这个函数需要200ms,也就是执行到一半,定时器就会到期,但是必须等到这个回调执行完之后才会离开这个阶段。
第三轮事件循环,已经有了到期的定时器,所以会在timers阶段执行定时器,最后输出大约200多毫秒。
setTimeout 和 setImmediate
由于setTimeout在timers阶段,setImmediate在check阶段,所以setTimeout会比setImmediate提前执行。
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
上边代码应该先输出1后输出2,但是结果却是不确定的。有时会先输出2。
这是因为setTimeout的第二个参数默认为0,但是Node做不到0毫秒,最少也需要1ms,官方文档规定,setTimeout第二个参数范围在1~2147483647毫秒之间。
实际执行的时候,进入循环后,有可能到了1ms,也有可能没到1ms,取决于当前的系统状况。如果没到1ms那么timers阶段就会被跳过,进入check阶段,先执行setImmediate回调。
一下代码肯定是先输出2再输出1。
const fs = require('fs');
fs.readFile('test.js', () => {
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
});
上边代码先进入I/O callbacks阶段,然后是check阶段,因此setImmediate会早于setTimeout执行。
参考资料:
1、Node 定时器详解
2、这一次,彻底弄懂 JavaScript 执行机制