乐趣区

FE-ES 理解Event Loop

JavaScript 引擎又称为 JavaScript 解释器,是 JavaScript 解释为机器码的工具,分别运行在浏览器和 Node 中。而根据上下文的不同,Event loop 也有不同的实现:其中 Node 使用了 libuv 库来实现 Event loop; 而在浏览器中,html 规范定义了 Event loop,具体的实现则交给不同的厂商去完成。
浏览器中的 Event Loops
根据 2017 年新版的 HTML 规范 HTML Standard,浏览器包含 2 类事件循环:browsing contexts 和 web workers。
browsing contexts 中有一个或多个 Task Queue,即 MacroTask Queue,仅有一个 Job Queue,即 MicroTask Queue。

macrotask queue(宏任务, 不妨称为 A)

setTimeout
setInterval
setImmediate(node 独有)
requestAnimationFrame
I/O
UI rendering

microtask queue(微任务, 不妨称为 I)

process.nextTick(node 独有)
Promises
Object.observe(废弃)
MutationObserver

这两个任务队列执行顺序:

取 1 个 A 中的 task,执行之。
把所有 I 顺序执行完,再取 A 中的下一个任务。

为什么 promise.then 的回调比 setTimeout 先执行代码开始执行时,所有这些代码在 A 中,形成一个执行栈(execution context stack), 取出来执行之。遇到 setTimeout,则加到 A 中,遇到 promise.then,则加到 I 中。等整个执行栈执行完,取 I 中的任务。
(function test() {
setTimeout(function() {console.log(4)}, 0);
new Promise(function executor(resolve) {
console.log(1);
for(var i=0 ; i<10000 ; i++) {
i == 9999 && resolve();
}
console.log(2);
}).then(function() {
console.log(5);
});
console.log(3);
})()
// 1
// 2
// 3
// 5
// 4
// 浏览器渲染步骤:Structure(构建 DOM) ->Layout(排版)->Paint(绘制)
// 新的异步任务将在下一次被执行,因此就不会存在阻塞。
button.addEventListener(‘click’, () => {
setTimeout(fn, 0)
})
V8 源码 https://github.com/v8/v8/blob…https://github.com/v8/v8/blob…

NodeJS 中的 Event Loop
而在 Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask 队列的任务。

node 新加了一个微任务 process.nextTick 和一个宏任务 setImmediate.
process.nextTick
在当前 ” 执行栈 ” 的尾部 (下一次 Event Loop 之前) 触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。
process.nextTick(function A() {
console.log(1);
process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
console.log(‘TIMEOUT FIRED’);
}, 0)
// 1
// 2
// TIMEOUT FIRED
setImmediate
setImmediate 方法则是在当前 ” 任务队列 ” 的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行,这与 setTimeout(fn, 0)很像。
setImmediate(function A() {
console.log(1);
setImmediate(function B(){console.log(2);});
});

setTimeout(function timeout() {
console.log(‘TIMEOUT FIRED’);
}, 0);
// 不确定
递归的调用 process.nextTick()会导致 I /O starving,官方推荐使用 setImmediate()
process.nextTick(function foo() {
process.nextTick(foo);
});
//FATAL ERROR: invalid table size Allocation failed – JavaScript heap out of memory
process.nextTick 也会放入 microtask quque,为什么优先级比 promise.then 高呢在 Node 中,_tickCallback 在每一次执行完 TaskQueue 中的一个任务后被调用,而这个_tickCallback 中实质上干了两件事:

nextTickQueue 中所有任务执行掉(长度最大 1e4,Node 版本 v6.9.1)
第一步执行完后执行_runMicrotasks 函数,执行 microtask 中的部分 (promise.then 注册的回调) 所以很明显 process.nextTick > promise.then”

node.js 的特点是事件驱动,非阻塞单线程。当应用程序需要 I / O 操作的时候,线程并不会阻塞,而是把 I / O 操作交给底层库(LIBUV)。此时 node 线程会去处理其他任务,当底层库处理完 I / O 操作后,会将主动权交还给 Node 线程,所以 Event Loop 的用处是调度线程,例如:当底层库处理 I / O 操作后调度 Node 线程处理后续工作,所以虽然 node 是单线程,但是底层库处理操作依然是多线程。
根据 Node.js 官方介绍,每次事件循环都包含了 6 个阶段,对应到 libuv 源码中的实现,如下图所示
timers:这个阶段执行 timer(setTimeout、setInterval)的回调 I /O callbacks:执行一些系统调用错误,比如网络通信的错误回调 idle, prepare:仅 node 内部使用 poll:获取新的 I / O 事件, 适当的条件下 node 将阻塞在这里 check:执行 setImmediate() 的回调 close callbacks:执行 socket 的 close 事件回调
timers 阶段
timers 是事件循环的第一个阶段,Node 会去检查有无已过期的 timer,如果有则把它的回调压入 timer 的任务队列中等待执行,事实上,Node 并不能保证 timer 在预设时间到了就会立即执行,因为 Node 对 timer 的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。但是把它们放到一个 I / O 回调里面,就一定是 setImmediate() 先执行,因为 poll 阶段后面就是 check 阶段。
I/O callbacks 阶段
这个阶段主要执行一些系统操作带来的回调函数,如 TCP 错误,如果 TCP 尝试链接时出现 ECONNREFUSED 错误,一些 *nix 会把这个错误报告给 Node.js。而这个错误报告会先进入队列中,然后在 I/O callbacks 阶段执行。
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 阶段之后。
close 阶段
突然结束的事件的回调函数会在这里触发,如果 socket.destroy(),那么 close 会被触发在这个阶段,也有可能通过 process.nextTick() 来触发。
示例
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
*/
const fs = require(‘fs’)

fs.readFile(‘test.txt’, () => {
console.log(‘readFile’)
setTimeout(() => {
console.log(‘timeout’)
}, 0)
setImmediate(() => {
console.log(‘immediate’)
})
})
/*
readFile
immediate
timeout
*/
更多示例 libuv 源码 https://github.com/libuv/libu…
其他
requestAnimationFrame
HTML5 标准规定了 setTimeout()的第二个参数的最小值(最短间隔),不得低于 4 毫秒,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为 10 毫秒。另外,对于那些 DOM 的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每 16 毫秒执行一次。这时使用 requestAnimationFrame()的效果要好于 setTimeout()
客户端可能实现了一个包含鼠标键盘事件的任务队列,还有其他的任务队列,而给鼠标键盘事件的任务队列更高优先级,例如 75% 的可能性执行它。这样就能保证流畅的交互性,而且别的任务也能执行到了。但是,同一个任务队列中的任务必须按先进先出的顺序执行。
用户点击与 button.click()的区别:用户点击:依次执行 listener。浏览器并不实现知道有几个 listener,因此它发现一个执行一个,执行完了再看后面还有没有。click: 同步执行 listener。click 方法会先采集有哪些 listener,再依次触发。示例详情
参考资料 Promise 的队列与 setTimeout 的队列有何关联?浏览器的 Event LoopEvent Loops 深入理解 js 事件循环机制(Node.js 篇)JavaScript 运行机制详解:再谈 Event Loop

退出移动版