前言
我们知道, JavaScript 是一门单线程的语言,这意味着在一段时间内, JavaScript 引擎只会有一个线程去执行 JavaScript 脚本。
那么,为什么会将 JavaScript 设计成一门单线程的语言呢?
单线程的好处
-
保证交互行为的一致性。
JavaScript 在设计之初是作为浏览器脚本语言 ,主要用于与用户进行页面交互 和操纵 DOM 。因此,为避免由不可预测的用户操作可能带来的复杂的并发问题,JavaScript 只能设计成单线程的。比如,如果多个线程同时修改一个 DOM 元素,将会导致结果的不确定性,通过单线程控制,可以避免竞态和同步问题。
-
避免复杂的并发控制。
在多线程环境下,开发者需要考虑多个线程之间的通信、同步和锁定等并发问题,这会增加编程的复杂性,而单线程设计可以简化这些问题,使开发者不用关注这些复杂的控制机制。
-
减少内存资源占用率。
相比于多线程,单线程的设计可以减小内存资源的占用率,节省上下文切换的时间,在一定程度上,提升性能以及用户体验。
单线程带来的问题
但是,单线程也会带来一些问题。假设你需要执行一个请求资源的操作,因为只有一个线程去执行代码,所以如果这个请求资源的操作需要花费很长的时间,那么将会阻塞后面代码的执行。比如,如果后面还有读取文件的操作,那么就必须等到请求资源完成后才能执行读取文件的操作。显然,这两个操作没有相关性,完全可以同时进行。
那么,JavaScript 是如何解决这个问题的呢?接下来,将会引入异步的概念。
同步和异步
- 同步 :如果一段代码在主线程里能立即执行并得到返回结果,那么可以称之为同步代码;
- 异步 :如果一段代码在主线程里不能立即执行并得到返回结果,而需要将其交由其他模块处理,并在某个时间点后将结果返回给主线程,那么可以将其称之为异步代码。
引入异步机制可以有效解决单线程中,某段代码因长时间的执行阻塞后面的代码,导致程序长时间阻塞而不可用的问题。
在 JavaScript 中,异步机制是由事件循环机制来实现的。
在讲解事件循环之前,先来说说浏览器中的进程和线程。
浏览器的进程和线程
关于进程和线程的概念,可以参考这篇文章:✊构建浏览器工作原理知识体系(开篇)
简单讲,可以将浏览器比作一个工厂,进程就相当于里面的一个个车间,而线程就相当于每个车间里的一条条流水线,这些流水线是工厂中实际运作的单位。
浏览器有哪些进程?
最新的 Chrome 浏览器包括:1 个浏览器进程、1 个 GPU 进程、1 个网络进程、多个渲染进程和多个插件进程。
-
浏览器进程
浏览器进程是浏览器的主进程 ,无论打开多少浏览器窗口,它仅有一个。
它主要负责浏览器界面显示 、用户交互 和进程管理。
这里说的界面和交互,不是视窗内的网站界面。而是指浏览器本身自带的部分,如地址栏、导航栏(前进,后退,刷新按钮)、书签栏等,以及处理 web 浏览器不可见的特权部分,如网络请求与文件访问。
刚打开浏览器的时候只有一个浏览器进程,其他进程都是它创建的。
-
渲染进程
渲染进程负责控制和显示视窗 部分(网站页面)的所有内容,主要是解析 HTML、CSS、JS 和其他资源,并生成渲染树、执行布局和绘制等操作,可以有多个。
在现代浏览器中,默认会为每个标签页 创建一个渲染进程。出于安全考虑,渲染进程运行在沙箱模式下,无法访问系统资源。
-
网络进程
网络进程负责处理网络请求,每个标签页都共享同一个网络进程,以减少资源占用。它主要负责处理网站的数据请求和响应 ,通常情况下,它与渲染进程的交互最为密切。每当网站需要进行资源请求,渲染进程就会将任务交给网络进程处理,网络进程取得响应结果后再返回给渲染进程。
网络进程内部会开启多个线程,以实现多网络请求 的异步化处理。
-
GPU 进程
处理独立于其他进程的 GPU 任务,GPU 被分成不同进程,因为 GPU 处理来自多个不同应用的请求并绘制在相同表面。
负责处理浏览器中与图形相关的任务,例如加速页面绘制、处理 CSS 动画、执行 WebGL 操作等。
GPU 进程与渲染进程分离,以提高性能。
-
插件进程
如果页面使用了浏览器插件,则还会有对应的插件进程。
渲染进程有哪些线程?
浏览器中最重要的进程就是渲染进程,它负责一个页面(标签页面)的渲染过程。
其中,渲染进程中含有五大类线程:
-
JavScript 引擎线程
执行 JavaScript 脚本程序(也称为 "主线程")。
-
GUI 渲染线程
负责渲染页面,当页面触发回流、重绘时会执行该线程。
注意,GUI 渲染线程和 JavaScript 引擎线程是互斥的,因为两个线程都可能会对页面的 DOM 元素进行操作,若两个线程同时运行则可能造成操作冲突。当 JavaScript 引擎线程执行时,GUI 渲染线程会被挂起,所有的 GUI 更新会被暂存到一个队列中,等到 JavaScript 引擎线程空闲时立即执行。
-
事件触发线程
负责处理用户输入和触发相应的事件。例如,当用户点击按钮时,事件触发线程会处理这个点击事件。
-
定时器触发线程
负责处理 JavaScript 中通过
setTimeout
和setInterval
设置的定时器,到达指定时间时处理相应的回调函数。 -
异步 HTTP 请求线程
XMLHttpRequest 连接后,会新增异步 HTTP 请求线程,检测到状态变更之后,异步 HTTP 请求线程将回调函数放入事件队列中,经过事件循环后交给 JavaScript 引擎线程处理。
所以,最后回到之前的问题:JavaSript 是单线程的,在同一个时间只能做一件事情,那为什么浏览器可以同时执行异步任务呢?
这是因为浏览器是多线程的,当 JavaSript 需要执行异步任务时,浏览器会另外启动一个线程去执行该任务。也就是说,JavaScript 是单线程的指的是执行 JavaScript 代码的线程只有一个,是浏览器提供的 JavaScript 引擎线程(主线程)。除此之外,浏览器中还有定时器触发线程、 异步 HTTP 请求线程等线程,这些线程主要不是来执行 JavaScript 代码的。
比如主线程中需要发送数据请求,就会把这个任务交给异步 HTTP 请求线程去执行,等请求数据返回之后,再将需要执行的 JavaScript 回调函数交给 JavaSript 引擎线程去执行。也就是说,浏览器才是真正执行发送请求这个任务的角色,而 JavaScript 只是负责执行最后的回调处理。所以这里的异步不是 JavaScript 自身实现的,而是浏览器为其提供的能力。
浏览器的事件循环
浏览器中的事件循环过程主要由以下模块组成:
-
调用栈(Call Stack)
调用栈是一个存储函数调用 的栈结构,遵循先进后出 的原则,执行所有同步代码(由于 JavaScript 是单线程的,所以只会有一个调用栈)。
每执行调用栈的一个方法,都会为它生成独有的执行环境(上下文),当方法执行完成后,就会销毁当前的执行环境并从调用栈中弹出该方法,然后继续执行下一个方法。
-
Web 接口(Web APIs)
浏览器提供了一系列的接口,让用户可以和浏览器进行交互。
例如:
- Fetch API (fetch):进行网络资源请求
- Timers API (setTimeout, setInterval ...):设置延迟任务或者重复任务
- Console API (console):打印输出
- Geolocation API (Geolocation, GeolocationPosition ...):允许用户向网页应用提供地理位置
- Web Storage API (localStorage, sessionStorage ...):提供存储键值对的能力
- File API (Blob, File, FileList ...):读取操作文件
- Performance API (Performance, PerformanceMeasure ...):测量网页应用的性能指标
- HTML DOM (HTMLElement, HTMLDivElement ...):定义 HTML 中的每个元素
- URL API (URL ...):操作 URL 链接
- ...
其中,一些接口可以开启其他线程来处理程序,例如,
setTimeout
和setInterval
方法可以开启定时器触发线程来处理异步代码(传入setTimeout
或setInterval
的回调函数)。由于其他线程会处理这些异步代码,因此不会阻塞主线程也就是调用栈里的同步代码。这些异步接口能够实现异步功能要么是基于回调函数 ,要么是基于 Promise。
-
宏任务队列(Task Queue)
当异步代码交由其他线程处理后,回调函数不会立即返回到调用栈中,因为调用栈中可能还存在正在执行的同步代码,如果直接插入到调用栈中,可能会破坏同步代码的执行结果。
事实上,这些回调函数会被存放到一个消息队列中,遵循先进先出 的原则,当调用栈为空时,依次返回到调用栈中执行。
常见的宏任务:
- 定时器任务:例如
setTimeout
、setInterval
- I/O 任务:例如网络请求、文件读写等需要进行输入输出操作的任务
- 用户交互任务:例如点击事件、键盘输入事件等与用户交互相关的任务
- 渲染任务:当浏览器需要回流或者重绘时触发的任务
- 请求动画帧任务:通过
requestAnimationFrame
方法设置的任务,用于在每一帧进行绘画操作 - script 整体代码
这些任务都是比较耗时 的任务,在事件循环中被视为宏任务 ,需要等待一定时间或者触发特定条件才会执行。
- 定时器任务:例如
-
微任务队列(Microtask Queue)
和宏任务队列一样,微任务队列遵循先进先出 的原则,当调用栈为空 时,依次返回到调用栈中执行。只不过,微任务队列的优先级更高,当宏任务队列和微任务队列中均有任务时,会优先执行微任务队列中的任务,且执行过程中如果产生新的微任务仍会插入到微任务队尾,只有当所有微任务执行完,检测微任务队列为空时,才会执行宏任务队列中的任务。
常见的微任务:
- Promise 中 then, catch, finally 中的回调函数
- async/await 函数中 await 后面代码块的任务
- queueMicrotask 方法的回调函数
- new MutationObserver() 中的回调函数
这些任务通常是较小且轻量级的操作,执行时间较短,适合在当前宏任务执行完毕后立即执行。由于微任务的执行时机在每个宏任务执行的过程中,因此可以保证在用户交互之前或渲染之前得到及时处理。
执行顺序
- 判断调用栈是否为空,如果不为空,则执行调用栈里的同步代码;
- 如果调用栈不为空,优先判断微任务队列中是否有微任务,如果有,则从中取出微任务放到调用栈中执行,如果期间产生新的微任务,则也会被加到微任务队尾等待执行;
- 如果微任务队列为空,则判断宏任务队列中是否有宏任务,如果有,则从中取出宏任务放到调用栈中执行,如果期间产生新的微任务,则会被加到微任务队列中;
- 当前宏任务执行完成之后,重复操作2,直到宏任务队列为空。
多说无益,下面举几个例子帮助理解事件循环的流程。
案例一
代码:
javascript
console.log('start');
navigator.geolocation.getCurrentPosition(
position => console.log(position),
error => console.log(error)
);
console.log('end');
输出结果:
shell
start
end
GeolocationPosition { ... }
讲解以上代码执行流程:
- 初始状态下,调用栈和微任务队列均为空,script 脚本作为一个宏任务插入到宏任务队列中。
- 因为调用栈为空且微任务队列为空,从宏任务队列中取任务返回给调用栈(主线程,也叫 JavaScript 引擎线程)执行。
- 调用栈执行
console.log('start')
,控制台打印start
。
console.log('start')
执行完成之后,将其从调用栈中弹出,继续执行下一行:navigator.geolocation.getCurrentPosition()
。
- 由于是异步 API,将其交由事件触发线程处理,线程会监听用户的输入事件,如果用户选择了地理位置,则将成功的回调函数推入宏任务队列中,如果操作过程中出现了错误,将失败的回调函数推入宏任务队列中。
- 在此期间,主线程没有被阻塞,仍然可以执行同步代码。执行下一行:
console.log('end')
,控制台输出end
,执行完毕后,将console.log('end')
弹出调用栈。 - 此时,调用栈为空,优先考虑执行微任务,因为此时微任务队列为空,则从宏任务队列中取任务执行,若此时用户已经正确选择了地理位置,则宏任务队列中会有成功的回调函数:
position => console.log(position)
。将该回调函数返回给调用栈执行,控制台打印用户的地理位置后,将该回调函数从调用栈中弹出。 - 此时,调用栈为空且微任务队列和宏任务队列均为空,事件循环结束。
案例二
代码:
javascript
setTimeout(() => {
console.log('timer1');
}, 300);
setTimeout(() => {
console.log('timer2');
}, 10);
console.log('end');
输出结果:
shell
end
timer2
timer1
执行流程:
- 初始状态下,调用栈和微任务队列均为空,script 脚本作为一个宏任务插入到宏任务队列中。
- 因为调用栈为空且微任务队列为空,从宏任务队列中取任务返回给调用栈(主线程,也叫 JavaScript 引擎线程)执行。
- 调用栈执行第一个
setTimeout
函数,此时主线程会将代码交由定时器触发线程 处理,定时器触发线程负责计时,当 300 ms 后将回调函数插入到宏任务队列中(注意,这里的 300 ms 是从定时器触发线程计时到回调函数被插入到宏任务队列的时间,而不是从定时器触发线程计时到回调函数返回到调用栈被执行的时间)。
- 将第一个
setTimeout
函数弹出调用栈,执行第二个setTimeout
函数,此时主线程将代码交由定时器触发线程处理,定时器触发线程计时 10 ms 后将该回调函数插入到宏任务队列中(同理,这里的 10 ms 指的是从定时器触发线程计时开始,到回调函数被插入到宏任务队列的时间)。
- 将第二个
setTimeout
函数弹出调用栈,执行console.log('end')
,然后将其弹出调用栈。
- 调用栈为空,检查微任务队列是否有微任务,此时微任务队列为空,而后检查宏任务队列是否为空,经过 10 ms 后,timer2 的回调函数被插入到宏任务队列中,执行这个宏任务,将该回调函数返回给调用栈,调用栈执行
console.log('timer2')
,执行完成后将其弹出调用栈。
- 调用栈为空,检查微任务队列是否有微任务,此时微任务队列为空,而后检查宏任务队列是否为空,经过 300 ms 后,timer1 的回调函数被插入到宏任务队列中,执行这个宏任务,将该回调函数返回给调用栈,调用栈执行
console.log('timer1')
,执行完成后将其弹出调用栈。
- 调用栈为空,检查微任务队列是否有微任务,此时微任务队列为空,而后检查宏任务队列是否为空,此时宏任务队列为空,事件循环结束。
案例三
代码:
javascript
Promise.resolve().then(() => console.log(1));
setTimeout(() => console.log(2), 10);
queueMicrotask(() => {
console.log(3);
queueMicrotask(() => console.log(4))
});
console.log(5);
输出结果:
shell
5
1
3
4
2
执行流程:
- 初始状态下,调用栈和微任务队列均为空,script 脚本作为一个宏任务插入到宏任务队列中。
- 因为调用栈为空且微任务队列为空,从宏任务队列中取任务返回给调用栈(主线程,也叫 JavaScript 引擎线程)执行。
- 调用栈执行
Promise.resolve()
,得到一个 fullfilled 的 Promise 实例,执行完成之后将Promise.resolve()
弹出调用栈。
- 调用栈执行
.then()
,由于是微任务,将其回调函数立即插入到微任务队列中,而后将.then()
弹出调用栈。
- 调用栈执行
setTimeout
,将回调函数交由定时器触发线程处理,将在 10ms 后,把回调函数插入到宏任务队列中。
- 调用栈执行
queueMicrotask()
,由于是微任务,将它的回调函数立即插入到微任务队列中,而后将queueMicrotask()
弹出调用栈。
- 执行
console.log(5)
,执行完成后将其弹出调用栈。
- 调用栈为空,检查微任务队列是否有任务,有任务,取出第一个微任务(.then() 的回调函数),返回给主线程的调用栈执行,执行
console.log(1)
,执行完成后,弹出调用栈。
- 调用栈为空,检查微任务队列是否有任务,有任务,取出第一个微任务(queueMicrotask() 的回调函数),返回给主线程的调用栈执行,执行
console.log(3)
,执行完成后,弹出调用栈。
- 然后执行
queueMicrotask()
,由于是微任务,将它的回调函数立即插入到微任务队列中,而后将queueMicrotask()
弹出调用栈。
- 调用栈为空,检查微任务队列是否有任务,有任务,取出第一个微任务(queueMicrotask() 的回调函数),返回给主线程的调用栈执行,执行
console.log(4)
,执行完成后,弹出调用栈。
- 调用栈为空,检查微任务队列是否有任务,无任务,检查宏任务队列是否有任务,有任务,取出第一个宏任务(setTimeout() 的回调函数),返回给主线程的调用栈执行,执行
console.log(2)
,执行完成后,弹出调用栈。
- 调用栈为空,检查微任务队列是否有任务,无任务,检查宏任务队列是否有任务,无任务,事件循环结束。