【浏览器工作原理系列】事件循环机制(核心深入篇)

414 阅读13分钟

努力让学习成为一种习惯,自信来源于充分的准备。

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

这里是浏览器工作原理系列事件循环机制的第二篇,这个系列会从事件循环起步,直到渲染原理,优化技巧等。打通浏览器相关知识体系,感兴趣的小伙伴可以持续关注

在上篇文章事件循环机制(基础铺垫篇)中,我们介绍了进程、线程同步、异步、并发、并行、串行等基础概念,这些概念对我们深入理解事件循环有非常大的帮助。接下来我们深入探究事件循环本身

以下所有浏览器相关的知识点都基于Chrome

渲染进程

之前我们提到过,进程之间是隔离的。一个进程崩溃了不会影响到其它的进程。现代浏览器均是采用了多进程架构。当我们打开一个网页,会启动以下进程:浏览器主进程、GPU进程、插件进程、网络进程、渲染进程。

温馨提示:有关浏览器架构、各个进程的详情、页面渲染详情等知识点在后面文章会专门详细介绍。本篇文章只简单提下,重点关注事件循环相关的知识点

渲染进程主要工作是运行Blink,Blink做了几乎所有浏览器网页内(浏览器tab栏内)的事情

  • 执行各个web平台的标准(比如HTML),包括 DOM、CSS和Web接口描述语言
  • 嵌入V8引擎、执行js(这里我们常说js单线程,本质是因为它运行在渲染主线程中)
  • 从底层网络堆栈请求资源
  • 构建DOM数
  • 计算样式、布局、处理图层、画页面

一个渲染进程包含一个主线程、一个IO线程、合成线程、多个Worker线程以及其他一些内部工作线程,出于安全考虑每个渲染进程都会用沙箱隔离

线程描述
渲染主线程几乎所有重要的任务都由主线程负责,渲染进程主要采用单线程架构,Blink经过高度优化,可最大限度地提高主线程的性能
IO线程主要用于渲染进程与其他进程的通信,即其他进程的任务通过IO线程将其放到消息队列中让渲染主线程执行(值得一提的是,对于渲染进程来说IPC这种进程通信方式已经逐渐被废弃,使用Mojo替代,感兴趣的小伙伴可以自行查阅资料,这里就不展开了)
工作线程Blink 可以创建多个工作线程来运行 Web Workers、Service Workers 和 Worklets。
内部线程Blink 和 V8 可能会创建几个内部线程来处理网络音频、数据库、垃圾回收等任务。
合成线程用于图层合成,某些 CSS3 动画可以直接通过合成线程而不需要主线程,效率很高。

消息队列

几乎所有的工作都由渲染主线程执行,渲染主线程将请求的js、html、css等网络文件资源转化为与用户可交互的动态网页。这其中有大量的工作任务:文件解析、页面渲染。所有除workers外的js代码执行、用户交互事件、异步事件等,可见渲染主线程是无论如何都不能被阻塞的,一旦被阻塞,整个页面就失去了响应,成了“假死”状态。这是万万不可接受的

因此,我们需要一种数据结构帮助渲染主线程有条不紊的执行各种任务,这个数据结构就是消息队列,而渲染主线程不断循环的从消息队列取出执行的过程就是事件循环

一图胜千言

messageloop.png

消息队列上的任务有很多类型,详细可以参考task_type

事件循环

一个页面渲染、运行的过程中会源源不断的产生各种类型任务。其中js执行一段异步代码时会通知其他线程,自己会继续执行后续代码,待事件得到响应的时候其对应的回调函数会被包装成任务。这些任务会统一被添加到一个名为消息队列的结构中。渲染主线程无限循环从消息队列取出任务执行的过程我们称为事件循环

我们可以用以下伪代码简单描述下事件循环的过程

void processTask() 
taskQueue task_queue
main_renderer_thread() {
   while(true) {
     task = task_queue.takeTask()
     processTask()
   }
}

再谈异步

之前我们对异步有一个定义:所有需要多个线程(在这里指除渲染主线程外的线程)参与的任务都可以定义为异步任务。(渲染主线程不能被阻塞。当js代码执行到不能立刻响应的代码时(IO事件,定时器、XHR、用户交互事件等),不可能一直等待直到响应。那么解决办法是什么?答案就是采用异步的方式:事件派发+回调函数。当我们触发某些异步事件的时候,相当于向外派发了一个事件。待将来事件完成后,该事件对应的回调处理函数会被包装成任务放到消息队列中等待渲染主线程调度执行(当然有些异步任务会稍微会有些不同,比如setTimeout就比较特殊。但是这个思想是通用的)

简单来说:异步是单线程避免阻塞的方法。通过异步的思想结合回调函数。将一个个任务存储到消息队列等待渲染主线程调度执行便是事件循环。

温馨提示:个人认为异步任务需要多个线程参与还有另外一个重要原因:安全问题。每一个站点都会对应一个单独的渲染进程。而浏览器进程会为其进行沙箱隔离。这也意味着,渲染进程本身一些I/O操作、定时器操作等都会交给浏览器内核处理。最后浏览器内核将处理结果给渲染进程中的I/O线程

宏任务

页面中的大部分任务都存放在消息队列中等待渲染主线程调度执行,这些任务有:

  • 计时器事件
  • js脚本执行事件
  • 网络请求完成事件
  • 渲染事件(DOM 解析、CSS解析、布局、绘制等)
  • 用户交互事件

在消息队列中存放、渲染主线程执行的任务称之为宏任务

setTimeout

setTimeout是典型的宏任务。但它相对于其它宏任务特殊些,因此我们单独讲下

首先需要明白一点:setTimeout是宿主环境本身提供的能力即WebAPI,它和js本身没有关系,另外计时功能底层也是调用的操作系统

function timeTaskCb() {
   console.log('延时回调处理函数执行')
}

setTimeout(timeTaskCb, 2000)

当js调用setTimeout设置回调的时候。会向浏览器主进程派发计时事件,此时渲染进程会创建一个任务添加到延时队列中(实际结构是一个hashmap),感兴趣的可以参考task_queue_impl.h

每一个任务是一个结构体,伪代码如下:

// 设置一个延时任务
struct DelayTask{
  int64 id;
  CallBackFunction cbf;
  int start_time;
  int delay_time;
};
DelayTask timerTask;
timerTask.cbf = timeTaskCb; // 延时回调函数
timerTask.start_time = getCurrentTime(); //获取当前时间
timerTask.delay_time = 2000;//设置延迟执行时间

delay_task_queue.push(timerTask)

接下来这句话非常重要,需要认真思考消化下:每一次事件循环,除了执行当前消息队列中的宏任务。还会去检查延迟队列中是否有已到时的计时任务,如果有则全部执行完

可以用以下伪代码表示:

void processTask() 
void processDelayTimingTask()
taskQueue task_queue
main_renderer_thread() {
   while(true) {
     task = task_queue.takeTask()
     processTask()
     // 如果有到时的定时任务,则全部执行,注意这里也是宏任务
     processDelayTimingTask()
   }
}

我们用一个🌰验证下上面的结论

<body>
  <script>
   document.addEventListener('DOMContentLoaded', () => {
      setTimeout(() => {
        console.log('定时器宏任务1');
      }, 2000);
      setTimeout(() => {
        console.log('定时器宏任务2');
      }, 3000);
      const now = performance.now()
      // 这里故意设置成4s,确保运行完当前宏任务,计时已经结束
      // 定时任务对应的回调添加在延迟队列中
      while(performance.now() - now < 4000) {}
    })
  </script>
</body>

运行网页,打开performance面板,我们观察下

image.png

Timer Fired事件计时器回调执行时触发。两个定时器之间的时间隔了一秒,从图中我们可以很明显的观察到这两个定时宏任务是前后相继触发的,中间没有插入任何别的宏任务

微任务

我们依旧借助performace面板说明(有关performace详细实战后续会专门出个文章讲解)

image.png

图中每一个灰色的Task我们可以认为是一个宏任务,可以看到宏任务非常的多、琐碎,颗粒度比较大。每一个宏任务添加到消息队列中的顺序是由宿主环境操作的(页面渲染、用户交互、js执行、网络请求等),js没有操作权限,这意味着执行js代码中产生的宏任务顺序并不一定是最终在消息队列中的顺序(渲染主线程的执行顺序)

举个🌰

<body>
  <script>
     setTimeout(() => {
      console.log(1);
      setTimeout(() => {
        console.log(2);
      }, 0);
    }, 0);
    // functi
  </script>
</body>

上面的例子我们期望执行完第一个定时器立刻执行第二个。但是事实真的如此吗?我们借助performance来看下

image.png

图中我们可以明显的看到两个定时器回调任务中间插了一个宏任务。这也说明了宏任务不能很好的处理对实时性要求高的任务

MutationObserver一个对实时性要求比较高的WebAPI,它用于监听DOM的变化。如果将其作为宏任务,实时性得不到保证。那如果采取同步的方式监听,效率性又太低了。前面也说了渲染主线程是无论如何都不能被阻塞的。那有什么办法既能保证效率又能保证实时性呢。浏览器采取了一种“中庸”的解决方案:微任务

我们来看看 mdn 对其的定义

一个微任务(microtask)就是一个简短的函数,当创建该微任务的函数执行之后,并且只有当 Javascript 调用栈为空,而控制权尚未返还给被用户代理用来驱动脚本执行环境的事件循环之前,该微任务才会被执行。事件循环既可能是浏览器的主事件循环也可能是被一个 web worker 所驱动的事件循环。这使得给定的函数在没有其他脚本执行干扰的情况下运行,也保证了微任务能在用户代理有机会对该微任务带来的行为做出反应之前运行。

简单来说:微任务的执行时机在主函数执行结束之后(即准备退出全局执行上下文),当前宏任务执行结束之前

在微任务出现之前,事件循环的调度规则是由宿主环境管理的。微任务的出现(准确的说是es6的promise的出现)意味着js(V8引擎)也能够参与到事件循环的调度规则中来。并且它提供了一种更为精细化的调度方式

每一个宏任务执行(更准确的说是在执行js相关的宏任务)的时候,都会创建自己的微任务队列

常见的微任务有:

  • promises:Promise.thenPromise.catchPromise.finally
  • MutationObserver:使用方式
  • queueMicrotask:使用方式
  • process.nextTick:Node独有

我们通过一个🌰来感受下

<body>
  <script>
    function Test() {
      console.log('a');
      const now = performance.now()
      // 这里故意延迟3s,让宏任务时间延长。好观察
      while(performance.now() - now < 3000) {}
    }
    Promise.resolve().then(() => {
      console.log(4);
    })

    setTimeout(() => {
      console.log(1);
    }, 0);
    Test()
  </script>
</body>

image.png

通过上图我们可以很清晰的看到,在执行完Test函数后,执行了微任务,但注意这时候它仍属于Evaluate Script下,这也意味着此时全局执行上下文还没有被销毁

接下来我们从V8的视角结合调用栈来看上面的例子

1.执行代码,函数执行上下文入栈。添加微任务到微任务队列中

call1.png

2.js全局代码执行完成,准备退出全局执行上下文清空调用栈。发现微任务队列有任务,取出并执行

call3.png

只要是本次js宏任务执行过程产生的微任务,都会在本次宏任务结束前执行完成。这也意味着,如果某个微任务的执行时间很长,这将导致整个宏任务的时间过长,这一样会导致页面卡顿。因此不建议微任务里面处理耗时的工作

总结

我们简单对本文内容做一个整体的总结:

浏览器页面内的几乎所有工作(dom/css解析,页面布局,绘制,渲染,用户交互,网络请求,js执行等)都由渲染主线程处理。渲染主线程不能被阻塞。v8在执行一些需要等待其他线程处理返回的任务时不会等待。派发事件给对应线程,继续执行后续代码。待事件响应后,其他线程将其回调函数包装为宏任务并通过MOJO的方式与IO线程通信。IO线程将其放在一个名为消息队列的先进先出的数据结构中。在页面运行的过程中,不断的有新任务添加到消息队列中并等待渲染主线程的调度执行。这个无限循环的过程称之为事件循环

消息队列上的任务称之为宏任务。setTimeout比较特殊,它由单独的延时队列维护(实际是个hashmap),在每一次循环执行完消息队列中的宏任务。会检查延时队列是否有已经到时的任务,如果有全部执行。宏任务的缺点在于颗粒度比较大。不适用于实时性要求高的场景。为了权衡单个宏任务的执行效率与实时性,从而诞生了一种颗粒度更小、更精确的任务:微任务。微任务由V8控制调度,每一个宏任务会有一个微任务队列。在js全局执行上下文退出前,V8会逐个执行微任务队列里的任务。至此,该宏任务执行完成

最后

给大家留一个思考题:不同类型的宏任务有没有优先级呢?比如用户交互的优先级理论上应该要比定时器的宏任务优先级高吧,这个我们放在后面讨论,后面我们将结合具体例子利用performance面板从实战中真实的感受事件循环的过程

到这里,就是本篇文章的全部内容了

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论

参考文档

microtask

MessageLoop

How Blink works

Threading and Tasks in Chrome

Inside look at modern web browser