前言
我们知道 JS 是单线程的,这也意味着,同一个时间,它只能做一件事,那么,它是怎么做到在执行代码的同时,去监听一些事件的呢?
想要回答这个问题,我们就得去深入了解 JS 的事件循环机制。
现阶段来说,JS 的主要运行环境包含两个:
- 浏览器
- Node.js
两者的实现从结果上来说,可以说殊途同归,深入到内部却大相径庭。
本文的内容也将包含这两个部分:
- 浏览器内部的事件循环机制。
- Node.js内部的事件循环机制。
废话不多说,直接开始吧。
浏览器的事件循环机制
进入正题之前,我们需要先了解一些基本概念。
首先,JS 虽然是单线程的,运行它的浏览器却不是,浏览器是多进程的框架,每一个标签页都包含一个独立的渲染进程,那里面又包含若干独立线程。
多进程架构的浏览器JS 引擎线程负责 JS 代码的解释和运行,除此之外,那里面还包含一个定时器触发线程、一个事件触发线程、一个异步 HTTP 请求线程。
有了这个基本概念之后,我们就可以详细来看看 JS 的事件循环机制了。
在浏览器里,JS 的事件循环机制是怎样的?
我们知道 JS 引擎在执行一段代码时,会首先将这些代码按顺序排放好,再依次执行这些内容。同步的代码,它当然可以直接执行,但如果遇到异步的,它就会触发一些操作。
- 遇到计时器(setTimeout、setInterval 等),它会找到定时器触发线程。
- 遇到事件监听(鼠标点击、键盘操作等),它会找到事件触发线程。
- 遇到异步 HTTP 请求,它会找到异步 HTTP 请求线程。
把事情交给这些线程去处理,JS 引擎继续往下执行。
接下来,在 JS 引擎忙着执行余下的代码时,定时器触发线程开始了倒计时,事件触发线程开始了事件监听,异步 HTTP 请求线程发起了 HTTP 请求。
当倒计时结束、事件被触发、HTTP请求有了回应,这些线程会再次跟 JS 引擎线程通信,它们会告诉它:好消息,异步操作执行结束了,可以开始执行回调函数了!
但这时候 JS 引擎可能正忙,前面我们又说了,JS 是单线程的,同一个时刻,只能做一件事,那么,该设计一个怎么样的机制,才能让 JS 引擎能够及时收到这些消息,并以最快的速度去执行这些回调呢?
答案是任务队列。
JS 引擎会维护一个任务队列,当它闲置下来时,便会去查询这个任务队列,发现那里面不为空,它便按照次序,去执行那里面的回调。
于是那些异步任务处理线程需要做的事,也就是在异步操作执行结束之后,把需要执行的回调函数,推到这个队列里面去。
这样一来,事情就很清晰了。
我们来给浏览器里的 JS 事件循环机制下一个定义吧。
浏览器里的 JS 事件循环机制指的是:JS 引擎会维护一个任务队列,当异步操作发生时,它会将该异步操作分配给不同的线程,等异步操作执行结束,这些线程再将各自的回调加入任务队列中,等待 JS 引擎闲置时去执行。
浏览器的 JS 引擎循环执行任务队列内容的操作,就被称为浏览器的 JS 事件循环机制。
来个示意图。
浏览器里的 JS 事件循环机制有了这个概念之后,我们就可以进一步深入了,——是的,还没完,我们还可以进一步深入!
下面来介绍两个新概念:宏任务和微任务。
不要被名字吓到,你其实跟它们很熟悉。
宏任务(macro task)和微任务(micro task)
前面我们提到一个任务队列,它由 JS 引擎创建,事件循环也是针对它发生。
在上面,我们为了方便理解,假设只有一个这样的任务队列,JS 引擎有空时,便去按顺序执行那里面的内容。
但其实这样的任务队列不止一个。
这样的任务队列有两个:宏任务队列和微任务队列。
这两个队列的作用很好理解,宏任务队列就是用来保存宏任务的,微任务队列就是用来保存微任务,一眼就能看明白,那么问题来了,宏任务是什么?微任务又是什么?
宏任务是什么?微任务又是什么?
简单来说,这就是一个分类。
我们知道,JS 代码在执行过程中会产生很多异步任务,前面提到的计时器是其中一种,事件监听是另一种,除此之外,还有 HTTP 请求、Promise 和 MutaionObserver 等。
出于某种原因,JS 引擎对它们进行了分类,将一部分称为宏任务,剩下的称为微任务。
常见宏任务如下:
- 计时器:setTimeout、setInterval、setImmediate
- 事件监听:鼠标点击和键盘事件等
- 异步 HTTP 请求
常见微任务如下:
- promise.then、promise.catch
- MutaionObserver
- process.nextTick
浏览器的 JS 引擎,对宏任务队列和微任务队列的处理方式也是不同的。
简单来说,微任务队列的优先级高于宏任务队列。
在微任务队列不为空的情况下,JS 引擎优先执行微任务队列的内容,等微任务队列空了,它才会去执行宏任务队列的内容。
考虑一下下面一段代码。
console.log('同步代码1');
setTimeout(() => console.log('宏任务:setTimeout'), 0);
new Promise((resolve) => {
console.log('同步代码2');
resolve();
})
.then(() => console.log('微任务:promise.then'));
console.log('同步代码3');
这段代码的输出结果是:
- 同步代码1
- 同步代码2
- 同步代码3
- 微任务:promise.then
- 宏任务:setTimeout
虽然 setTimeout 先于 promise.then 被添加进任务队列,但因为微任务队列的优先于高于宏任务队列的缘故,promise.then 被先一步执行。
Node.js 的事件循环机制
首先,我们知道,事件循环机制之所以出现,最重要一个原因,就是 JS 是单线程的。
它不像其他多线程语言一样,可以在需要的时候,随时开一个线程,去单独处理任务,再在结束之后,将线程销毁。它避开了新建、销毁线程,以及线程切换的资源消耗,同时也带来了新的问题。
如何处理异步操作?
为了解决这个问题,有了 JS 的事件循环机制。
一言以蔽之,JS 事件循环机制的诞生,就是为了解决 JS 执行过程中所遇到的异步操作问题。
在浏览器端,我们通常可能遇见的异步操作有:
- 计时器
- 事件监听
- 异步请求
- 其他种种
到了 Node.js 方面,跟用户有关的鼠标和键盘操作的用户事件监听虽然没了,却多了很多服务端特有的异步任务。
- 文件读写相关的 I/O 任务
- 线程相关的 process.nextTick
- 服务相关的 server.close,socket.on('close', ...)
所以虽然在 Node.js 端,事件循环机制仍然涉及到主线程、任务队列、宏任务、微任务的概念,微任务的优先级,也仍旧高于宏任务,其他方面却还存在一些不同。
首先,它重新给宏任务分了类。
其次,它添加了任务阶段的概念。
下面我们逐一来了解。
宏任务的新分类
- 计时器 Timers:用于存储和执行
setTimeout
、setInterval
等的回调函数。 - 系统任务回调 Pending Callbacks:用于存储和执行系统任务执行结束之后的回调,比如端口监听、比如 TCP 错误。
- I/O 回调 I/O Polls:用于存储和执行 I/O 执行结束之后的回调,比如读写文件、比如读写数据库。
- 检查 Check:用于存储和执行
setImmediate
的回调。 - 关闭回调 Close Callbacks:用于存储和执行关闭相关的回调,比如
socket.on('close', ...)
。
任务阶段这个新概念
在浏览器里,JS 引擎单独维护一个宏任务队列,队列内的宏任务按照入队时间,依次被执行。
但在 Node.js 内,事情变得有一点点不相同。
它会按照宏任务的类型,维护不同的宏任务队列内。
计时器有一个单独的计时器队列;系统任务回调有一个单独的系统任务回调队列;I/O 回调也有单独的I/O 回调队列。
当事件循环函数开始执行,它会依次去读取这些队列,再依次执行这些队列内的回调函数,等一个队列被清空,它进入下一个。
相当于在浏览器里,宏任务们不分组,只要是宏任务,大家都待在一起,执行时按照入队顺序。到了 Node.js 里,不同的宏任务被分了组,只有前一组执行结束,后一组才有执行的机会。
举一个具体的例子吧。
计时器队列内有 3 个待执行回调,系统任务队列内有 5 个待执行回调,I/O 队列内有 2 个,那么等事件循环函数开始执行时,它不会把这些任务混杂在一起,先后去执行,它会先去执行计时器队列内的回调,等计时器队列的三个回调执行结束,它再去执行系统任务回调,之后来到 I/O。
Node.js 的事件循环机制Node.js 的跨平台能力是基于 Libuv 库实现的,JS 引擎并不会真正去操作数据库或者读写文件,它所做的事情,只是让 JS 去调用 Node.js 的 API,而 Node.js 的 API 真正调用的,又都是底层的C++代码。
Livub 库给不同类型的宏任务进行了分组,这些分组,就是 Node.js 事件循环的不同任务阶段。
它们的执行顺序如下图所示:
Node.js 事件循环的不同任务阶段循环开始时,循环函数会先去读取计时器回调的内容,等那里的回调队列被清空,它继续去读取系统任务的回调队列,之后来到 I/O 回调阶段。
I/O 回调阶段跟其他几个阶段有些不同。
这个阶段的回调队列被清空后,循环函数不会立刻往下,它会先进行一些检查。
- 如果它的下一个阶段,也就是 check 阶段的回调队列(setImmediate 的回调)不为空,那么它继续往下走去。
- 如果 check 阶段的回调队列为空,但 timers 阶段的回调队列不为空,那么循环函数将从头开始,——从 timers 阶段开始,重新执行。
- 如果这两者都为空,那么它将暂停执行,等待新的回调进入。
有了这个了解之后,我们再来看看 Node.js 微任务。
浏览器端区分了宏任务和微任务,Node.js 端同样如此,这儿的微任务定义也跟浏览器端没有差别。对于 Node.js 来说,常见的微任务同样是那些,但在执行的时机上,两者还是存在一些细微的差别。
Node.js 11 之前,Libuv 会在执行完一个任务阶段的所有回调之后,再去检查和执行微任务队列的内容,等微任务队列被清空,它进入下一个任务阶段。
也就是说,下面这段代码的输出,可能不尽如人愿。
setTimeout(() => {
console.log('宏任务 timers 阶段:timeout1')
Promise.resolve().then(function () {
console.log('微任务:promise1');
});
}, 0);
setTimeout(() => {
console.log('宏任务 timers 阶段:timeout2')
Promise.resolve().then(function () {
console.log('微任务:promise2');
});
}, 0);
在浏览器里,这段同步代码执行结束后,定时器触发线程会多出两条计时任务。
由于计时任务属于宏任务,所以时间到了之后,这两条计时任务的回调函数,会被先后添加进 JS 引擎的宏任务队列。
第一个回调函数先被执行,控制台输出宏任务 timers 阶段:timeout1,Promise.resolve 属于同步代码,直接执行,其后的 Promise.then 向 JS 引擎的微任务队列,添加一条微任务。
第一个回调函数执行结束后,回调函数本身被从宏任务队列当中移除,之后,由于微任务的优先级高于宏任务,所以浏览器会暂停遍历宏任务队列,先去检查微任务队列的内容,发现不为空,于是第二条微任务:promise1被输出。
微任务队列被清空后,JS 引擎会继续往下,检查宏任务队列的内容,于是第三条信息宏任务 timers 阶段:timeout2 被输出,之后再去检查微任务队列,第四条信息微任务:promise2 被输出。
于是在浏览器,这段代码的输出依次是:
- 宏任务 timers 阶段:timeout1
- 微任务:promise1
- 宏任务 timers 阶段:timeout2
- 微任务:promise2
但到了 Node.js 11 之前的环境里,情况变得不太相同,在这里,这段代码的输出内容是:
- 宏任务 timers 阶段:timeout1
- 宏任务 timers 阶段:timeout2
- 微任务:promise1
- 微任务:promise2
这是因为,在 Node.js 11 之前的环境里,事件循环会先将一个阶段的所有任务先清空,再去检查微任务队列的内容,在这里,代码的执行次序如下:
- 同步代码执行结束后,timers 阶段多出两条计时任务。
- 时间到了,两条计时任务的回调函数,先后被添加进 timers 阶段的待执行回调队列中。
- 事件循环来到 timers 阶段,发现任务队列不为空,于是,先执行第一条计时任务的回调函数:输出 timeout1,将输出 promise1 添加进微任务队列;再执行第二个计时任务的回调函数:输出 timeout2,将输出 promise2 添加进微任务队列。
- timers 阶段的回调队列被清空,往下个阶段进发之前,先去检查微任务队列,发现微任务队列不为空,按照顺序依次执行,promise1 和 promise2 也相继被输出。
于是输出的内容就成了上面描述的那样。这个偏差看起来不大,却会给编写程序的程序员带来不小影响,于是 Node.js 11 之后,事件循环的机制被调整,Node.js 端的输出内容,也开始跟浏览器端一致,也就是说,每个阶段的每个回调执行结束后,在继续执行下一个回调之前,事件循环相关函数会先去检查和执行微任务队列的内容,之后再回来。
好嘞,以上就是本文的全部内容啦,感谢看到这里的大小可爱们,有机会再见!
网友评论