前言
大家好,今天给大家带来两个JS中的难点讲解--浏览器的多进程/多线程,以及EventLoop流程,文章的前半部分 会讲解浏览器在运行时,线程和进程是如何工作的? 文章的后半部分详细讲解宏任务、微任务以及EventLoop的机制,并且还会结合前半部分的知识,讲讲在EventLoop运行过程中,渲染进程的主线程具体做了哪些工作,会复习到很多前半部分的知识,让你融会贯通,理解地更深。
进程和线程的概念
什么是进程?
打开任务管理器,我们就能看到运行的部分进程:
进程是分配资源的最小单元,资源是是一个统称,是指我们电脑的内存,CPU的算力等,每个进程会分配不同大小的资源从而工作。
什么是线程?
线程组成了进程,它是执行的最小单元,每个进程就像是一个部门经理,手上拿着资源(内存、算力等),而线程就像员工,它是专门干活的(执行),线程可以共享进程的资源
多进程与多线程
1. 多进程: 一个应用可以有多个进程,在多线程中,主进程通常负责创建和管理子进程,协调它们的工作,并可能处理全局状态或资源分配。但是注意哦:非所有应用都是多进程的,很多简单的应用是单进程的。
2. 多线程: 每个进程可以包含多个线程,线程共享进程的资源。主线程通常是程序入口或核心调度线程,负责重要任务(如 UI 更新或任务分配),而其他线程并行处理子任务。
浏览器相关的进程与线程
浏览器是多进程的
浏览器是多进程的,比如我们每打开一个tab页面,就会产生一个进程。
为什么要这样设计呢?
因为如果设计成单线程管理每个tab页的话,万一某个tab页崩溃的,则会影响其它所有的tab页都崩溃,甚至影响整个浏览器崩溃,这会带来诸多安全问题。
你可能会说浏览器多线程会浪费资源,但相比于资源的浪费,我们更加注重安全性,所以我们使用浏览器的时候,也不要同时打开太多tab页,会带来性能的下降。
浏览器有哪些进程
我们列举几个重要的进程:
浏览器主进程:浏览器主进程管理着浏览器中其它线程的相互协调,确保浏览器能够正常运行
渲染进程:每个tab标签页对应着一个渲染进程,负责渲染流程、JS执行等任务,这个进程我们等下会细讲,十分重要。
GPU进程:负责一些图形的加速
网络进程:负责所有网络请求(如DNS解析、HTTP请求等)
插件进程 :每个插件(如PDF阅读器)独立一个进程
最重要进程--渲染进程
在浏览器的这些进程中,最重要的进程可以说是渲染进程,这也是面试官最喜欢拷问我们的,所以下面对渲染进程进行详细讲解。
渲染进程是多线程的,它虽然名字是叫渲染进程,但它负责的可不仅仅只是渲染过程,它同时负责了页面的渲染,JS的执行,事件的循环等,这些任务都有对应的线程,下面我们对这些线程进行介绍:
GUI渲染线程
GUI渲染线程就是用来渲染的,它负责了渲染的整个过程:
- 构建DOM树
- 构建CSSOM树
- 结合DOM树和CSSOM树,生成Render树
- 生成布局树
- 根据Render树和布局树,绘制页面
过程大概如下
由于今天讲解的重点是等下的EventLoop机制,所以这里就简要介绍了这个GUI渲染线程做的事,作者之前有一篇文章:《一文带你理清浏览器渲染过程》对这个渲染过程进行了详解,感兴趣的朋友们可以去看看
JS引擎线程
JS引擎线程和GUI渲染线程是互斥的
比如在构建DOM树时,当我们遇到了一个<script>,则会将控制权由此时的渲染线程GUI交给JS引擎线程,渲染线程GUI此时会进入加入队列等待,只有等到JS引擎线程执行完毕之后,才会继续刚刚的渲染线程GUI
可能有人会好奇为什么要这样做,让JS线程和渲染线程GUI同步进行不好吗,其实这样会涉及一个问题:
可能JS线程此时删除了某个节点, 但是渲染线程GUI还在将这个节点添加到树,以及为这个节点构建CSSOM中的样式节点,这就很没有意义了。
所以我们就需要让JS线程暂时阻塞GUI线程的执行,即阻塞DOM树和CSSOM的构建。
事件触发线程和定时器线程
事件触发线程和定时器线程也十分重要,不过这里作者先不介绍这两个线程有什么作用,待会介绍EventLoop了之后,你才能回过头来看得懂这两个线程有什么作用。
宏任务、微任务、EventLoop
了解了上面的基础之后,现在可以正式开始我们的文章后半部分:宏任务、微任务以及EventLoop机制的详解了,接下来才是硬菜!
从一个案例讲起:
请想象下面的场景:炎炎夏日你正在房间里打着LOL呢,打完一波团战后,此时你突然想喝一杯奶茶了,于是你准备点外卖:你首先拿起手机下单外卖,下单了之后,然后需要等待半个小时,外卖才能送到。
在这个过程中,你正在打LOL就是你正在执行同步任务,打完一波团战后(同步任务执行完了),你突然想喝一杯奶茶了,立即点外卖,就是执行微任务,而外卖小哥半小时后到,这时你必须放下游戏去开门,就是执行宏任务了。
什么是同步任务?
一般是指普通的 JavaScript 代码(如变量声明、循环、函数调用等)。
console.log("开始打团战"); // 同步任务
for (let i = 0; i < 3; i++) {
console.log("击杀敌方英雄"); // 同步任务
}
它的特点是必须等当前任务完成才能处理下一个任务。
什么是微任务?
微任务(Microtasks)是在当前同步任务结束后立即执行的任务
常见的微任务如下:
Promise.then、Promise.catch、Promise.finally。queueMicrotask()、MutationObserver。 例如:
console.log("团战结束"); // 同步任务
Promise.resolve().then(() => {
console.log("立刻下单奶茶"); // 微任务
});
什么是宏任务?
宏任务是异步的,需要等待同步任务和微任务队列清空后才会执行。
常见的宏任务如下:
setTimeout、setInterval。- DOM 事件(如
click、scroll)。 fetch、XMLHttpRequest的回调
在上面的例子中: 下单后外卖小哥半小时后到(宏任务注册)。你必须暂停游戏去开门(执行宏任务回调)。
EventLoop
EventLoop是指事件循环,它是JavaScript的一个核心机制,负责协调 同步任务、微任务、宏任务的执行顺序,确保代码高效运行。
我们先用上面的点外卖案例来初步分析一下EventLoop,找找感觉:
// 同步任务:打LOL
console.log("开始团战");
// 微任务:团战后立刻下单
Promise.resolve().then(() => {
console.log("下单奶茶");
});
// 宏任务:等外卖
setTimeout(() => {
console.log("外卖到了,去开门");
}, 0);
console.log("团战胜利"); // 同步任务
Event Loop 的运行分为以下步骤:
一、执行同步任务(打LOL)
按顺序执行所有同步代码:先执行输出“开始团战”,再执行Promise.resolve()和setTimeout():将微任务和宏任务加入队列,最后输出团战胜利
请注意:
-
这里的Promise.resolve()和setTimeout()本身的调用都是同步任务,只有它们里面回调函数才是是微任务和宏任务。
-
将宏任务和微任务加入队列时会加入不同的队列,宏任务有宏任务对应的队列,微任务有微任务对应的队列
二、清空微任务队列(立刻下单奶茶)
当同步任务执行完后,查看微任务队列是否有微任务,如果有,则立即执行所有微任务(如 Promise.then)。
三、执行宏任务(外卖送到)
当前同步任务和微任务都执行完了之后,接着从宏任务队列中取出一个任务执行。
所以这里的输出如下:
开始团战 -> 团战胜利 -> 下单奶茶 -> 外卖到了,去开门
找到大概的感觉之后,接下来给出一个更加专业完整的EventLoop过程:
首先,当script里的代码开始执行时,就意味着创建并开始了一个宏任务
在这个宏任务中:
-
执行同步任务:逐行执行 JavaScript 同步代码(如
console.log、变量声明等)有时你会看到这个同步代码部分可能还伴随着一些setTimeout(...)这样的操作,注意区分:setTimeout()是声明一个宏任务, 它本身的调用是同步的,但是里面的回调函数是异步的。 -
执行微任务:执行完同步任务后,会查看微任务队列里面是否有微任务,如果有的话,则立即执行所有的微任务
-
渲染:在执行完微任务之后,会完成渲染过程,即上面提到的GUI渲染线程执行完了最后一步,绘制。
-
执行下一个宏任务:当渲染完后,查看宏任务队列里面是否有宏任务,如果有的话,执行下一个宏任务,(再进入下一个宏任务的
1.执行同步任务,2.执行微任务......的这个过程),如此重复... 这便是EventLoop的核心机制
注意几点:
- 微任务队列必须在 渲染之前 完全清空。
- 并非每次 EventLoop 都渲染需满足:有 DOM 修改或视觉变更。
- 宏任务和微任务的调用都是同步的,只有其中的回调函数才是异步的
结合线程的EventLoop
通过前面的学习,我们已经了解了浏览器的多进程架构和EventLoop机制。现在让我们结合这些知识,深入分析在EventLoop运行过程中,渲染进程的主线程具体做了哪些工作。
1. 脚本执行的初始化阶段
当遇到<script>标签时,浏览器会:创建一个初始的宏任务来执行这段脚本,主线程开始工作
2. 同步代码执行阶段
主线程执行同步代码时:
遇到微任务(如Promise.then)时,会将回调函数添加到微任务队列(microtask queue)
3. 异步宏任务处理(以setTimeout为例)
在同步代码时,如果遇到宏任务:比如发现setTimeout()时,涉及的操作可能比你想象中的复杂
// 宏任务
setTimeout(() => {
console.log("外卖到了,去开门");
}, 1000);
-
定时器触发: 首先,此时会触发定时器线程开始工作,定时器线程是专门为了
setTimeout()和setInterval()而工作的线程,此时这个线程会触发定时,比如这里会设定等待一秒 -
当一秒过后,定时器线程会将
setTimeout()或者setInterval()里面的回调函数console.log("外卖到了,去开门");交给事件触发线程,事件触发线程将回调添加到宏任务队列 -
然后等待JS引擎的当前宏任务执行完毕之后,再从宏任务队列中取,就能执行
setTimeout里面的console.log("外卖到了,去开门");了 -
当然了,如果宏任务队列之前就已经有等待的事件A,那么肯定是先执行事件A的咯!然后按照EventLoop的循环流程,后执行此时的事件
console.log("外卖到了,去开门");
小结:
了解了这个过程之后,你会发现“将宏任务添加到宏任务队列”这个过程并不是那么地直接,而是需要经过专属的定时器线程和事件触发线程这样复杂的过程,事实上,不仅仅有定时器线程针对setTimeout()和setInterval(),还有别的专属的线程针对某些宏任务和微任务,甚至对于fetch/xhr这样的宏任务,还会有专属的下载线程伺候着它们,但是也有像addEventListener这样的宏任务是没有独立的线程的。
注意几点:
微任务:对于微任务来说,它的注册和触发不需要事件触发线程来协助,主线程就可以直接管理微任务队列 对于当前的宏任务来说,当同步代码执行完了之后,它会立即执行微任务队列中的所有微任务,这一特点和宏任务很不相同
宏任务:事件触发线程管理着宏任务队列,这个队列里会有通过定时器线程添加过来的不同宏任务回调函数,比如宏任务1、宏任务2、宏任务3,当事件触发线程发现执行栈为空了,才会从宏任务队列中执行下一个宏任务。
setTimeout不准确的原因:所以这就是setTimeout定时器有时候触发事件不准的原因:已经过了一秒之后,按理来讲,事件触发线程要将里面的回调函数从宏任务队列中拿出来执行了,但是这个时候执行栈的同步任务执行慢的要死(比如for循环一万次console.log),结果就只能等这个同步任务做完了之后,再执行setTimeout被添加到宏任务队列里面的回调函数,这个时候已经过了1.5秒了(正常等待一秒 + 等待执行栈为空0.5秒)
下面这张图很好地诠释了上文所述:
总结
本文从浏览器的多进程/多线程架构切入,详细讲解了渲染进程的核心线程(GUI渲染线程、JS引擎线程、事件触发线程等)如何协同工作,并深入剖析了EventLoop的执行机制,包括同步任务、微任务、宏任务的执行顺序及其与线程的关系。通过实际案例和流程拆解,揭示了setTimeout不准确的原因及任务队列的管理方式,现在相信你就能全面理解浏览器运行时的工作流程,将进程线程知识与事件循环机制融会贯通。
如果对文中的内容有异议,欢迎评论指正 创作不易,如果感觉对你有帮助的话,可以点个赞呀!😀