# 浏览器中的进程和线程

在了解浏览器中的进程和线程前先了解一下什么是进程和线程

# 进程和线程

本质上说,进程和线程都是 CPU 工作时间片的一个描述:

  • 进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。

  • 线程是进程中的更小单位,描述了执行一段指令所需的时间

进程是资源分配的最小单位,线程是CPU调度的最小单位。

一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。进程是运行在虚拟内存上的,虚拟内存是用来解决用户对硬件资源的无限需求和有限的硬件资源之间的矛盾的。从操作系统角度来看,虚拟内存即交换文件;从处理器角度看,虚拟内存即虚拟地址空间

如果程序很多时,内存可能会不够,操作系统为每个进程提供一套独立的虚拟地址空间,从而使得同一块物理内存在不同的进程中可以对应到不同或相同的虚拟地址,变相的增加了程序可以使用的内存。

进程和线程之间的关系有以下四个特点:

  1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃。

  2. 线程之间共享进程中的数据。

  3. 当一个进程关闭之后,操作系统会回收进程所占用的内存, 当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。

  4. 进程之间的内容相互隔离。 进程隔离就是为了使操作系统中的进程互不干扰,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信的机制了

# 进程和线程的区别

  • 进程可以看做独立应用,线程不能

  • 资源:进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位);线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)。

  • 通信方面:线程间可以通过直接共享同一进程中的资源,而进程通信需要借助 进程间通信。

  • 调度:进程切换比线程切换的开销要大。线程是CPU调度的基本单位,线程的切换不会引起进程切换,但某个进程中的线程切换到另一个进程中的线程时,会引起进程切换。

  • 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存、I/O 等,其开销远大于创建或撤销线程时的开销。同理,在进行进程切换时,涉及当前执行进程 CPU 环境还有各种各样状态的保存及新调度进程状态的设置,而线程切换时只需保存和设置少量寄存器内容,开销较小。

# 进程之前的通信方式(IPC)

什么是 IPC 通信,如何建立 IPC 通信?什么场景下需要用到 IPC 通信?

IPC (Inter-process communication) ,即进程间通信技术,由于每个进程创建之后都有自己的独立地址空间,实现 IPC 的目的就是为了进程之间资源共享访问,实现 IPC 的方式有多种:管道、消息队列、信号量、Domain Socket,Node.js 通过 pipe来实现。

管道通信

管道是一种最基本的进程间通信机制。管道就是操作系统在内核中开辟的一段缓冲区,进程1可以将需要交互的数据拷贝到这段缓冲区,进程2就可以读取了。

管道的特点:

  • 只能单向通信

  • 只能血缘关系的进程进行通信

  • 依赖于文件系统

  • 生命周期随进程

  • 面向字节流的服务

  • 管道内部提供了同步机制

消息队列通信

消息队列就是一个消息的列表。用户可以在消息队列中添加消息、读取消息等。消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。可以通过发送消息来避免命名管道的同步和阻塞问题。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。

使用消息队列进行进程间通信,可能会收到数据块最大长度的限制约束等,这也是这种通信方式的缺点。如果频繁的发生进程间的通信行为,那么进程需要频繁地读取队列中的数据到内存,相当于间接地从一个进程拷贝到另一个进程,这需要花费时间。

信号量通信

共享内存最大的问题就是多进程竞争内存的问题,就像类似于线程安全问题。我们可以使用信号量来解决这个问题。信号量的本质就是一个计数器,用来实现进程之间的互斥与同步。例如信号量的初始值是 1,然后 a 进程来访问内存1的时候,我们就把信号量的值设为 0,然后进程b 也要来访问内存1的时候,看到信号量的值为 0 就知道已经有进程在访问内存1了,这个时候进程 b 就会访问不了内存1。所以说,信号量也是进程之间的一种通信方式。

共享内存通信

共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问(使多个进程可以访问同一块内存空间)。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

套接字通信

上面说的共享内存、管道、信号量、消息队列,他们都是多个进程在一台主机之间的通信,那两个相隔几千里的进程能够进行通信吗?答是必须的,这个时候 Socket 这家伙就派上用场了,例如我们平时通过浏览器发起一个 http 请求,然后服务器给你返回对应的数据,这种就是采用 Socket 的通信方式了。

# 关于父进程与子进程是如何通信的

父进程在创建子进程之前会先去创建 IPC 通道并一直监听该通道,之后开始创建子进程并通过环境变量(NODECHANNELFD)的方式将 IPC 通道的文件描述符传递给子进程,子进程启动时根据传递的文件描述符去链接 IPC 通道,从而建立父子进程之间的通信机制

# 僵尸进程和孤儿进程是什么?

  • 孤儿进程:父进程退出了,而它的一个或多个进程还在运行,那这些子进程都会成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

  • 僵尸进程:子进程比父进程先结束,而父进程又没有释放子进程占用的资源,那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵死进程。

# 死锁

所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。

系统中的资源可以分为两类:

  • 可剥夺资源,是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU和主存均属于可剥夺性资源;

  • 不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。

产生死锁的原因:

(1)竞争资源

  • 产生死锁中的竞争资源之一指的是竞争不可剥夺资源(例如:系统中只有一台打印机,可供进程P1使用,假定P1已占用了打印机,若P2继续要求打印机打印将阻塞)

  • 产生死锁中的竞争资源另外一种资源指的是竞争临时资源(临时资源包括硬件中断、信号、消息、缓冲区内的消息等),通常消息通信顺序进行不当,则会产生死锁

(2)进程间推进顺序非法(循环)

若P1保持了资源R1,P2保持了资源R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁。例如,当P1运行到P1:Request(R2)时,将因R2已被P2占用而阻塞;当P2运行到P2:Request(R1)时,也将因R1已被P1占用而阻塞,于是发生进程死锁

产生死锁的必要条件:

互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。

请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。

不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。

环路等待条件:在发生死锁时,必然存在一个进程——资源的环形链。

预防死锁的方法:

资源一次性分配:一次性分配所有资源,这样就不会再有请求了(破坏请求条件)

只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏请保持条件)

可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)

资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

# 多进程的浏览器

理解了进程与线程了区别后,接下来对浏览器进行一定程度上的认识:

  • 浏览器是多进程的

  • 浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存)

  • 简单点理解,每打开一个 Tab 页,就相当于创建了一个独立的浏览器进程。

浏览器是多进程的,有一个主控进程,以及每一个 Tab 页面都会新开一个进程(某些情况下多个 Tab 会合并进程)

如下图:

TIP

打开 chrome任务管理器的方法 :在浏览头部标签页旁边的空白区域,右键 就可以弹出有任务管理器的面板.或者 Chrome的更多工具 -> 任务管理器

chrome任务管理器 可以看到浏览器进程可能包括主控进程,插件扩展进程,GPU,tab页(浏览器内核)等等

  • Browser进程:浏览器的主进程(负责协调、主控),只有一个

    • 负责浏览器界面显示,与用户交互。如前进,后退等

    • 负责各个页面的管理,创建和销毁其他进程

    • 将 Renderer 进程得到的内存中的 Bitmap,绘制到用户界面上

    • 网络资源的管理,下载等

  • 第三方插件扩展进程:每种类型的插件对应一个进程,仅当使用该插件时才创建

  • GPU进程:最多一个,用于 3D 绘制

  • 浏览器渲染进程(浏览器内核)( Renderer 进程,内部是多线程的): 可能有多个,默认每个 Tab 页面一个进程,互不影响(有时候会优化,如多个空白 tab 会合并成一个进程)

    • 主要作用:控制页面渲染,脚本执行,事件处理等
  • 网络进程

    主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程

所以,打开一个网页,最少需要四个进程: 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程。如果打开的页面有运行插件的话,还需要再加上 1 个插件进程

浏览器中打开一个网页相当于新起了一个进程(进程内有自己的多线程)

# 浏览器多进程的优势

相比于单进程浏览器,多进程有如下优点:

  • 避免单个 page crash 影响整个浏览器

  • 避免第三方插件 crash 影响整个浏览器

  • 多进程充分利用多核优势

  • 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性

简单点理解:如果浏览器是单进程,那么某个 Tab 页崩溃了,就影响了整个浏览器,体验有多差;同理如果是单进程,插件崩溃了也会影响整个浏览器

TIP

当然,开启开进程就意味着内存等资源消耗也会更大,有点空间换时间的意思。

# 渲染进程

作为前端一枚,我们这里重点关注一下 渲染进程

可以这样理解,页面的渲染,JS的执行,事件的循环,都在这个进程内进行。

浏览器的渲染进程是多线程的,那么接下来看看它都包含了哪些线程(列举一些主要常驻线程):

  • GUI渲染线程

    • 负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。

    • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行

    • 注意,GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起(相当于被冻结了),GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行

  • JS引擎线程

    • 也称为 JS 内核,负责处理 Javascript 脚本程序。(例如V8引擎)

    • JS 引擎线程负责解析 Javascript 脚本,运行代码。

    • JS 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页(renderer进程)中无论什么时候都只有一个 JS 线程在运行 JS 程序

    • 同样注意,GUI 渲染线程与 JS 引擎线程是互斥的,所以如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

  • 事件触发线程

    • 属于浏览器而不是JS引擎,用来控制事件循环;当JS引擎执行代码块如setTimeOut时(也可是来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件触发线程中;当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
  • 定时触发器线程

    • 传说中的 setIntervalsetTimeout 所在线程

    • 浏览器定时计数器并不是由 JavaScript 引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)

    • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)

    • 注意,W3C 在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。

    注意:W3C在HTML标准中规定,定时器的定时时间不能小于4ms,如果是小于4ms,则默认为4ms。

  • 异步 http 请求线程

    • XMLHttpRequest 在连接后是通过浏览器新开一个线程请求

    • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JavaScript 引擎执行。

# Browser进程和Renderer进程的通信过程

以浏览器解析 URL 到页面渲染为例:

  • Browser 进程收到用户请求,首先需要获取页面内容(譬如通过网络下载资源),随后将该任务通过 RendererHost 接口传递给 Render 进程

  • Renderer 进程的 Renderer 接口收到消息,简单解释后,交给渲染线程,然后开始渲染

    • 渲染线程接收请求,加载网页并渲染网页,这其中可能需要 Browser 进程获取资源和需要 GPU 进程来帮助渲染

    • 当然可能会有 JS 线程操作 DOM(这样可能会造成回流并重绘)

  • 最后 Render 进程将结果传递给 Browser 进程

  • Browser 进程接收到结果并将结果绘制出来

# GUI渲染线程与JS引擎线程互斥

由于 JavaScript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JS 引擎为互斥的关系,当JS引擎执行时GUI线程会被挂起

GUI更新则会被保存在一个队列中等到 JS 引擎线程空闲时立即被执行。所以,要尽量避免JS执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

# Q&A

# 什么是异步

  • 在主线程外额外开启一个线程去处理任务,所以不会导致主线程的代码阻塞

  • 不用等待结果的代码

# click是异步的嘛

事件都是由事件处理线程处理的,所以是异步

# 如何实现浏览器内多个标签页之间的通信

实现多个标签页之间的通信,本质上都是通过中介者模式来实现的。因为标签页之间没有办法直接通信,因此我们可以找一个中介者,让标签页和中介者进行通信,然后让这个中介者来进行消息的转发。通信方法如下:

  • 使用 websocket: websocket 协议可以实现服务器推送,所以服务器就可以用来当做这个中介者。标签页通过向服务器发送数据,然后由服务器向其他标签页推送转发

  • 使用 ShareWorker: shareWorker 会在页面存在的生命周期内创建一个唯一的线程,并且开启多个页面也只会使用同一个线程。这个时候共享线程就可以充当中介者的角色。标签页间通过共享一个线程,然后通过这个共享的线程来实现数据的交换

  • 使用 localStorage 的方式 我们可以在一个标签页对 localStorage 的变化事件进行监听,然后当另一个标签页修改数据的时候,我们就可以通过这个监听事件来获取到数据。这个时候 localStorage 对象就是充当的中介者的角色

    window.addEventListener('storage', function(e) {
      document.querySelector('.my-key').textContent = e.key;
      document.querySelector('.my-old').textContent = e.oldValue;
      document.querySelector('.my-new').textContent = e.newValue;
      document.querySelector('.my-url').textContent = e.url;
      document.querySelector('.my-storage').textContent = e.storageArea;
    });
    

    注意点:

    • 在同一个页面内发生的改变不会起作用

    —在相同域名下的其他页面(如一个新标签或 iframe)发生的改变才会起作用

    • 在其他域名下的页面不能访问相同的 Storage 对象