故事: 以下都是拾人牙慧,主要是方便自己的学习总结,因为觉得EventLoop一直是 自己的弱点,平时感觉很懵逼,就到处查资料学习一下
为什么要有Event Loop?
因为Javascript设计之初就是一门单线程语言,因此为了实现主线程的不阻塞,Event Loop这样的方案应运而生。
Event Loop即事件循环,是指浏览器或Node的一种解决javaScript单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。
为什么要学习Event Loop
考官经常问到为什么 setTimeout 会比 Promise 后执行,明明代码写在 Promise 之前。还会出些题目,判断执行的先后顺序,这其实涉及到了 Event Loop 相关的知识
Event Loop 任务
在JavaScript中,任务被分为两种,一种宏任务(MacroTask)也叫Task,一种叫微任务(MicroTask)。
MacroTask(宏任务),包括setTimeout、setInterval、setImmediate(浏览器暂时不支持,只有IE10支持)、I/O、UI交互事件
MicroTask(微任务),包括Promise、process.nextTick(Node独有)、MutaionObserver
浏览器中的Event Loop
当我们执行 JS 代码的时候其实就是往执行栈中放入函数,那么遇到异步代码的时候该怎么办?其实当遇到异步的代码时,会被挂起并在需要执行的时候加入到 Task(有多种 Task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task
同一次事件循环中,微任务永远在宏任务之前执行。
console.log(1);
setTimeout(() => {
console.log('setTimeout');
}, 0);
let promise = new Promise(resolve => {
console.log(3);
resolve();
}).then(data => {
console.log(100);
}).then(data => {
console.log(200);
});
console.log(2);
执行顺序如下
1
3
2
100
200
setTimeout
解释:
如上,按照js由上到下的执行顺序,遇到同步任务先输出1。setTimeout是宏任务,会先放到宏任务队列中。而new Promise是立即执行的,所以会先输出3。而Promise.then是微任务,会依次排列到微任务队列中,继续向下执行输出2。现在执行栈中的任务已经清空,再将微任务队列清空,依次输出100和200。之后每次取出一个宏任务,因为现在只有一个宏任务,所以最后输出setTimeout。
再看下稍微升级版的
console.log(1)
setTimeout(() => {
console.log(2)
new Promise(resolve => {
console.log(4)
resolve()
}).then(() => {
console.log(5)
})
})
new Promise(resolve => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
})
setTimeout(() => {
console.log(9)
new Promise(resolve => {
console.log(11)
resolve()
}).then(() => {
console.log(12)
})
})
输出结果如下
1
7
8
2
4
5
9
11
12
分析如下:
同步运行的代码首先输出:1、7
接着,清空microtask队列:8
第一个task执行:2、4
接着,清空microtask队列:5
第二个task执行:9、11
接着,清空microtask队列:12
再升级一下 加上process.nextTick后在node上测试
console.log(1)
setTimeout(() => {
console.log(2)
new Promise(resolve => {
console.log(4)
resolve()
}).then(() => {
console.log(5)
})
process.nextTick(() => {
console.log(3)
})
})
new Promise(resolve => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
})
process.nextTick(() => {
console.log(6)
})
输出结果如下
1
7
6
8
2
4
3
5
这说明**process.nextTick注册的函数优先级高于Promise**
再升级一下
console.log(1)
setTimeout(() => {
console.log(2)
new Promise(resolve => {
console.log(4)
resolve()
}).then(() => {
console.log(5)
})
process.nextTick(() => {
console.log(3)
})
})
new Promise(resolve => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
})
process.nextTick(() => {
console.log(6)
})
setTimeout(() => {
console.log(9)
process.nextTick(() => {
console.log(10)
})
new Promise(resolve => {
console.log(11)
resolve()
}).then(() => {
console.log(12)
})
})
输出结果如下
1
7
6
8
2
4
9
11
3
10
5
12
Timer是整个Event Loop中非常重要的一环,我们先从timer切入,来切身体会下规范和实现的差异。
setTimeout(() => {
console.log(2)
}, 2)
setTimeout(() => {
console.log(1)
}, 1)
setTimeout(() => {
console.log(0)
}, 0)
在chrome运行结果是 1 0 2
在node下运行不是很稳定 有时候是 2 1 0 有时候是 1 0 2
Node的Event Loop
Node中的Event Loop是基于libuv实现的,而libuv是 Node 的新跨平台抽象层,libuv使用异步,事件驱动的编程方式,核心是提供i/o的事件循环和异步回调。libuv的API包含有时间,非阻塞的网络,异步文件操作,子进程等等。 Event Loop就是在libuv中实现的。
不同于浏览器中的事件循环队列的实现,Node中的事件循环分的更加细致,从而有的时候会有些并不明确。Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
以下是网上盗图
当Node.js启动时会初始化event loop, 会做这几件事
- 初始化 event loop
- 开始执行脚本(或者进入 REPL,本文不涉及 REPL)。这些脚本有可能会调用一些异步 API、设定计时器或者调用 process.nextTick()
- 开始处理 event loop
每一个event loop都会包含按如下顺序六个循环阶段
- timers 阶段会执行 setTimeout 和 setInterval callback回调,并且是由 poll 阶段控制的。同样,在 Node 中定时器指定的时间也不是准确时间,根据node的环境而变化
- I/O callbacks回调:I/O 阶段会处理一些上一轮循环中的少数未执行的 I/O 回调,比如TCP错误,但不包括close事件、定时器和setImmediate的回调;
- idle, prepare:只在node内部使用 忽略不计;
- poll:等待新的I/O事件,node在一些特殊情况下会阻塞在这里;
- check:setImmediate回调在这个阶段执行;
- close callbacks:close callbacks 阶段执行 close 事件,只在node内部使用。
timeout、immediate 两个谁先执行不一定 取决于node的执行时间。因此定时器的执行顺序其实是随机的
---------------------------macrotask(宏任务)---------------------------------
setTimeout(function () {
console.log('setTimeout1');
})
setImmediate(function () {
console.log('setImmediate2');
});
执行node 时而输出setTimeout1 setImmediate2,时而输出 setImmediate2 setTimeout1
解释:
- 首先
setTimeout(fn, 0) === setTimeout(fn, 1)
,这是由源码决定的 - 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行
setTimeout
回调 - 那么如果准备时间花费小于 1ms,那么就是
setImmediate
回调先执行了
当然在某些情况下,他们的执行顺序一定是固定的,如果i/o文件操作以后就会先执行setImmediate,因为setImmediate在i/o文件操作后面的那个阶段执行,执行完setImmediate会在下一个阶段的时候再执行setTimeout (timers 计时器执行阶段)
---------------------------macrotask(宏任务)---------------------------------
let fs = require('fs');
fs.readFile(__filename, function () {
console.log('fs');
setTimeout(function () {
console.log('timeout');
});
setImmediate(function () {
console.log('mmiediate');
});
});
输出结果 fs mmiediate timeout
解释:
在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。
---------------------------microtask 微任务---------------------------------
setTimeout(() => {
console.log('timer21')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
})
浏览器中的输出是一样的 输出 promise1 timer21
解释:
对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,,microtask 永远执行在 macrotask 前面。
------------------------microtask 微任务中的process.nextTick----------------------
Promise.resolve().then(function () {
console.log('then2'):
});
process.nextTick(function () {
console.log('nextTick1');
});
输出结果 nextTick1 then2
解释:
Node 中的 process.nextTick,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。因此微任务中nextTIck 会比then先执行。
微任务会优先与i\o文件操作执行。
let fs = require('fs');
fs.readFile(__filename, function () {
console.log('fs');
});
process.nextTick(function(){
console.log('text2');
})
输出结果 text2 fs