进程(process)和线程(thread)是操作系统的基本概念,但是它们比较抽象,不容易掌握。
为了不把概念性的东西说的太枯燥,这里我引用了阮一峰老师进程与线程的文章,用一个形象的例子来说明。
首先,计算机的核心是CPU,它承担了所有的计算任务。它就像一座工厂,时刻运行着。
假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是,单个CPU一次只能运行一个任务。
进程就好比工厂的车间,它代表CPU所能处理的单个任务。任一时刻,CPU总是运行一个进程,其他进程处于非运行状态。
一个车间里,可以有很多工人。他们协同完成一个任务。线程就好比车间里的工人。一个进程可以包括多个线程。
车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存(共享内存中间的各种协调机制这里不详细说明)。
好了,到这里我们大概说清了进程与线程的关系,这时我们再用专业点的语言来解释一下:
一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。
线程是处理具体任务的,多线程可以并行处理任务。但是线程是不能单独存在的,它是由进程来启动和管理的。
从图中可以看到,线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率。
总结来说,进程和线程之间的关系有以下 4 个特点。
1.进程中的任意一线程执行出错,都会导致整个进程的崩溃。
2.线程之间共享进程中的数据。
3.当一个进程关闭之后,操作系统会回收进程所占用的内存。
当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。
4.进程之间的内容相互隔离。
进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。
顾名思义,单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等。 其实早在 2007 年之前,市面上浏览器都是单进程的。
我们可以结合上面进程与线程的特点,来分析一下单线程的问题。
首先是进程中的任意一线程执行出错,都会导致整个进程的崩溃。这导致了单线程浏览器不稳定的问题。
早期浏览器需要借助于插件来实现诸如 Web 视频、Web 游戏等各种强大的功能,但是插件是最容易出问题的模块,并且还运行在浏览器进程之中,所以一个插件的意外崩溃会引起整个浏览器的崩溃。
除了插件之外,渲染引擎模块也是不稳定的,通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃。和插件一样,渲染引擎的崩溃也会导致整个浏览器的崩溃。
第二点,当一个进程关闭之后,操作系统会回收进程所占用的内存。 那么就是说单进程浏览器在关闭之前,操作系统都不会完全回收进程所占用的内存,只能依靠浏览器的内存回收机制。
但是,通常浏览器的内核都是非常复杂的,运行一个复杂点的页面再关闭页面,会存在内存不能完全回收的情况,这样导致的问题是使用时间越长,内存占用越高,浏览器会变得越慢。
还有就是所有页面的渲染模块、JavaScript 执行环境以及插件都是运行在同一个线程中的,这就意味着同一时刻只能有一个模块可以执行。那么当一个循环的JS脚本运行时,会独占一整个线程,这样就导致其他运行在该线程中的任务没有机会被执行,又因为页面渲染都放在了一个线程里面运行,所以页面会失去响应,变卡顿。这也就是为什么早年的浏览器一个页面卡主,整个浏览器都会卡主的原因了。
综上所述,导致了单线程浏览器的第二个问题:不流畅。
最后一点,线程之间共享进程中的数据,进程之间的内容相互隔离。也就是说单线程浏览器中的页面是可以通过一些手段获取到浏览器的所有权限,然后再通过浏览器攻击到操作系统的。这就导致了单线程浏览器不安全的问题。
因为浏览器插件可以使用 C/C++ 等代码编写,通过插件可以获取到操作系统的任意资源,当你在页面运行一个插件时也就意味着这个插件能完全操作你的电脑。如果是个恶意插件,那么它就可以释放病毒、窃取你的账号密码,引发安全性问题。
至于页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题。总而言之,就是浏览器对于脚本与插件的运行没有一个隔离环境的存在,导致了各种安全问题的出现。
总结一下,单线程浏览器有以下三个主要问题:
好在现代浏览器已经解决了这些问题,是如何解决的呢?这就得聊聊我们这个“多进程浏览器时代”了。
先看看下面这张图,这是 2008 年 Chrome 发布时的进程架构。
从图中可以看出,Chrome 的页面是运行在单独的渲染进程中的,同时页面里的插件也是运行在单独的插件进程之中,而进程之间是通过 IPC 机制进行通信(如图中虚线部分)。
我们先看看如何解决不稳定的问题。 由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃,也就是不稳定的问题。
接下来再来看看不流畅的问题是如何解决的。 同样,JavaScript 也是运行在渲染进程中的,所以即使 JavaScript 阻塞了渲染进程,影响到的也只是当前的渲染页面,而并不会影响浏览器和其他页面,因为其他页面的脚本是运行在它们自己的渲染进程中的。所以当我们再在 Chrome 中运行上面那个死循环的脚本时,没有响应的仅仅是当前的页面。
对于内存泄漏的解决方法那就更简单了,因为当关闭一个页面时,整个渲染进程也会被关闭,之后该进程所占用的内存都会被系统回收,这样就轻松解决了浏览器页面的内存泄漏问题。
最后我们再来看看上面的两个安全问题是怎么解决的。 采用多进程架构的额外好处是可以使用安全沙箱,你可以把沙箱看成是操作系统给进程上了一把锁,沙箱里面的程序可以运行,但是不能在你的硬盘上写入任何数据,也不能在敏感位置读取任何数据,例如你的文档和桌面。Chrome 把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。
不过 Chrome 的发展是滚滚向前的,相较之前,目前的架构又有了很多新的变化。我们先看看最新的 Chrome 进程架构,可以参考下图:
从图中可以看出,最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。
下面我们来逐个分析下这几个进程的功能。
讲到这里,你应该就可以知道打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。
对于上面这两个问题,Chrome 团队一直在寻求一种弹性方案,既可以解决资源占用高的问题,也可以解决复杂的体系架构的问题。
为了解决这些问题,在 2016 年,Chrome 官方团队使用“面向服务的架构”(Services Oriented Architecture,简称 SOA)的思想设计了新的 Chrome 架构。也就是说 Chrome 整体架构会朝向现代操作系统所采用的“面向服务的架构” 方向发展,原来的各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。
Chrome 最终要把 UI、数据库、文件、设备、网络等模块重构为基础服务,类似操作系统底层服务,下面是 Chrome“面向服务的架构”的进程模型图:
同时 Chrome 还提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,但是如果在资源受限的设备上(如下图),Chrome 会将很多服务整合到一个进程中,从而节省内存占用。
现在我们已经大致了解浏览器进程的发展过程,接下来结合我之前的《深入浏览器之页面加载中的计算机网络》一文,来详细看看页面加载中的进程间的配合。
首先我们回顾一下页面加载的流程:
然后再看一下下面这张图:
到这里,我们再回头看一下目前多进程架构中总结的各进程主要功能,这里值得我们注意的是渲染进程所有的内容都是通过网络获取的,会存在一些恶意代码利用浏览器漏洞对系统进行攻击,所以运行在渲染进程里面的代码是不被信任的。这也是为什么 Chrome 会让渲染进程运行在安全沙箱里,就是为了保证系统的安全。
先大概说一下,上图流程:
CommitNavigation
消息到渲染进程,发送CommitNavigation
时会携带响应头、等基本信息。渲染进程接收到消息和网络进程建立传输数据的“管道”。那么下面我们再来详细解说一下上面的流程。
当用户输入关键字并键入回车之后,这意味着当前页面即将要被替换成新的页面,不过在这个流程继续之前,浏览器还给了当前页面一次执行 beforeunload 事件的机会。
beforeunload 事件允许页面在退出之前执行一些数据清理操作,还可以询问用户是否要离开当前页面,比如当前页面可能有未提交完成的表单等情况,因此用户可以通过 beforeunload 事件来取消导航,让浏览器不再执行任何后续工作。
当前页面没有监听 beforeunload 事件或者同意了继续后续流程,那么浏览器便进入下图的状态:
从图中可以看出,当浏览器刚开始加载一个地址之后,标签页上的图标便进入了加载状态。但此时图中页面显示的依然是之前打开的页面内容,并没立即替换页面。因为需要等待提交文档阶段,页面内容才会被替换。
接下来,便进入了页面资源请求过程。这时,浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。那具体流程是怎样的呢?
首先,网络进程会根据强制缓存规则查找本地是否缓存了当前URL是否存在强缓存。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么再查找是否存在协商缓存信息,如果有把协商缓存信息写入请求头中,否则直接进入网络请求流程。关于协商缓存与强制缓存的详细过程,可以查看这里。
接着请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。关于DNS解析也很好理解,由于 IP 地址具有不方便记忆并且不能显示地址组织的名称和性质等缺点,人们设计出了域名,并通过域名解析协议(DNS,Domain Name System)来将域名和 IP 地址相互映射,使人更方便地访问互联网,而不用去记住能够被机器直接读取的 IP 地址数串。所以域名解析就是找到URL背后对应的IP地址,让路由器知道到哪去找目标服务器。关于详细DNS解析过程,可以查看这里。
如果请求协议是 HTTPS,那么还需要建立 TLS 连接。TLS连接就是在HTTP和TCP中间的协议,核心作用就是加密HTTP报文给TCP传输或者解密HTTP报文传给上层应用使用。关于HTTPS详情,可以查看这里。
接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。
服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。
补充:
重定向
在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301 或者 302,那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。
比如我们在终端中输入如下指令:
curl -I http://bilibili.com
我们会获得一个如下响应报文:
HTTP/1.1 301 Moved Permanently
Server: Tengine
Date: Sat, 01 Jan 2022 12:30:21 GMT
Content-Type: text/html
Content-Length: 239
Connection: keep-alive
Location: https://www.bilibili.com/B站服务器会通过重定向的方式把所有 HTTP 请求转换为 HTTPS 请求。也就是说你使用 HTTP 向B站服务器请求时,服务器会返回一个包含有 301 或者 302 状态码响应头,并把响应头的
Location
字段中填上 HTTPS 的地址,这就是告诉了浏览器要重新导航到新的地址上。那我们再试试直接访问
Location
中的HTTPS的网站是什么样的响应报文:HTTP/2 200
date: Sat, 01 Jan 2022 12:33:38 GMT
content-type: text/html; charset=utf-8
support: nantianmen
set-cookie: innersign=0; path=/; domain=.bilibili.com
set-cookie: buvid3=4BC19AF-7335-E979-74C3-AA00D1411DC017954infc; path=/; expires=Sun, 01 Jan 2023 12:33:37 GMT; domain=.bilibili.com
cache-control: no-cache
gear: 1
vary: Origin,Accept-Encoding
idc: shjd
expires: Sat, 01 Jan 2022 12:33:37 GMT
x-cache-webcdn: MISS from blzone07
x-cache-time: 0
x-origin-time: no-cache, must-revalidate, max-age=0, no-store
x-save-date: Sat, 01 Jan 2022 12:33:38 GMT从图中可以看出,服务器返回的响应头的状态码是 200,这是告诉浏览器一切正常,可以继续往下处理该请求了。
好了,以上是重定向内容的介绍。现在你应该理解了,在导航过程中,如果服务器响应行的状态码包含了 301、302 一类的跳转信息,浏览器会跳转到新的地址继续导航;如果响应行是 200,那么表示浏览器可以继续处理该请求。
响应数据类型处理
URL 请求的数据类型,有时候是一个下载类型,有时候是正常的 HTML 页面,那么浏览器是如何区分它们呢?
答案是
Content-Type
。Content-Type
是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据Content-Type
的值来决定如何显示响应体的内容。这里我们可以回头看一下上面B站的响应体,响应头中的
Content-type
字段的值是text/html
,这就是告诉浏览器,服务器返回的数据是 HTML 格式。还有我们正常请求后端的接口,很多是application/json
类型的,这样的数据类型浏览器就会当成是普通数据由网络进程传递给渲染进程。再比如说application/octet-stream
类型的,显示数据是字节流类型的,通常情况下,浏览器会按照下载类型来处理该请求。所以,不同
Content-Type
的后续处理流程也截然不同。如果Content-Type
字段的值被浏览器判断为下载类型,那么该请求会被提交给浏览器的下载管理器,同时该 URL 请求的导航流程就此结束。但如果是 HTML,那么浏览器则会继续进行导航流程。由于 Chrome 的页面渲染是运行在渲染进程中的,所以接下来就需要准备渲染进程了。
默认情况下,Chrome 会为每个页面分配一个渲染进程,也就是说,每打开一个新页面就会配套创建一个新的渲染进程。但是,也有一些例外,在某些情况下,浏览器会让多个页面直接运行在同一个渲染进程中。
这里我找了一些网站,对比一下LeeCode和B站的任务管理器截图:
其中,LeeCode的三个网页都是同属于一个进程66661
的,而B站的三个网页分别都有各自的进程。
那什么情况下多个页面会同时运行在一个渲染进程中呢?
Chrome 的默认策略是,每个标签对应一个渲染进程。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫 process-per-site-instance
。
这个策略的依据具体来说就是以下两点:
- 如果两个标签页都位于同一个浏览上下文组,且属于同一站点,那么这两个标签页会被浏览器分配到同一个渲染进程中。
- 如果这两个条件不能同时满足,那么这两个标签页会分别使用不同的渲染进程来渲染。
首先了解下什么是同一站点。具体地讲,我们将“同一站点”定义为根域名加上协议,还包含了该根域名下的所有子域名和不同的端口,比如下面这三个:
https://space.bilibili.com
https://www.bilibili.com
https://www.bilibili.com:443
它们都是属于同一站点,因为它们的协议都是 HTTPS,而且根域名也都是 bilibili.com。
再看看浏览组上下文,要搞清楚这个,我们要先来分析下浏览器标签页之间的连接关系。
我们知道,浏览器标签页之间是可以通过 JavaScript 脚本来连接的,通常情况下有如下几种连接方式:
第一种是通过标签来和新标签建立连接:
百度
点击该链接会打开新的百度标签页,新标签页中的 window.opener
的值就是指向原来标签页中的 window
,这样就可以在新的百度标签页中通过 opener
来操作上个标签页了。这样我们可以说,这两个标签页是有连接的。
另外,还可以通过 JavaScript 中的 window.open 方法来和新标签页建立连接,演示代码如下所示:
new_window = window.open("https://baidu.com")
通过上面这种方式,可以在当前标签页中通过 new_window
来控制新标签页,还可以在新标签页中通过 window.opener
来控制当前标签页。所以我们也可以说,如果从 A 标签页中通过 window.open
的方式打开 B 标签页,那么 A 和 B 标签页也是有连接的。
其实通过上述两种方式打开的新标签页,不论这两个标签页是否属于同一站点,他们之间都能通过 opener
来建立连接,所以他们之间是有联系的。在 WhatWG 规范中,把这一类具有相互连接关系的标签页称为浏览上下文组 ( browsing context group
)。
既然提到浏览上下文组,接下来就是浏览上下文,通常情况下,我们把一个标签页所包含的内容,诸如 window 对象,历史记录,滚动条位置等信息称为浏览上下文。这些通过脚本相互连接起来的浏览上下文就是浏览上下文组。
也就是说,如果在A的标签页中,通过链接打开了多个新的标签页,不管这几个新的标签页是否是同一站点,他们都和A的标签页构成了浏览上下文组,因为这些标签页中的 opener
都指向了A标签页。
了解完前面的知识,我们再看一下LeeCode
的任务进程,三个页面确实是同一个进程,符合我们上面分析的结果。但是看下B站的三个标签页,却使用了三个不同的进程,这和我们预期的完全不同。
其实问题也很好解释,默认的 a 标签链接,如果设置 target=_blank
时,则在新窗口能够通过全局对象的 opener
属性拿到原 tab 的引用,此时可能会引发黑客攻击等危险。
具体来说就是:
window.opener
获取到 A 网页的 window
对象, 进而可以使得 A 页面跳转到一个钓鱼页面 window.opener.location.href ="abc.com"
, 用户没注意地址发生了跳转, 在该页面输入了用户名密码后则发生信息泄露解决这个问题的方式也很简单,就是在 a 标签上加上ref
属性noopener 和 noreferrer
。
引入 rel="noopener"
属性, 这样新打开的页面便获取不到来源页面的 window
对象了, 此时 window.opener
的值是 null
。
与 noopener
类似, 设置了 rel="noreferrer"
后新开页面也无法获取来源页面的 window
以进行攻击, 同时, 新开页面中还无法获取 document.referrer
信息, 该信息包含了来源页面的地址。
通常 noopener
和 noreferrer
会同时设置, rel="noopener noreferrer"
。这是考虑到兼容性, 因为一些老旧浏览器不支持 noopener
。
其实我们都能想到还有一个最特殊的情况,就是iframe标签。简单总结就是,如果标签页中的 iframe 和标签页是同一站点,并且有连接关系,那么标签页依然会和当前标签页运行在同一个渲染进程中,如果 iframe 和标签页不属于同一站点,那么 iframe 会运行在单独的渲染进程中。
用一张图来分析就是:
渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段。
所谓提交文档,就是指浏览器进程将网络进程接收到的 HTML 数据提交给渲染进程,具体流程是这样的:
所以这也就解释了为什么在浏览器的地址栏里面输入了一个地址后,之前的页面没有立马消失,而是要加载一会儿才会更新页面。
一旦文档被提交,渲染进程便开始页面解析和子资源加载了。 为了能更好地理解下文,你可以先结合下图快速抓住 HTML、CSS 和 JavaScript 的含义:
由于渲染机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线,其大致流程如下图所示:
按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。
过程很多,但是我们都可以从下面三个方面去理解每一个阶段:
理解了这三部分内容,能让你更加清晰地理解每个子阶段。
因为浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树。何为DOM树,可以参考下图加以理解:
从图中可以看出,构建 DOM 树的输入内容是一个非常简单的 HTML 文件,然后经由 HTML 解析器解析,最终输出树状结构的 DOM。同样我们可以在控制台中的Console
标签中输入document
后回车,这样我们也能看到一个完整的DOM树。
DOM 和 HTML 内容几乎是一样的,但是和 HTML 不同的是,DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改其内容。
样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成。
把 CSS 转换为浏览器能够理解的结构
从图中可以看出,CSS 样式来源主要有三种:
所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:
从上图我们可以看到,document层上有A和B层,而B层之上又有两个图层。这些图层组织在一起也是一颗树状结构。
图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update LayerTree)。
在这里我们把 div 的大小限定为 200 * 200 像素,而 div
里面的文字内容比较多,文字所显示的区域肯定会超出 200 * 200 的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div
区域,下图是运行时的执行结果:
出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。你可以参考下图:
所以说,元素有了层叠上下文的属性或者需要被剪裁,满足其中任意一点,就会被提升成为单独一层。
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的。
试想一下,如果给你一张纸,让你先把纸的背景涂成蓝色,然后在中间位置画一个红色的圆,最后再在圆上画个绿色三角形。你会怎么操作呢?
通常,你会把你的绘制操作分解为三步:
渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:
从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。
你也可以打开“开发者工具”的Layers
标签,选择document
层,来实际体验下绘制列表,如下图所示:
在该图中,区域 1 就是 document 的绘制列表,拖动区域 2 中的进度条可以重现列表的绘制过程。
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:
如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,那么接下来合成线程是怎么工作的呢?
那我们得先来看看什么是视口,屏幕上页面的可见区域就叫视口(ViewPort),通俗点来说就是,一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口。
在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,如下图所示:
然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。 渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:
通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。
相信你还记得,GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。具体形式你可以参考下图:
从图中可以看出,渲染进程把生成图块的指令发送给 GPU,然后在 GPU 中执行生成图块的位图,并保存在 GPU 的内存中。
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令DrawQuad
,然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫 viz
的组件,用来接收合成线程发过来的 DrawQuad
命令,然后根据 DrawQuad
命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。
好了,我们现在已经分析完了整个渲染流程,从 HTML 到 DOM、样式计算、布局、图层、绘制、光栅化、合成和显示。下面我用一张图来总结下这整个渲染流程:
结合上图,一个完整的渲染流程大致可总结为如下:
DrawQuad
给浏览器进程。DrawQuad
消息生成页面,并显示到显示器上。如果你通过 JavaScript 或者 CSS 修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。 具体可以参考下图:
如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。 具体可以参考下图:
如果更改一个既不要布局也不要绘制的属性,渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。具体流程参考下图:
前面我们讲到了每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是消息队列和事件循环系统。
从最简单的场景讲起,如果存在一系列任务代码需要执行,那我们需要把所有任务代码按照顺序写进主线程里,等线程执行时,这些任务会按照顺序在线程中依次被执行;等所有任务执行完成之后,线程会自动退出。如图所示:
但并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的。比如在线程执行过程中,又接收到了一个新的任务要求计算10+2
,那上面那种方式就无法处理这种情况了。
要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制。主要改动有如下两点:
上面我们改进了线程的执行方式,引入了事件循环机制,可以让其在执行过程中接受新的任务。不过在第二版的线程模型中,所有的任务都是来自于线程内部的,如果另外一个线程想让主线程执行一个任务,利用第二版的线程模型是无法做到的。
那下面我们就来看看其他线程是如何发送消息给渲染主线程的,具体形式你可以参考下图:
从上图可以看出,渲染主线程会频繁接收到来自于 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行 DOM 解析了;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的 JS 脚本来处理该点击事件。
那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?
一个通用模式是使用消息队列。在解释如何实现之前,我们先说说什么是消息队列,可以参考下图:
从图中可以看出,消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。
有了队列之后,我们就可以继续改造线程模型了,改造方案如下图所示:
从上图可以看出,我们的改造可以分为下面三个步骤:
通过使用消息队列,我们实现了线程之间的消息通信。在 Chrome 中,跨进程之间的任务也是频繁发生的,那么如何处理其他进程发送过来的任务?你可以参考下图:
从图中可以看出,渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面讲解的“处理其他线程发送的任务”一样了,这里就不再重复了。
消息队列中的任务类有很多种,这里面包含了很多内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。
以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,我们还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。
页面线程所有执行的任务都来自于消息队列。消息队列是“先进先出”的属性,也就是说放入队列中的任务,需要等待前面的任务被执行完,才会被执行。鉴于这个属性,就有如下两个问题需要解决。
第一个问题是如何处理高优先级的任务。
比如一个典型的场景是监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。
不过这个模式有个问题,因为 DOM 变化非常频繁,如果每次发生变化的时候,都直接调用相应的 JavaScript 接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降。
如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。
这也就是说,如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性。
针对这种情况,微任务就应运而生了。通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。关于宏任务微任务详细介绍可以参考这篇文章。
第二个是如何解决单个任务执行时长过久的问题。
因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。可以参考下图:
从图中你可以看到,如果在执行动画过程中,其中有个 JavaScript 任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉,这当然是极不好的用户体验。针对这种情况,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。
前面我们了解了页面中的事件和消息队列,知道了浏览器页面是由消息队列和事件循环系统来驱动的。接下来我们再说说两个特殊的API:setTimeout
和 XMLHttpRequest
。这两个 WebAPI 是两种不同类型的应用,比较典型,并且在 JS 中的使用频率非常高。现在仔细想想,它们的运行机制似乎不符合我们上述的消息队列。
接下来我们来简单了解一下它们的工作原理。
先简单介绍一下setTimeout
,它就是一个定时器,用来指定某个函数在多少毫秒之后执行。它会返回一个整数,表示定时器的编号,同时还可以通过该编号来取消这个定时器。
要了解定时器的工作原理,就得先来回顾下之前讲的事件循环系统,我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。
所以说要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,我们不能将定时器的回调函数直接添加到消息队列中。
在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。 所以当通过 JS 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。
准确来说这里提到的延迟队列是一个hashmap
结构,等到执行这个结构的时候,会计算hashmap
中的每个任务是否到期了,到期了就去执行,直到所有到期的任务都执行结束,才会进入下一轮循环。
如果当前任务执行时间过久,会影响定时器任务的执行
通过 setTimeout
设置回调时间为0的回调任务被放入了消息队列中并且等待下一次执行,这里并不是立即执行的;要执行消息队列中的下个任务,需要等待当前的任务执行完成,如果是个很长的循环代码,那么当前这个任务的执行时间会比较久一点。这势必会影响到下个任务的执行时间。
如果 setTimeout
存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒
在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。
未激活的页面,setTimeout
执行最小间隔是 1000 毫秒
除了前面的 4 毫秒延迟,还有一个很容易被忽略的地方,那就是未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。
延时执行时间有最大值
除了要了解定时器的回调函数时间比实际设定值要延后之外,还有一点需要注意下,那就是 Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout
设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。
使用 setTimeout
设置的回调函数中的 this 不符合直觉
如果被 setTimeout
推迟执行的回调函数是某个对象的方法,那么该方法中的 this
关键字将指向全局环境,而不是定义时所在的那个对象。
在 XMLHttpRequest
出现之前,如果服务器数据有更新,依然需要重新刷新整个页面。而 XMLHttpRequest
提供了从 Web 服务器获取数据的能力,如果你想要更新某条数据,只需要通过 XMLHttpRequest
请求服务器提供的接口,就可以获取到服务器的数据,然后再操作 DOM 来更新页面内容,整个过程只需要更新网页的一部分就可以了,而不用像之前那样还得刷新整个页面,这样既有效率又不会打扰到用户。
在深入讲解 XMLHttpRequest
之前,我们得先介绍下同步回调和异步回调这两个概念。
首先,将一个函数作为参数传递给另外一个函数,那作为参数的这个函数就是回调函数。
回调函数在主函数返回之前执行的,我们把这个回调过程称为同步回调。
回调函数没有在主函数返回之前执行,而是在主函数外部执行的过程称为异步回调。
理解了什么是同步回调和异步回调,接下来我们就来分析 XMLHttpRequest 背后的实现机制,具体工作过程你可以参考下图:
这是 XMLHttpRequest 的总执行流程图,下面我们就来分析从发起请求到接收数据的完整流程。
我们先从 XMLHttpRequest 的用法开始,首先看下面这样一段请求代码:
function GetWebData(URL){
/**
* 1:新建XMLHttpRequest请求对象
*/
let xhr = new XMLHttpRequest()
/**
* 2:注册相关事件回调处理函数
*/
xhr.onreadystatechange = function () {
switch(xhr.readyState){
case 0: //请求未初始化
console.log("请求未初始化")
break;
case 1://OPENED
console.log("OPENED")
break;
case 2://HEADERS_RECEIVED
console.log("HEADERS_RECEIVED")
break;
case 3://LOADING
console.log("LOADING")
break;
case 4://DONE
if(this.status == 200||this.status == 304){
console.log(this.responseText);
}
console.log("DONE")
break;
}
}
xhr.ontimeout = function(e) { console.log('ontimeout') }
xhr.onerror = function(e) { console.log('onerror') }
/**
* 3:打开请求
*/
xhr.open('Get', URL, true);//创建一个Get请求,采用异步
/**
* 4:配置参数
*/
xhr.timeout = 3000 //设置xhr请求的超时时间
xhr.responseType = "text" //设置响应返回的数据格式
xhr.setRequestHeader("X_TEST","time.geekbang")
/**
* 5:发送请求
*/
xhr.send();
}
上面是一段利用了 XMLHttpRequest 来请求数据的代码,再结合上面的流程图,我们可以分析下这段代码是怎么执行的。
第一步:创建 XMLHttpRequest 对象。
当执行到let xhr = new XMLHttpRequest()
后,JS 会创建一个 XMLHttpRequest
对象 xhr
,用来执行实际的网络请求操作。
第二步:为 xhr 对象注册回调函数。
因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。
XMLHttpRequest
的回调函数主要有下面几种:
ontimeout
:用来监控超时请求,如果后台请求超时了,该函数会被调用onerror
:用来监控出错信息,如果后台请求出错了,该函数会被调用onreadystatechange
:用来监控后台请求过程中的状态,比如可以监控到 HTTP 头加载完成的消息、HTTP 响应体消息以及数据加载完成的消息等第三步:配置基础的请求信息。
注册好回调事件之后,接下来就需要配置基础的请求信息了,首先要通过 open 接口配置一些基础的请求信息,包括请求的地址、请求方法(是 get 还是 post)和请求方式(同步还是异步请求)。
第四步:发起请求。
一切准备就绪之后,就可以调用xhr.send
来发起网络请求了。你可以对照上面那张请求流程图,可以看到:
渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr
的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。
好了,到这里本篇文章也接近了尾声,简单回顾一下本篇文章所讲的东西:
最后再次感谢李兵老师的课程《浏览器工作原理与实践》,这里也强烈推荐一下大家,如果想了解更多浏览器底层的知识,建议去学习一下李兵老师的课程,真的非常地棒。那么,我们下一篇文章见。
作者:纸上的彩虹
链接:https://juejin.cn/post/7064499913115041806
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。