学习浏览器 - 多进程架构

162 阅读16分钟

浏览器打开一个页面, 会启动多少进程?

以Chrome浏览器为例,通过工具栏 -> More Tools -> Task Manager,将显示 Chrome 任务管理器的窗口。

Screenshot 2024-03-10 at 22.38.42.png

由图可见,任务管理器中有包括 Browser主进程GPU进程Network/Storage/Audio/Video/V8公共服务进程,多个渲染器进程备用渲染器进程,还有扩展进程,这是在未打开任何页面的情况。

如果打开了多个页面,那么还会创建多个Tab进程,如果打开的页面有使用诸如插件、Service Worker、SubFrame,还会创建插件进程Service Worker进程SubFrame进程(如果iframe和所嵌入的页面不在同一域名下)。

由此可见,仅仅打开一个页面, 便启动了N多个进程。为什么是这样的情况? 可以从浏览器进程架构的演变历史中找到答案。

单进程浏览器时代

在此之前, 先简单回顾进程与线程。

进程是计算机中具有一定独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源, 是系统进行资源分配和调度的基本单位。

从狭义上,可以将进程理解为正在运行的程序的实例,当一个程序进入内存运行时, 系统就会创建一个进程,并为它分配资源,然后把该进程放进进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序真正运行。

线程是程序执行中的一个单一顺序执行流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。

多任务既可以由多进程实现,也可以由单进程内的多线程实现,也可以混合多进程+多线程。和多进程相比, 多线程的优势在于:

  • 线程的调度与切换比进程很多,同时创建一个线程的开销也比进程要小很多;
  • 线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,线程间通信就是读写同一个变量,速度很快。而进程之间的通信需要以IPC进行。

多进程的优点在于:

  • 多进程程序更健壮,在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。

基于以上,单进程浏览器就是指浏览器的所有功能模块都是运行在同一个进程里。这些模块包含了网络、插件、JavaScript运行环境、渲染引擎和页面等。早在2007年之前, 市面上浏览器基本都是单进程架构的。架构简图如下:

Screenshot 2024-03-11 at 21.46.47.png

如此多的功能模块运行在一个进程中, 是单进程导致浏览器不稳定不流畅不安全的一个主要因素。

  • 问题1: 不稳定

早期浏览器需要借助插件来实现诸如Web视频、Web游戏等各种强大的功能,但是插件是最容易出问题的模块,且运行在浏览器进程之中,一个插件的意外崩溃会导致整个浏览器的崩溃。

除插件之外,渲染引擎模块也是不稳定的,通常一些复杂的JavaScript代码就有可能引起渲染引擎的崩溃,进而也会导致整个浏览器的崩溃。

  • 问题2: 不流畅

所有页面的渲染模块、JavaScript执行环境以及插件都是运行在同一个线程中,这就意味着同一时刻只能有一个模块可以执行。如果将一个无限循环的脚本运行在一个单进程浏览器的页面里,当其执行时, 会独占整个线程,这样导致其他运行在该线程的模块就没有机会被执行,因为浏览器中所有的页面都运行在该线程中,所以这些页面都没有机会执行任务,导致整个浏览器失去响应,变卡顿。

除了脚本或者插件会让浏览器变卡顿外, 页面的内存泄漏也是单进程变慢的一个重要原因。通常浏览器的内核都是非常复杂的,运行一个复杂的页面再关闭页面,会存在内存不能完全回收的情况,这样导致的问题就是随着使用时间的变长,内存占用就越高,浏览器会变得越慢。

  • 问题3: 不安全

插件可以使用C/C++等代码编写,通过插件可以获取到操作系统的任意资源,当你在页面运行一个插件时也就意味着这个插件有可能完全操作你的电脑。如果是个恶意插件,那么它就可以释放病毒,窃取账号密码,引发安全性问题。

至于页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取到系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题。

所以早期的浏览器很容易在使用过程中,由于某个页面的问题,而导致整个浏览器的问题。

多进程浏览器时代

早期多进程架构

2008年 Chrome 发布时 的进程架构。

Screenshot 2024-03-11 at 22.10.03.png

由图, Chrome 的页面是运行在单独的渲染进程中的, 同时页面里的插件也是运行在单独的插件进程之中, 进程之间通过IPC通信。

如何解决不稳定的问题?

由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前页面进程或者插件进程,并不会影响到整个浏览器和其他页面,这就解决了页面或者插件的崩溃导致整个浏览器崩溃的问题。

如何解决不流畅问题?

同样,JavaScript也是运行在渲染进程中的,所以即使JavaScript阻塞了渲染进程,影响的也只是当前页面,并不会影响浏览器和其他页面,因为其他页面的脚本是运行它们在自己的渲染进程中的,所以当在此浏览器架构中的页面中运行一段死循环的脚本时,没有响应的仅仅是当前页面。

对于内存泄漏,因为关闭一个页面时, 整个渲染进程也就被关闭,之后该进程所占用的内存都会被系统回收,所以自然也就解决了内存泄漏的问题。

如何解决不安全问题?

采用多进程架构的额外好处是可以使用安全沙箱, 你可以把沙箱看成是操作系统给进程上了一把锁,沙箱里面的程序可以运行,但是不能在你的硬盘上写入任何数据, 也不能在敏感位置读取任何数据,Chrome把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,也无法突破沙箱去获取系统权限。

为什么多进程架构就能用安全沙箱?如果一个进程使用了安全沙箱之后,该进程对操作系统的权限就会受到限制,比如不能对一些位置的文件进行读写操作,而这些权限浏览器主进程是需要的,所以安全沙箱是不能应用到浏览器主进程之上的。(也就是说浏览器至少需要一个进程,且它是拥有最高权限的,如果要使用安全沙箱,则至少需要两个进程,一个用作主进程,一个使用安全沙箱)。简而言之,安全沙箱的最小隔离单位就是进程, 如果在当进程浏览器使用安全沙箱会影响浏览器对系统的操作。

现代多进程架构

而后, Chrome 浏览器架构又发生了变化。

Screenshot 2024-03-11 at 22.23.29.png

至此, 浏览器包括: 1个Browser主进程、1个GPU进程、1个NetWork进程、多个渲染进程和多个插件进程。

  • Browser进程:浏览器的主进程(负责主控,协调), 主要负责浏览器界面显示、用户交互(go forward/go back)、子进程管理(各个页面的管理,创建和销毁其他进程)、将Renderer进程得到的内存中的Bitmap绘制到用户界面上、同时提供存储等功能。
  • GPU进程:GPU最早的使用初衷是为了实现3D CSS效果,随后网页、Chrome的UI界面都选择使用GPU来绘制,这使得GPU成为浏览器普遍的需要,也就在多进程架构中引入了GPU进程。
  • NetWork进程:主要负责页面的网络资源加载(在此之前,作为一个模块运行在Browser进程里)
  • Renderer进程:浏览器内核,核心任务是页面渲染、脚本执行、事件处理。(将HTML、CSS和JavaScript转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个Tab创建一个渲染进程,且处于安全考虑,渲染进程都是运行在沙箱模式下)。
  • 插件进程:主要负责插件的运行,因插件易崩溃,所以需要通过插件进程隔离,以避免对浏览器和页面的影响。

多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样也带来一些问题:

  • 更高的资源占用: 因为每个进程都会包含公共基础结构的副本(如JavaScript运行环境),导致浏览器会消耗更多的内存资源。
  • 更复杂的体系架构:各模块复杂,耦合性高、扩展差等问题

为了解决上述问题,2016年 Chrome团队使用“面向服务的架构”(Services Oriented Architecture)的思想设计新的架构,使浏览器整体架构朝着现代操作系统所采用的“面向服务的架构”方向发展,各个模块重构成独立的服务,每个服务都可以在独立的进程中运行,访问服务必须使用定义好的接口,通过IPC通信,从而构建一个更内聚,松耦合,易于维护和扩展的系统。

Screenshot 2024-03-11 at 22.40.38.png

将UI、数据、文件、设备、网络等重构为基础服务,类似操作系统底层服务。同时,Chrome 还提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,但是如果在资源受限的设备上,Chrome 会将很多服务整合到一个进程中,从而节省内存占用。

Screenshot 2024-03-11 at 22.43.49.png

延伸:

  1. 多进程架构中,一定不会出现一个页面的崩溃而影响其他页面吗?

不是的, 通常一个页面使用一个进程,但是,有特例。 Chrome 的默认策略是,每个标签对应一个渲染进程,但是如果从一个页面打开了新页面,而新页面和当前页面属于同一站点时,新页面会复用父页面的渲染进程。官方把这个策略叫 process-per-site-instance.

如果几个页面符合同一站点,且作为新页面由当前页面打开,此时,一个页面崩溃了,也会导致这些同一站点的页面同时崩溃,因为它们使用同一个渲染进程。之所以此种场景共用,是因为在一个渲染进程里面,它们会共享JS执行环境,也就是说A页面可以直接在B页面中执行脚本。

如果是在新标签页打开的一个页面,即使是和已经存在的页面是同一站点,也不会公共渲染进程,而是创建一个新的渲染进程。
如果代码设置了一些安全属性,即便同一站点,也会使用不同的进程(如:<a target="_blank" rel="noopener noreferrer" class="hover" href="xxx.com" title </a> ,使用noopener noreferrer就是告诉浏览器,新打开的子窗口不需要访问父窗口的任何内容,这是为了防止一些钓鱼网站窃取父窗口的信息。 浏览器在打开新页面时,解析到含有noopener noreferrer时,就知道他们不需要共享页面内容,所以这时候浏览器就会让新链接在一个新页面中打开了)。

同一站点即为根域名相同,协议相同的站点,如:
time.geekbang.org
www.geekbang.org
www.geekbang.org:8080

2.单进程浏览器架构中,打开多个页面,渲染线程也是只有一个吗?

IE6时代, 浏览器都是单进程的, 所有页面也都是运行在一个主线程中的,而且此时的IE6是单标签的,也就是说一个页面一个窗口。

彼时,国内很多国产浏览器都是基于IE6二次开发的, 而IE6原生架构就是所有页面跑在单线程里面的,意味着,所有的页面都共享着同一套 JavaScript运行环境,同样,对于存储 Cookie 也都是在一个线程里操作的。 而且,这些国产浏览器由于需要,采用多标签的形式,所以其中一个标签页面的卡顿都会影响整个浏览器。

基于卡顿的原因,国内浏览器就开始尝试支持页面多线程,也就是让部分页面运行在单独的线程之中,意味着每个线程拥有单独的 JavaScript执行环境,和 Cookie环境,这时候问题就来了:

比如A站点页面登录一个网站,保存了一些Cookie数据到磁盘上,再在当前线程环境中保存部分Session数据,由于Session是不需要保存到磁盘上的,所以Session只会保存在当前的线程环境中。这时候再打开另外一个A站点的页面,假设这个页面是在另一个线程中,那么它首先读取磁盘上的Cookie信息,但是,却无法直接读取Session信息(存在另一个线程环境中),这样就需要解决一个Session同步的问题,由于IE并没有源代码, 所以实现起来非常难。

Session问题解决了,但是假死的问题依然存在,因为进程内使用了一个窗口,这个窗口是依附到浏览器主窗口之上的,所以他们公用一套消息循环机制,这也就意味着这一个窗口如果卡死了,也会导致整个浏览器的卡死。

国内浏览器又出了一招,就是把页面做成一个单独的弹窗,如果这个页面卡死了,就把这个弹窗给隐藏掉。

Chrome是怎么做的呢? 实际上 Chrome 输出的是经过 GPU 计算生成的text buffer(位图),然后浏览器端把位图贴到自己的窗口上,在 Chrome 的渲染进程中,并没有一个渲染窗口,输出的只是"图片"(如果有譬如点击鼠标选中文字之类的操作,实则是这些消息会传递到渲染进程,渲染进程再合成选中文字的状态,然后更新位图)如果卡住了,顶多图片就不更新了。

3.Renderer进程内的多线程模型

页面的渲染,JS的执行,事件的循环,都是在渲染进程内进行。JS引擎是单线程的,渲染进程是多线程的。其包括:

  • GUI渲染线程:
    • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局绘制等;
    • 当界面需要重绘(Repaint)或由于某种操作引发回流(Reflow)时,该线程就会执行;
    • 需要注意的是,GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会保存在一个队列中等到JS引擎空闲时立即被执行。
  • JS引擎线程(也称为JS内核):
    • 负责处理Javascript脚本程序(例如V8);
    • JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页面(Renderer进程)中只有一个JS线程在运行JS程序;
    • 同样注意:GUI渲染线程与JS引擎线程是互斥的, 如果JS执行时间过长,就会造成页面渲染的不连贯,导致页面渲染加载阻塞;
  • 事件触发线程:
    • 归属于浏览器而不是JS引擎, 用来控制事件循环
    • 当JS引擎执行代码块(如setTimeout时,也可以来自浏览器内核的其他线程,如鼠标点击、AJAX请求等),会将对应任务添加到事件线程中;
    • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理;
    • 需要注意的是, 由于JS的单线程关系, 所以这些待处理队列中的事件都需要排队等待JS引擎处理;
  • 定时器触发线程:
    • setIntervalsetTimeout 所在线程;
    • 浏览器定时计数器并不是由Javascript引擎计数的(因为JS引擎是单线程的,如果处于阻塞线程状态会影响计时的准确性),因此需要单独的线程来计时并触发定时操作(计时完毕后并不是立即执行任务,而是将事件添加到队列中,等待调度)
    • 注意: W3C在HTML标准中规定, setTimeout中低于4ms的时间间隔均记为4ms;
  • 异步HTTP请求线程:
    • 在XMLHTTPRequest在连接后是通过Newwork进程新开一个线程请求;
    • 在检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中,继而由JS引擎执行。