简介
我们都知道 JS 是一门单线程执行语言,单线程意味着每次只能处理一件事,意味着阻塞。JS 提供了很多异步代码,Event Loop(事件环,又称事件轮询)就是 JS 处理异步的一种机制。现在我们用一种比较形象的方式去解释这种机制
先备知识
在了解 Event Loop 之前,我们先要了解一些知识:
- 什么是(JS)异步?
在表现上来说,异步表现为代码的执行顺序和你书写的顺序不一样了。具体来说,是与同步相对,异步处理不用阻塞当前线程来等待处理完成,而是允许后续操作,直至其它线程将处理完成,并回调通知此线程。
- 什么是回调函数?
异步编程往往需要回调函数辅助(但有回调函数并不一定是异步编程)。结合上面提到的异步,举个例子,你去商店买东西,店员说没货,于是你登记自己的电话号码要求店员有货的时候打电话,然后你就可以继续做自己的事,随后进货后店员打电话通知你取货。在这个例子里,你的电话号码就叫回调函数,你把电话留给店员就叫登记回调函数,店里后来有货了叫做触发了回调关联的事件,店员给你打电话叫做调用回调函数,你到店里去取货叫做响应回调事件
// 下面这个并非异步
function call(cb) {
cb();
}
call(() => {});
// 异步
fetch('http://example.com/movies.json')
.then(function(response) {
return response.json();
})
setTimeout(param => {
console.log(param)
}, 1000, 'param')
- 进程和线程?
这两个概念都会在下面用到
进程: 进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,在现代系统中,进程基本都是线程的容器。(百度百科)
线程: 是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位,一条线程指的是进程中一个单一顺序的控制流(由此可见线程同一时间基本只能做一件事情)。(百度百科)
说白了,如果把我们的身体比喻成一个进程,那么线程就是我们身体的各个部件,因为线程同一时间基本只能做一件事情,所以我们脚走路的时候不能踢毽子,踢毽子的时候不能走路。
浏览器每打开一个 Tab 页,即为一个进程,其中又包括许多线程,比如渲染线程、JS 引擎线程、HTTP 请求线程、计时器等等。我们可以在 Chrome > 更多工具 > 任务管理器
中看到我们浏览器中的所有进程。
- 栈和队列?
栈(stack)和队列(queue)都是一个运算受限的线向表。
栈遵循的是“后进先出”原则,栈的模式就像我们挤地铁,最后进去的人(最外面的人,即栈顶)出去之后,先进去的人(最里面的人,即栈底)才能出来。
队列遵循的是“先进先出”原则,队列很语义化,和我们排队一样,前面的人先办业务,后面的人才能继续。
浏览器中的 Event Loop
首先我们引入执行栈的概念,JS 代码在执行时,每次遇到一个函数,便会在执行栈中压入这个函数,函数执行完会被移出执行栈,直到执行栈为空。
当遇到异步代码时(HTTP 请求),回调函数(会被挂起,由其他线程处理(比如网络线程),当处理完毕会把回调函数加入到 Task 队列中。一旦执行栈为空,Event Loop 就会从队列中取出一个函数放入执行栈中执行。所以本质上来说 JS 中的异步还是同步行为。
JS 中的 Task 队列分别两种,不同的任务源会被分配到不同的队列中。任务源可以分为微任务(microtask)和宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。看下图:
- 微任务包括 process.nextTick ,promise ,MutationObserver,其中 process.nextTick 为 Node 独有。
- 宏任务包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering。
[图片上传失败...(image-1e0115-1555772297164)]
Event Loop 的执行顺序如下:
- 执行最旧的 macrotask(一次);
- 检查是否存在 microtask,然后不停执行,直到清空队列(多次);
- 执行render;
- 然后开始下一轮 Event Loop;
这里有一个关键点,对于 macrotask,每次只会拉出一个来执行;对于 microtask,是把整体队列拉出来执行空。从图中我们也可以看到这一点
我们来看一下例子
console.log(1)
setTimeout(() => {
console.log(2)
new Promise(resolve => {
console.log(4)
resolve()
}).then(() => {
console.log(5)
}).then(() => {
console.log(5.1)
})
})
new Promise(resolve => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
}).then(() => {
console.log(8.1)
})
setTimeout(() => {
console.log(9)
new Promise(resolve => {
console.log(11)
resolve()
}).then(() => {
console.log(12)
}).then(() => {
console.log(12.1)
})
})
- 执行同步代码(macrotask),打印出 1 7;
- 执行完所有微任务,打印出 8 8.1;
- 执行一次宏任务,打印出 2 4;
- 执行完所有微任务,打印出 5 5.1;
- 执行一次宏任务,打印出 9 11;
- 执行完所有微任务,打印出 12 12.1;
Node Event Loop
Node 中的 Event Loop 与浏览器大有不同,这是因为 Node 中的异步操作更多更复杂,除了网络,定时器外,还有文件读写,数据库操作等等... 所以 Node 中 Event Loop 分为 6 个阶段,每个阶段都有一个回调队列等待执行,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
6个阶段
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
Node 会尽可能地保障定时器任务的准时可靠,所以有好几个阶段都是在处理定时器。我们分别来解释一下这 6 个阶段:
1. timers
这个阶段执行 setTimeout 和 setInterval 设定的回调。我们的定时器都会设定一个时间阈值,但这个值并不意味着回调会精确在这个时刻执行,而是时间超过阈值之后,回调会被尽早的执行。因为操作系统对其他回调的调度可能会延迟他们的执行。
2. pending callbacks
处理一些上一轮循环中的少数未执行的 I/O 回调。
3. idle,prepare
只在内部使用。这个阶段为一些系统操作执行回调,例如 TCP 错误。例如,如果一个 TCP socket 在尝试连接时收到 ECONNREFUSED,一些 *nix 系统会等待要报告这个错误。这会被放入 pending callbacks 阶段的队列中。
4. poll
poll 是一个至关重要的阶段,这个阶段会判断定时器的存在而更改 Event Loop 流程。
poll 阶段有两个主要功能:
- 计算需要阻塞多久然后轮询 I/O,然后
- 处理 poll 队列中的事件
当事件循环进入 poll 阶段,并且没有需要执行的定时器,会进入下面两种状况之一:
- 如果 poll 队列不是空的,事件循环会在队列上同步迭代直到完成全部队列的回调,或者系统依赖的限制被达到。
- 如果 poll 队列是空的,那么会进入下面两种状况之一:
- 如果有代码被 setImmediate() 设定,事件循环会结束轮询阶段,并且进入 check 阶段去执行设定的回调。
- 如果代码没有被 setImmediate() 设定,事件循环会等待回调被加入队列,然后立即执行它们。
一旦 poll 队列是空的,事件循环会检查超过阈值的定时器。如果一个或者多个定时器已经准备好,事件循环会绕回定时器阶段去执行定时器回调。
注意:为了防止 poll 阶段耗尽事件循环,libuv(实现了 Node.js 事件循环和这个平台全部异步行为的 C 语言库)也有对轮询事件有一个最大限制。
5. check
这个阶段允许用户在 poll 阶段完成之后立即执行回调。如果 poll 阶段变的空闲并且代码被 setImmediate() 放入队列,事件循环就可能进入 check 阶段而不是继续等待。
事实上 setImmediate() 是在事件循环一个单独的阶段运行的特殊计时器。它使用 libuv 的 API 让设定的回调在 poll 阶段完成之后进行。
通常,随着代码被执行,事件循环会最终达到 poll 阶段,在 poll 阶段会等待可能到来的连接、请求等等。但是,如果一个回调被 setImmediate() 设定并且 poll 阶段是空闲的,那么 poll 阶段就会结束,然后进入 check 阶段而不是继续等待轮询事件。
6. close callbacks
如果一个 socket 或者句柄被突然关闭(例如 socket.destroy()
),close
事件会在这个阶段被发送,否则它就会被通过 process.nextTick() 发送。
细节详解
setImmediate() vs setTimeout()
setImmediate() 和 setTimeout() 很相似,但是他们的行为却根据被调用的时机有差别。
- setImmediate() 是在当前 poll 阶段结束时立即执行。
- setTimeout() 是一段代码在时间阈值被超过之后尽早执行。
定时器被执行的顺序根据他们被调用的上下文会有区别。如果它们都在主模块被调用,时机会和进程的性能有关(可能会被机器的其他进程影响)。
例如,如果我们运行并不在一个I/O循环中的脚本(比如在主模块中),这两个定时器执行的顺序是不确定的,因为它被进程的性能限制。
- 首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
- 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
- 那么如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
但是,如果你把两个回调放入一个 I/O 循环中,那么 setImmediate() 的回调总是会被最先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。
// timeout_vs_immediate.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
相比于使用 setTimeout(),使用 setImmediate() 的主要优势是,如果在一个 I/O 循环中,setImmediate 总会比其他定时器先执行,无论其他定时器有多少。
microtask
上面介绍的都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,这里和浏览器类似,microtask 都会在在 macrotask 前面处理
process.nextTick()
你可能注意到 process.nextTick() 在示意图中没有展示,尽管它是异步 API 的一部分。这是因为 process.nextTick() 严格来说并不属于事件循环的一部分。实际上,nextTickQueue 会在每一个正在进行的操作完成后之后执行。它有一个自己的队列,当每个阶段完成后,如果存在 nextTickQueue,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
参考
- 官方文档
- 官方文档 - 翻译
- Event Loop的规范和实现