Jacleklm's Blog

JS的事件循环和Node.js的事件循环

2019/11/03

JS 的事件循环

宿主环境

JS 运行的环境称之为宿主环境,eg. 浏览器,服务器,桌面等

执行环境栈

放执行环境的地方,栈尾就是活动的执行环境

浏览器内核

浏览器内核是多线程的:GUI 渲染线程、JavaScript 引擎线程、定时触发器线程、事件触发线程、异步 http 请求线程

  1. 渲染引擎和 JS 引擎是互斥的,所以渲染引擎在解析 DOM 的时候遇到 srcipt 会被暂停,阻塞渲染
  2. 主线程依次执行代码时,遇到定时器,会将定时器交给定时器触发线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待 JS 引擎线程执行
  3. 事件触发线程遇到事件,eg. setTimeout 计时结束,Ajax 异步请求成功触发回调,该线程会将对应的事件处理程序加入任务队列的尾部,等待 JS 引擎的执行
  4. http 请求线程处理 fetch,ajax,axios 等,也是主线程遇到异步请求就交给该线程处理,当监听到状态码变更,就把回调函数给事件线程,事件线程把回调函数加进任队列尾部

Event Loop

JS 引擎对任务队列的取出执行方式,以及与宿主环境的配合,称之为事件循环(Event Loop)

当上面的线程发生了某些事,事件循环发现这些事有对应的处理程序(eg. 异步代码,延时器),事件循环会把处理程序挂起并放进任务队列中。一旦执行栈为空,事件循环就会从事件队列队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。

微任务与宏任务

不同的任务源会被分配到不同的事件队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。宏任务队列可以有多个,微任务队列只有一个

微任务:

  • process.nextTick
  • promise.prototype.then
  • Object.observe
  • MutationObserver

宏任务:

  • script
  • setTimeout(比 setImmediate 前)
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

所以正确的一次 Event loop 顺序是这样的:

  • 执行同步代码,这属于宏任务
  • 执行栈为空,查询是否有微任务需要执行
  • 执行所有微任务
  • 必要的话渲染 UI
  • 然后开始下一轮 Event loop,执行宏任务中的异步代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
console.log('script start')

setTimeout(function() {
console.log('setTimeout')
}, 0)

new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})

console.log('script end')
// script start => Promise => script end => promise1 => promise2 => setTimeout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
console.log('script start')

async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()

setTimeout(function() {
console.log('setTimeout')
}, 0)

new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})

console.log('script end')
// 把async理解为promise的另一种写法即可
// script start => async2 end => Promise => script end =>async1 end => promise1 => promise2 => setTimeout
1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.resolve().then(() => {
console.log('Promise1')
setTimeout(() => {
console.log('setTimeout2')
}, 0)
})
setTimeout(() => {
console.log('setTimeout1')
Promise.resolve().then(() => {
console.log('Promise2')
})
}, 0)
// Promise1 => setTimeout1 => Promise2 => setTimeout2

一道腾讯PCG的面试题。同理把 async 理解为 Promise 的写法即可,这样 async1 函数中 的 async2 其实就是Promise中的同步部分,await后的部分就是 .then

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
async function async1 () {
console.log('async1 start');
await async2();
console.log('async1 end');
}

async function async2 () {
console.log('async2');
}

console.log('script start');

setTimeout(function () {
console.log('setTimeout');
}, 0);

async1();

new Promise(function (resolve) {
console.log('promise1');
resolve();
}).then(function () {
console.log('promise2');
});

console.log('script end');

// script start - async1 start - async2 - promise1 - script end - async1 end - promise2 - setTimeout

Node.js 的事件循环

在 Node.js 中,Event Loop 是一个消息线程。和 JS 的 Event Loop 是完!全!不!同!的!想起昨天我面试时的回答,我一直以为它们两个是一个机制,顿时觉得凉凉…

Node.js

Node.js 采用 V8 作为 js 的解析引擎,而 I/O 处理方面使用了自己设计的 libuv,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API,事件循环机制也是它里面的实现

六个阶段

libuv 引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行

所以 node 中事件循环的顺序是:
外部输入数据–>轮询阶段(poll)–>检查阶段(check)–>关闭事件回调阶段(close callback)–>定时器检测阶段(timer)–>I/O 事件回调阶段(I/O callbacks)–>闲置阶段(idle, prepare)–>轮询阶段(按照该顺序反复运行)…

我目前是理解为 node 把宏任务分类了,分为了 timer 队列, IO 队列,setImmediate 队列…这些不同的宏任务队列在 node 的不同阶段执行

  • timers 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调,即执行完timer 队列中的任务
  • I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
  • idle, prepare 阶段:仅 node 内部使用
  • poll 阶段:获取新的 I/O 事件, 适当的条件下 node 将阻塞在这里
  • check 阶段:执行 setImmediate() 的回调
  • close callbacks 阶段:执行 socket 的 close 事件回调

Node 中 process.nextTick 是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会执行,并且优先于其他 microtask 执行(可以理解为优先度最高的微任务?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1

两种事件循环的区别

浏览器环境下,microtask 的任务队列是每个 macrotask 执行完之后执行。而在 Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一类宏任务都执行完,再去执行 microtask 队列的任务
我现在理解为浏览器中是执行完一个宏任务再执行微任务,node是执行完一类宏任务再执行微任务


我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
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 的事件循环(Event Loop)有何区别?
JavaScript 运行机制详解:再谈 Event Loop
掘金小册-前端面试之道

CATALOG
  1. 1. JS 的事件循环
    1. 1.1. 宿主环境
    2. 1.2. 执行环境栈
    3. 1.3. 浏览器内核
    4. 1.4. Event Loop
      1. 1.4.1. 微任务与宏任务
  2. 2. Node.js 的事件循环
    1. 2.1. Node.js
    2. 2.2. 六个阶段
  3. 3. 两种事件循环的区别