浏览器中的页面循环系统

936 阅读8分钟

参透了浏览器的工作原理,你就能解决80%的前端难题!

Chrome 打开一个页面至少会启动 4 个进程

  • 浏览器进程。主要负责界面显示、用户交互、子进程管理等。
  • GPU 进程。初衷是为了实现 3D CSS 的效果,只是随后网页、 Chrome 的 UI 界面都是采用 GPU 绘制的,使得 GPU 成为浏览器普遍的需求。
  • 网络进程。主要负责页面的网络资源加载。
  • ** 渲染进程**。核心任务就是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下, Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • 插件进程。主要负责插件的运行。因为插件易奔溃,所以需要通过插件进程来隔离,以保证插件进程不影响浏览器。

其中每个渲染进程都有一个主线程,并且主线程非常繁忙

  • 解析 DOM
  • 计算样式
  • 处理布局
  • 处理 JavaScript 任务
  • 处理各种输入事件 ...

要让这么多不同类型的任务在主线程中有条不紊地执行,就需要一个系统来统筹调度这些任务,这个系统就是消息队列和事件循环系统

使用单线程处理安排好的任务

从最简单的场景讲起,比如有以下一系列的任务:

  • 任务1: 1+2
  • 任务2: 3* 4
  • 任务3: 20 /5
  • 任务4: 打印出运行结果

如果要在一个线程去执行这些任务,通常会这样编写代码

void MainThread(){
     int num1 = 1+2; //任务1
     int num2 = 20/5; //任务2
     int num3 = 7*8; //任务3
     print("最终计算的值为:%d,%d,%d",num1,num2,num3); //任务4
 }

把所有任务都按照顺序写进主线程里,按照顺序依次执行,等所有任务执行完成之后,线程会自动退出。

线程的一次执行

不过并不是所有的任务都是在执行之前就统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的。比如在线程执行过程中,又接收到了一个新的任务要求计算“10+2”

在线程运行过程中处理新任务

要想在线程运行过程中能接收并执行新的任务,就需要采用事件循环机制

比如通过一个 for 循环语句来监听是否有新的任务

//GetInput
//等待用户从键盘输入一个数字,并返回该输入的数字
int GetInput(){
    int input_number = 0;
    cout<<"请输入一个数:";
    cin>>input_number;
    return input_number;
}

//主线程(Main Thread)
void MainThread(){
     for(;;){
          int first_num = GetInput();
          int second_num = GetInput();
          result_num = first_num + second_num;
          print("最终计算的值为:%d",result_num);
      }
}

主要是两点改进:

  • 引入循环机制:线程会一直循环执行
  • 引入事件:可以在线程运行过程中,等待用户输入数字

在线程中引入事件循环

通过引入事件循环机制,可以让线程在执行的过程中接受新的任务,不过所有的任务都是来自于线程内部的。

处理其他线程发送过来的任务

渲染主线程会频繁接收到来自 IO 线程的一些任务,比如:

  • 接收到资源加载完成的消息,需要进行DOM解析
  • 接收到鼠标点击的消息,要执行相应的 JavaScript 脚本

一个通用模式是使用消息队列

消息队列 + 循环

  1. 添加消息队列
  2. IO 线程中产生的新任务添加到队列的尾部
  3. 渲染主线程会循环的从消息队列头部取出任务并执行

通过使用消息队列,实现了线程之间的通信。不过在浏览器中,跨进程之间的任务也是频繁发生的。

处理其他进程发送过来的任务

跨进程发送消息

从图中可以看出,渲染进程专门有一个 IO 线程来接收其他进程传进来的消息,接收到消息之后会将这些消息组装成任务发送给渲染主线程,添加到消息队列中。

到目前为止,页面线程所有执行的任务都来自于消息队列。鉴于消息队列先进先出的属性,就有如下两个问题需要解决。

问题一:如何处理高优先级的任务

比如一个典型的场景是监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。

  • 如果使用同步通知的方式,比如利用 JavaScript 设计一套监听接口,当发生变化时渲染引擎同步调用这些接口,因为 DOM 变化非常频繁,可能会把当前任务执行时间拉长,从而导致 执行效率的下降
  • 如果使用异步通知的方式,比如将 DOM 变化作为消息事件添加到消息队列的尾部,可能将影响监控的实时性,因为添加到消息队列的过程中,前面可能已经有很多任务了。

为了适应效率实时性,浏览器引入了微任务

通常把消息队列中的任务称为宏任务,比如:

  • 渲染事件(如解析 DOM、计算布局、绘制等)
  • 用户交互事件(如鼠标点击、滚动画面、放大缩小等)
  • JavaScript 脚本执行事件
  • 网络请求完成等
  • ...

微任务 就是一个需要异步执行的函数,存放在宏任务的微任务队列中。

微任务队列 是 V8在执行一段脚本创建全局上下文时创建的,每个宏任务都会关联一个微任务队列,在当前宏任务执行过程中,产生的所有的微任务都会存储在这个队列中。

微任务的产生时机

在现代浏览器中,产生微任务有两种方式:

  1. 使用 MutationObserver 监控某个 DOM 节点,然后通过 JavaScript 来修改这个节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务
  2. 使用 Promise,当调用 Promise.resolve()  或者 Promise.reject() 的时候,也会产生微任务

微任务的执行时机

通常情况下,在当前宏任务中的 JavaScript 执行完成时,也就是 JavaScript 引擎准备退出全局执行上下文并清空调用栈时,会检查微任务队列并执行。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。

通过微任务在实时性和效率之间做了有效的权衡

微任务添加和执行流程示意图

问题二:怎么实现setTimeout /setInterval?

说起 setTimeout 大家都不会陌生,它就是一个定时器,用来指定某个函数在多少毫秒之后执行,并返回一个表示定时器编号的整数,同时还可以通过该编号来取消这个定时器。

	const timerID = setTimeout(() => {
		console.log("test")
	},200);

如果将任务添加到消息队列中,但是消息队列中的任务是按照顺序执行的,而通过定时器设置回调函数需要在指定的时间间隔内被调用,所以不能直接将定时器的回调函数直接添加到消息队列中。

为了支持定时器的实现,浏览器增加了延时队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。

所以当通过 JavaScript 创建一个定时器时,渲染进程会为定时器创建一个回调任务,包含回调函数发起时间延迟执行时间等,并添加到延迟队列中。

延迟队列的执行时机

在处理完消息队列中的一个任务之后,就会开始遍历延迟队列中的每个任务,根据发起时间和延迟时间计算出到期的任务并依次执行,直到所有到期任务执行完成之后,再继续下一个循环过程。

在使用 setTimeout 的时候,有很多因素会导致回调函数执行比设定的预期值要久,其中一个就是当前任务执行时间过久从而导致定时器设置的任务被延后执行。

总结

  • 如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务,这是第一版线程模型。
  • 如果要在线程执行过程中接收并处理新的任务,就需要引入循环语句和事件系统,这是第二版线程模型。
  • 如果要接收其他线程发送过来的任务,就需要引入消息队列,这是第三版线程模型。
  • 如果其他进程想要发送任务给页面主线程,那么先通过 IPC 把任务发送给渲染进程的 IO 线程,IO 线程再把任务发送给页面主线程。
  • 消息队列机制并不是太灵活,为了适应效率和实时性,引入了微任务
  • 为了支持定时器的实现,浏览器增加了延时队列

参考文档

  • 浏览器工作原理与实践--李兵