浏览器的进程模型

要了解事件循环概念我们要先了解浏览器的进程模型。

浏览器的进程模型是指浏览器在执行任务时如何划分和管理进程,以实现页面渲染、网络请求、插件处理等功能。不同的浏览器使用不同的进程模型来优化性能、增强安全性和提高稳定性。

1. 单进程模型

在早期浏览器(如最早版本的IE浏览器)中,所有任务都在一个进程中完成。这种模型会将浏览器的界面、网络请求、JavaScript 解析和页面渲染都集中在一个进程里。

主要特点如下:

  • 优势:占用较少的系统资源,适合低性能设备。

  • 劣势:不安全、不稳定。一个页面崩溃或代码执行错误可能导致整个浏览器崩溃或卡死。

2. 多进程模型

现代浏览器(如 Chrome、Edge)多采用多进程模型。多进程模型将不同任务划分到独立的进程中,以减少任务之间的相互干扰。

一般来说,常见的进程类型包括:

  • 浏览器进程:负责界面展示、用户交互、文件管理等功能。

  • 渲染进程:负责页面渲染和 JavaScript 解析,通常每个页面或标签页使用一个独立的渲染进程。

  • 插件进程:负责处理插件(如 Flash)内容,避免插件崩溃影响其他页面。

  • GPU 进程:负责图形加速任务,提升渲染性能。

这种模型下,浏览器进程是主进程,其他进程负责特定任务并与主进程通信。

  • 优势:安全性和稳定性高。页面崩溃不会影响其他页面或浏览器进程。

  • 劣势:资源占用较高,每个标签页和插件进程都消耗系统内存。

3. 混合进程模型

有些浏览器(如 Firefox)采用混合进程模型,介于单进程和多进程之间。通常会分为一个浏览器主进程和多个渲染进程,但不是每个标签页都独占一个进程,而是根据需要共享渲染进程。Firefox 的 Electrolysis (e10s) 项目就是这种模型的实现。

  • 优势:在提升性能和安全性的同时,内存占用更低。

  • 劣势:相较于完全独立的多进程模型,某些页面崩溃可能会影响其他页面。

4. 站点隔离进程模型

站点隔离模型是 Chrome 等浏览器的进一步优化,主要为了防范不同站点间的跨站脚本攻击(如 Spectre 漏洞)。每个站点使用单独的渲染进程,同一站点的多个标签页可以共享进程,而不同站点的标签页则独立于不同进程中。

  • 优势:安全性更高,可以更好地防止站点间的信息泄露。

  • 劣势:资源消耗进一步提升,需要更多的内存来支持隔离。


何为进程

程序运行需要有它自己的专属的内存空间,可以把这块内存空间简单的理解为进程。
每个应用至少有一个进程,进程之间相互独立,即时要通信也需要双方同意(王者荣耀登陆微信授权);


何为线程

一个进程至少有一个线程,所以进程在开启后会自动创建一个线程来运行代码,该线程称之为主线程
如果程序需要同时执行多块代码,主线程就会启动更多的线程来执行代码,所以一个进程中可以包含多个线程。

以当前比较火的手游王者荣耀为例:

  • 主线程:处理登录注册之类的

  • 游戏线程:监听点击事件,响应用户操作

  • 网络线程:看别人有移动,放啥技能

实际上游戏内部不止上述三个线程,以上只是为了举例来说明线程


渲染主线程是如何工作的?

了解完浏览器的基础知识后,我们来了解浏览的渲染主线程

渲染主线程是浏览器中最繁忙的线程。

需要他处理的任务包括但不限于:

  • 解析 HTML 和 CSS

  • 计算样式(例如,将 vw、百分比等单位换算为像素)

  • 布局计算(确定每个元素的位置和尺寸)

  • 图层处理(如处理 z-index 确定元素的前后顺序)

  • 以每秒 60 帧的速率刷新页面,以确保动画和交互效果流畅

  • 执行全局 JavaScript 代码

  • 执行事件处理函数

  • 触发计时器回调函数

  • …等等

思考:为什么渲染进程不使用多个线程来处理这些事情?

要处理这么多的任务,主线程遇到一个前所未有的难题:如何调度任务?

比如:

  • 当正在执行一个 JavaScript 函数时,如果用户点击了按钮,是否应立即中断当前任务去处理点击事件?

  • 当执行一个 JavaScript 函数时,某个计时器达到时间,是否应该立即执行其回调?

  • 如果浏览器同时收到“用户点击按钮”的通知,而某个计时器也到时间,应该先处理哪一个?

渲染主线程使用队列或者白话一点就是排队来处理这些问题

  • 检查消息队列:每次循环,渲染主线程会检查消息队列中是否有待处理任务。如果有,则从队列中取出第一个任务执行。完成后,主线程重新进入循环,继续检查下一个任务。

  • 进入休眠:如果消息队列中没有任务,主线程会进入休眠状态以节省资源。

  • 唤醒机制:其他线程(或其他进程中的线程)可以随时向消息队列添加任务。新任务会添加到队列末尾。当有新任务加入时,如果主线程处于休眠状态,则会被唤醒以继续循环处理任务。

通过这种队列机制,渲染主线程能够有效地按顺序处理任务,避免不同任务间的冲突或抢占。渲染主线程会根据消息队列的顺序处理事件,比如 JavaScript 函数、点击事件、计时器回调等,确保页面响应的连贯性和稳定性。

这种调度策略帮助浏览器主线程更高效地处理任务,同时确保页面流畅运行。

这整个过程被称之为事件循环(消息队列)


异步编程

熟悉js编程的小伙伴都知道,我们在js进行进行的Api请求、事件绑定等等相关方法都是常常采用回调函数的方式来处理,亦或者代码在执行过程中,会遇到一些无法立即处理的任务。

比如:

  • 计时完成后需要执行的任务 – setTimeOutsetInterval

  • 网络通信完成后需要执行的任务 – XHRaxios

  • 用户操作后需要执行的任务 – addEventListener

如果让渲染主线程等待这些任务的时机达到,就会导致主线程长期处于阻塞的状态,从而导致浏览器卡死

渲染主线程承担的极其重要的工作,无论如何都不能阻塞。因此浏览器选择了异步来解决这个问题。

如下图:

WX20221011-102804@2x (1).png

1. 渲染主线程

  • 起始点:流程图从“渲染主线程”开始,这是处理图形界面和用户交互的主要线程。

2. 计算开始

  • 任务入队:在“计算开始”阶段,一个任务(例如,需要定时执行的操作)被创建并放入“message_queue”的消息队列中。任务现在等待被执行。

  • 通知计时线程:同时,渲染主线程通知一个名为“计时线程”的单独线程开始计时。这个计时线程与渲染主线程并行运行,负责监控任务的执行时间。

3. 消息队列(message_queue)

  • 任务等待:在消息队列中,任务按照先进先出的顺序等待被执行。队列是任务调度的核心,确保任务按照预定的顺序和资源可用性被执行。

4. 计算任务结束

  • 任务执行:在某个时刻,消息队列中的任务被取出并执行。执行可能涉及数据处理、网络请求、文件读写等操作。

  • 任务完成:一旦任务执行完毕,它会被从消息队列中移除,并可能触发一些后续操作,如更新用户界面、发送通知等。

5. 计时线程

  • 计时开始:计时线程在接收到渲染主线程的通知后开始计时。

  • 计时中...:计时线程持续监控任务的执行时间,直到任务完成或达到预定的时间限制。

  • 计时结束:当任务完成或时间限制到达时,计时线程停止计时。如果任务完成了,计时线程可能会将一个回调函数放入消息队列的末尾,以便在稍后执行一些清理工作或更新状态。


任务有优先级吗?

任务没有优先级,在消息队列中先进先出。但消息队列是有优先级的

根据 W3C 的最新解释:

  • 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。

  • 浏览器必须准备好一个微队列,微队列中的任务优先于所有其他任务执行

但随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法。

目前 chrome 的实现举例, 优先级排序如下:

  • 微队列:用户存放需要最快执行的任务,优先级「最高」

  • 交互队列:用户存放用户操作后产生的时间处理任务,优先级「高」

  • 延时队列:用户存放计时器到达后的回调任务,优先级「中」

但其实浏览器还有很多其他队列,由于与前端开发关系不大,此处不作说明。

添加任务到微队列的主要方式:PromiseMutationObserver

Promise.resolve().then(函数); // 立即把一个函数添加到微队列

// 题目 5,4,3,1,2,6
function a() {
  console.log(1);
  Promise.resolve().then(() => {
    console.log(2);
  })
}
setTimeout(() => {
  console.log(3)
  Promise.resolve().then(a)
}, 0);

Promise.resolve().then(() => {
  console.log(4);
  setTimeout(() => {
    console.log(6)
  },0);
})
console.log(5);

面试常见问题

如何理解 JS 的异步?

JS 是一门单线程语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。渲染主线程承担的诸多的工作,渲染页面、执行 JS 等。如果使用同步的方式,就极有可能导致主线程产生阻塞,从而导致消息队列中的很多其他任务无法得到执行。这样一来,一方面会导致繁忙的主线程白白的消耗时间,另一方面导致页面无法及时更新,给用户造成卡死现象。

所以浏览器采用异步的方式来避免。具体做法是当某些任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身立即结束任务的执行,转而执行后续代码。当其他线程完成时,将事先传递的回调函数包装成任务,加入到消息队列的末尾排队,等待主线程调度执行。在这种异步模式下,浏览器永不阻塞,从而最大限度的保证了单线程的流畅运行。

阐述下 JS 的事件循环

事件循环是浏览器渲染主线程的工作方式。

在 Chrome 的源码中,它开启了一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。

过去把消息队列简单分为宏队列和微队列,这种说法目前已经无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。

根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列,不同的队列有不同的优先级,在一次时间循环中,由浏览器自行决定去哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。

总的来说:单线程是异步产生的原因,事件循环是异步的实现方式。

当然此处还是说一句博主的观点:

虽然我本身不喜欢,也不提倡八股文背诵来面试,但这不代表你可以什么都不用了解,可以不熟记所有的知识,但是你不能不知道有这么个知识。你可以不了解具体的实现,但是你不能不知道有这么个知识,如果你都不知道有这么个东西,即使你遇到问题了,你连查找关键词都整不明白,既影响你的工作效率,也会让自己因无法解决问题而产生工作情绪。