事件循环和消息队列(一)
前言
在讲述事件循环和消息队列之前,需要了解 JS 的单线程执行机制,JS 的执行是从上到下依次执行的,这些便是同步任务,而 ES6 引入了 Promise 对象,使得异步任务开始频频出现在 JS 的代码中。
异步任务不同于顺序执行的同步任务,他对于 JS 运行时来说是一个黑盒,无法预知他究竟什么时候会被执行,因为这取决于异步任务何时从消息队列中出队执行,而消息队列中的异步任务是否出队,则与事件循环机制直接相关。
从多进程(process)和单线程(thread)谈起
人们使用的现代浏览器都是多进程的应用程序,而运行在浏览器上的 JS 代码是单线程的。
浅谈Chrome浏览器架构
如果自己设计一个浏览器,浏览器可以是哪种架构呢?
- 单进程架构(线程间进行通信)
- 多进程架构(进程间 IPC (Inter-Process Communication)通信)
如果你的浏览器要以单进程架构进行设计,需要在一个进程内实现网络、调度、存储、IO设备、渲染、插件等任务,当然你可以把这些任务分为若干个线程去执行,形成单进程多线程的浏览器架构。
但是由于这些任务在现在操作系统中越来越复杂,例如把网络、存储、渲染这些任务放在一个线程中,执行效率和性能越来越低下(比如有一些网页的代码存在内存泄露,即便关闭这些网页线程,进程中的这块内存也无法被回收,除非关闭浏览器,否则越用越卡),且无法再向下拆分出类似线程的子空间,因为线程已经是最小的执行单位。
因此,为了强化浏览器的各个复杂功能,出现了多进程架构的浏览器,可以将网络、存储、渲染、IO、插件这些复杂任务分配给一个个单独的进程,这样每个进程又能向下拆分出多个线程,极大程度上强化了浏览器。
理解Chrome的多进程架构
Chrome也是基于多进程架构的现代浏览器,Chrome的主要进程组成如下:
Browser 进程:Tab之外的一切都有该进程处理。负责地址栏、书签栏、前进后退、网络请求、文件访问等;
Renderer 进程:负责一个 Tab 内所有和网页渲染有关的事情,是最核心的进程;
Plugin 进程:负责 Chrome 插件相关的任务;
GPU 进程:GPU进程与其他浏览器进程相隔离处理GPU任务,把浏览器的页面内容绘制到屏幕上;
所有应用程序都要在OS的调度下基于CPU和GPU的计算才能运行。因为GPU要处理多个应用程序的的请求,浏览器的的GPU进程只是一个分量。GPU擅长处理图形,因此提供GPU计算的应用程序可以实现快速渲染和平滑交互。
Chrome 的每一个Tab 选项卡都拥有自己的 Renderer 进程,有三个 Tab 就意味着有三个不同的 Renderer 进程这样可以保证多个 Tab 之间互不影响,即使其中一个 Tab 没有响应,也不影响其他 Tab 的正常执行。然而,由于进程是 OS 中拥有资源的独立单位,多个 Tab 之间的数据是非共享的,这也意味着多个 Tab 都会有相同的 V8引擎初始化数据,这意味着更多的内存使用。
了解 Browser 浏览器进程
简单来说,在浏览器中,Tab之外的一切都归浏览器进程所接管,它包含3个主要的线程:
- UI thread UI线程:负责绘制和管理浏览器的按钮和输入框区域。
- Network thread 网络线程:负责处理网络堆栈以从互联网接收数据
- Storage thread 存储线程:负责控制文件访问
而根据浏览器的优化策略,这三个线程往往会独立为三个进程。
现在让我们来模拟一个在地址栏输入网址,并将网页呈现在浏览器上的过程
用户在地址栏中键入字符串,UI 线程会识别该字符串是 URL 还是搜索关键词。
Chrome中的地址既可以访问网页,同时又是个搜索框,这里假设我们输入的是 URL。
UI 线程通知网络线程开始进行导航,发起网络请求
读取响应数据,如果响应的是 HTML 文件,那么下一步会将该数据传递给渲染进程;但如果响应数据是一个压缩包或其它类型的文件,那么就意味着我们发送的是下载请求,所以需要把数据传递给下载管理器
UI线程负责找到渲染进程,通知它要进行网页渲染
此时数据和渲染进程都已经准备好,浏览器进程和渲染进程开启 IPC 传递数据,导航部分完成,你会发现tab由原网页台跳转到空白页面,然后开始边传输HTML 边进行网页渲染。
了解最为重要的 Renderer 渲染进程
渲染进程主要包括4个线程:
- Main thread 主线程:执行JS、下载资源、计算样式、进行布局、绘制合成
- Raster thread 光栅线程
- Compositor thread 合成线程
- Worker thread 工作者线程
主线程的功能
执行 JS:主线程在遇到
<script>
标签时会阻塞HTML文档的解析,并必须先下载、解析和执行js代码,why?。因为浏览器需要一个稳定的 DOM 树结构,而 JavaScript 中的代码可能直接改变了 DOM 树的结构,甚至 直接使用 location.href 进行跳转,所以浏览器为了防止出现 JavaScript 改变 DOM 树的情况,会阻塞其他的下载和渲染。下载外部资源:如果HTML中由需要加载外部资源的标签,这在解析HTML构建DOM树之前会由预加载扫描线程检测到,并提前利用 Browser 线程的 Network 线程来下载
<img/>
、CSS和 JS的<link>
等渲染DOM需要的外部资源文件,这减少了解析 HTML 的阻塞时间解析HTML:由 HTML解析器解析 HTML 内容,首先由分词器检测出各个标签名,我们称他们为token,然后利用token栈和括号匹配算法,构建出DOM树。同时会根据外部、内部和内联 CSS 样式计算得到 CSSOM 树。
计算CSS样式:主线程根据 CSSOM 树进行CSS属性值的计算,并将计算后的样式添加到DOM树的对应DOM节点上。
:boat: 计算(最终)样式(computed style):是把继承、层叠关系理清,并且把所有CSS属性都赋值之后的CSS样式。
:warning:HTML本质上只是提供了语义化的标签。为何div、p标签是块盒,而span标签却是行盒?根本原因是浏览器的源码中,设置了浏览器默认样式,而这些标签分别被设置为了
display:block
和display:inline
确定布局结构Layout:只有DOM节点和和它的样式可不够,还需要确定他们之间的布局关系,并构建与DOM树类似的布局树,比如在页面上的位置、盒子的尺寸大小的信息
:label:布局树通常情况下与DOM树结构并不相同。由于布局树只考虑存在位置和尺寸这样的几何信息的DOM元素,所以
display:none
的DOM元素在构建布局树时是不被考虑的,类似的,还有一些伪元素,匿名行盒,匿名块盒…..:man_teacher: W3C规定:标签的文本必须被包含在行盒中;行盒和块盒不能相邻。因此用匿名行盒和匿名块盒来适应这个规定
分层(Layer):主线程会使用一套复杂的策略对整个布局树中进行分层。分层的好处在于,将来某一个层发生改变后,仅会对该层进行后续处理,从而提升效率
滚动条、层叠上下文(z-index)、transform、opacity 等样式都会或多或少的影响分层结果,也可以通过
will-change
属性更大程度的影响分层结构。分层不是越多越好,层数太多会导致占用大量的内存空间,因为浏览器会根据内存和效率权衡分层的数量。
计算绘制指令集(paint):主线程会为每个层单独产生绘制指令集,用于描述这个层的内容该如何一步步地画出来。完成此步后,主线程将绘制指令集交付给合成线程
合成器线程
一旦确定了绘制指令集,主线程就会将该信息提交给合成器线程。然后,合成线程将对每个层进行分块并光栅化。一个层可以比视口要大,所以合成器线程将它们划分为瓦片(图块),并将每个瓦片发送到GPU进程,完成光栅化,并且在这个过程中优先光栅化靠近视口的区域,紧接着再去光栅化页面的其他区域。(tiling和raster)
将这些信息转换为屏幕上的像素称为光栅化
光栅化完成后,GPU进程将生成的位图交回给合成线程,合成线程收到每个层、每个块的位图之后,生成一个个的指引(quad)信息。指引信息会表示出每个位图应该滑到屏幕的那个位置,以及会考虑到旋转、缩放等变形。然后合成线程将 quad 提交给GPU进程,由GPU进程产生系统调用,提交给GPU硬件,完成最终的屏幕成像。(draw)
由于变形操作是在合成线程中执行的,与渲染主线程无关,这就是 transform 效率高的原因
- 浏览器滚动时,合成线程会创建一个新的合成帧发送给 GPU,以显示到屏幕上,所以即便主线程卡死,也不影响页面滚动。
- 合成线程工作与主线程无关,不用等待样式计算和 js 的执行,因此合成线程相关的动画比涉及到主线程重新计算样式和执行 js 的动画更加流畅
浏览器的渲染过程流程图
参考
Inside look at modern web browser (part 1)
浅谈浏览器架构、单线程js、事件循环、消息队列、宏任务和微任务