深入解析浏览器多进程架构与EventLoop机制

144 阅读13分钟

前言

大家好,今天给大家带来两个JS中的难点讲解--浏览器的多进程/多线程,以及EventLoop流程,文章的前半部分 会讲解浏览器在运行时,线程和进程是如何工作的? 文章的后半部分详细讲解宏任务、微任务以及EventLoop的机制,并且还会结合前半部分的知识,讲讲在EventLoop运行过程中,渲染进程的主线程具体做了哪些工作,会复习到很多前半部分的知识,让你融会贯通,理解地更深。


进程和线程的概念

什么是进程?

打开任务管理器,我们就能看到运行的部分进程:

c7357a31620328fe9961ac83ef25f71c.png

进程是分配资源的最小单元,资源是是一个统称,是指我们电脑的内存,CPU的算力等,每个进程会分配不同大小的资源从而工作。

什么是线程?

线程组成了进程,它是执行的最小单元,每个进程就像是一个部门经理,手上拿着资源(内存、算力等),而线程就像员工,它是专门干活的(执行),线程可以共享进程的资源


多进程与多线程

1. 多进程: 一个应用可以有多个进程,在多线程中,主进程通常负责创建和管理子进程,协调它们的工作,并可能处理全局状态或资源分配。但是注意哦:非所有应用都是多进程的,很多简单的应用是单进程的。

2. 多线程: 每个进程可以包含多个线程,线程共享进程的资源。主线程通常是程序入口或核心调度线程,负责重要任务(如 UI 更新或任务分配),而其他线程并行处理子任务。


浏览器相关的进程与线程

浏览器是多进程的

浏览器是多进程的,比如我们每打开一个tab页面,就会产生一个进程。

为什么要这样设计呢?

因为如果设计成单线程管理每个tab页的话,万一某个tab页崩溃的,则会影响其它所有的tab页都崩溃,甚至影响整个浏览器崩溃,这会带来诸多安全问题。

你可能会说浏览器多线程会浪费资源,但相比于资源的浪费,我们更加注重安全性,所以我们使用浏览器的时候,也不要同时打开太多tab页,会带来性能的下降。


浏览器有哪些进程

我们列举几个重要的进程:

浏览器主进程:浏览器主进程管理着浏览器中其它线程的相互协调,确保浏览器能够正常运行

渲染进程:每个tab标签页对应着一个渲染进程,负责渲染流程、JS执行等任务,这个进程我们等下会细讲,十分重要。

GPU进程:负责一些图形的加速

网络进程:负责所有网络请求(如DNS解析、HTTP请求等)

插件进程 :每个插件(如PDF阅读器)独立一个进程


最重要进程--渲染进程

在浏览器的这些进程中,最重要的进程可以说是渲染进程,这也是面试官最喜欢拷问我们的,所以下面对渲染进程进行详细讲解。

渲染进程是多线程的,它虽然名字是叫渲染进程,但它负责的可不仅仅只是渲染过程,它同时负责了页面的渲染,JS的执行,事件的循环等,这些任务都有对应的线程,下面我们对这些线程进行介绍:

GUI渲染线程

GUI渲染线程就是用来渲染的,它负责了渲染的整个过程:

  • 构建DOM树
  • 构建CSSOM树
  • 结合DOM树和CSSOM树,生成Render树
  • 生成布局树
  • 根据Render树和布局树,绘制页面

过程大概如下 image.png 由于今天讲解的重点是等下的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.thenPromise.catchPromise.finally
  • queueMicrotask()MutationObserver。 例如:
console.log("团战结束"); // 同步任务
Promise.resolve().then(() => {
  console.log("立刻下单奶茶"); // 微任务
});

什么是宏任务?

宏任务是异步的,需要等待同步任务和微任务队列清空后才会执行。

常见的宏任务如下:

  • setTimeoutsetInterval
  • DOM 事件(如 clickscroll)。
  • fetchXMLHttpRequest 的回调

在上面的例子中: 下单后外卖小哥半小时后到(宏任务注册)。你必须暂停游戏去开门(执行宏任务回调)。


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过程:

0c096ade76bd5e085540349be903141c.png

首先,当script里的代码开始执行时,就意味着创建并开始了一个宏任务

在这个宏任务中:

  1. 执行同步任务:逐行执行 JavaScript 同步代码(如 console.log、变量声明等)有时你会看到这个同步代码部分可能还伴随着一些setTimeout(...)这样的操作,注意区分:setTimeout()是声明一个宏任务, 它本身的调用是同步的,但是里面的回调函数是异步的。

  2. 执行微任务:执行完同步任务后,会查看微任务队列里面是否有微任务,如果有的话,则立即执行所有的微任务

  3. 渲染:在执行完微任务之后,会完成渲染过程,即上面提到的GUI渲染线程执行完了最后一步,绘制。

  4. 执行下一个宏任务:当渲染完后,查看宏任务队列里面是否有宏任务,如果有的话,执行下一个宏任务,(再进入下一个宏任务的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秒)

下面这张图很好地诠释了上文所述:

7bcc4580c7d8ce2dcadf5c45b575ec31.png


总结

本文从浏览器的多进程/多线程架构切入,详细讲解了渲染进程的核心线程(GUI渲染线程、JS引擎线程、事件触发线程等)如何协同工作,并深入剖析了EventLoop的执行机制,包括同步任务、微任务、宏任务的执行顺序及其与线程的关系。通过实际案例和流程拆解,揭示了setTimeout不准确的原因及任务队列的管理方式,现在相信你就能全面理解浏览器运行时的工作流程,将进程线程知识与事件循环机制融会贯通。

如果对文中的内容有异议,欢迎评论指正 创作不易,如果感觉对你有帮助的话,可以点个赞呀!😀

求赞.gif