课外加餐 2 | 任务调度:有了 setTimeOut,为什么还要使用 rAF?

前言:该篇说明:请见 说明 —— 浏览器工作原理与实践 目录

 

  都知道,要想利用 JS 实现高性能的动画,那就得使用 requestAnimationFrame 这个 API,简称 rAF,那为什么都推荐使用 rAF 而不是 setTimeout 呢?

  要解释清楚这个问题,就要从渲染进程的任务调度系统讲起,理解了渲染进程任务调度系统,自然就明白了 rAF 和 setTimeout 的区别。其次,如果理解了任务调度系统,就能将渲染流水线和浏览器架构等知识串起来,理解了这些概念也有助于理解 Performance 标签是如何工作的。

  要想了解最新 Chrome 的任务调度系统是如何工作的,就得先来回顾下之前说的消息循环系统。渲染进程内部的大多数任务都是在主线程上的,诸如 JS 执行、DOM、CSS、计算布局、V8 的垃圾回收等任务。要让这些任务能够在主线程上有条不紊的运行,就需要引入消息队列

  在《16 | WebAPI:setTimeout 是如何实现的?》一文中介绍:主线程维护了一个普通的消息队列和一个延迟消息队列,调度模块会按照规则依次取出这两个消息队列中的任务,并在主线程上执行。为了下文讲述方便,在这里把普通的消息队列和延迟队列都当成一个消息队列。

  新的任务都是被放进消息队列中去的,然后主线程再依次从消息队列中取出这些任务来顺序执行。这就是之前介绍的消息队列和事件循环系统(消息队列和事件循环系统又忘的差不多了,还需要回顾之前的知识。)

 

单消息队列的队头阻塞问题

  渲染主线程会按照先进先出的顺序执行消息队列中的任务,具体地讲,当产生了新的任务,渲染进程会将其添加到消息队列尾部,在执行任务过程中,渲染进程会顺序地从消息队列头部取出任务并依次执行。

  在最初,采用这种方式并没有太大的问题,因为页面中的任务还不算太多,渲染主线程也不是太繁忙。不过浏览器是向前不断进化的,其进化路线体现在架构的调整、功能的增加以及更加精细的优化策略等方面,这些变化让渲染进程所需要处理的任务变多了,对应的渲染进程的主线程也变得越拥挤。下图展示的仅仅是部分运行在主线程上的任务:

任务和消息队列

  试想下,在基于这种单消息队列的架构下,如果用户发出一个点击事件或缩放页面的事件,而在此时,该任务前面可能还有很多不太重要的任务在排队等待着被执行,诸如V8 的垃圾回收、DOM 定时器等任务,如果执行这些任务需要花费的时间过久的话,那就会让用户产生卡顿的感觉。参考下图:

队头阻塞问题

  因此,在单消息队列架构下,存在着低优先级任务会阻塞高优先级任务的情况,比如在一些性能不高的手机上,有时候滚动页面需要等待一秒以上。这像极了在介绍 HTTP 协议时所谈论的队头阻塞问题,那我们也把这个问题称为消息队列的队头阻塞问题吧。

 

Chromium 是如何解决队头阻塞问题的?

  为了解决由于单消息队列而造成的队头阻塞问题,Chromium  团队从 2013 年到现在,花了大量的精力在持续重构底层消息机制。在接下来的篇幅里,会按照 Chromium 团队的重构消息系统的思路来分析下他们是如何解决队头阻塞的问题。

1. 第一次迭代:引入一个高优先级队列

  首先在最理想的情况下,我们希望能够快速跟踪高优先级任务,比如在交互阶段,下面几种任务都应该视为高优先级的任务:

  • 通过鼠标触发的点击任务、滚动页面任务;
  • 通过手势触发的页面缩放任务;
  • 通过 CSS、JS 等操作触发的动画特效等任务。

  这些任务被触发后,用户想立即得到页面的反馈,所以需要让这些任务能够优先与其他的任务执行。要实现这种效果,可以增加一个高优先级的消息队列,将高优先级的任务都添加到这个队列中,然后优先执行该消息队列中的任务。如下:

 

 引入高优先级的消息队列

  观察上图,使用了一个优先级高的消息队列和一个优先级低的消息队列,渲染进程会将它认为是紧急的任务添加到高优先级队列中,不紧急的任务就添加到低优先级的队列中。然后再在渲染进程中引入一个任务调度器,负责从多个消息队列中选出合适的任务,通常实现的逻辑,先按照顺序从高优先级队列中取出任务,如果高优先级的队列为空,那么再按照顺序从低优先级队列中取出任务。

  还可以进一步,将任务划分为多个不同的优先级,来实现更加细粒度的任务调度,比如可以划分为高优先级,普通优先级和低优先级,如下:

增加多个不同优先级的消息队列

  观察上图,实现了三个不同优先级的消息队列,然后可以使用任务调度器来统一调度这三个不同消息队列中的任务。

  好了,现在引入了多个消息队列,结合任务调度器就可以灵活的调度任务了,这样就可以让高优先级的任务提前执行,采用这种方式似乎解决了消息队列的队头阻塞问题。

  不过大多数任务需要保持其相对执行顺序,如果将用户输入的消息或合成消息添加进多个不同优先级的队列中,那么这种任务的相对执行顺序就会被打乱,甚至有可能出现还未处理输入事件,就合成了该事件要显示的图片。因此需要让一些相同类型的任务保持其相对执行顺序

  问题:大多数任务需要保持其相对执行顺序。要不然有可能会出现还未处理输入事件,就合成了该事件要显示的图片。

2. 第二次迭代:根据消息类型来实现消息队列

  要解决上述问题,可以为不同类型的任务创建不同优先级的消息队列,比如:

  • 可以创建输入事件的消息队列,用来存放输入事件。
  • 可以创建合同任务的消息队列,用来存放合成事件。
  • 可以创建默认消息队列,用来保存如资源加载的事件和定时器回调等事件。
  • 还可以创建一个空闲的消息队列,用来存放 V8 的垃圾自动回收这一类实时性不高的事件。

最终实现效果如下:

根据消息类型实现不同优先级的消息队列

  通过迭代,这种策略已经相当实用了,但是它依然存在着问题,那就是这几种消息队列的优先级都是固定的,任务调度器会按照这种固定好的静态的优先级来分别调度任务。那么静态优先级会带来什么问题呢?

  在《25 | 页面性能:如何系统地优化页面?》一文中分析过页面的生存周期,页面大致的生存周期分为两个阶段:加载阶段交互阶段

  虽然在交互阶段,采用上述这种静态优先级的策略没有什么太大问题的,但是在页面加载阶段,如果依然要优先执行用户输入事件和合成事件,那么页面的解析速度将会被拖慢。Chromium 团队曾测试过这种情况,使用静态优先级策略,网页的加载速度会被拖慢 14%。

  问题:静态优先级策略。交互阶段没什么问题,但在页面加载阶段,如果依然要优先执行用户输入事件和合成事件,那页面的解析速度会被拖慢。

3. 第三次迭代:动态调度策略

  可以看出,所采用的优化策略像个跷跷板,虽然优化了高优先级任务,却拖慢低优先级任务,之所以会这样,是因为采取了静态的任务调度策略,对于各种不同的场景,这种静态策略就显得过于死板。所以还得根据实际场景来继续平衡这个跷跷板,也就是说在不同的场景下,根据实际情况,动态调整消息队列的优先级。参考下图:

动态调度策略

  这张图展示了 Chromium 在不同的场景下,是如何调整消息队列优先级的,通过这种动态调度策略,就可以满足不同场景的核心诉求了,同时这也是 Chromium 当前所采用的任务调度策略。

  上图列出了三个不同的场景,分别是加载过程,合成过程以及正常状态。下面就结合这三种场景,来分析下 Chromium 为何做这种调整。

 

  首先来看看页面加载阶段的场景,在这个阶段,用户的最高诉求是在尽可能短的时间内看到页面,至于交互和合成并不是这个阶段的核心诉求,因此我们需要调整策略,在加载阶段将页面解析,JS 脚本执行等任务调整为优先级最高的队列,降低交互合成这些队列的优先级。

  页面加载完成之后就进入了交互阶段,在介绍 Chromium 是如何调整交互阶段的任务调度策略之前,还需要回顾下页面的渲染过程。

  在《06 | 渲染流程(下)》和 《24 | 分层和合成机制》中分析了一个页面是如何渲染并显示出来的。

  在显卡中有一块叫 前缓冲区 的地方,这里存放着显示器要显示的图像,显示器会按照一定的频率来读取这块前缓冲区,并将前缓冲区中的图像显示在显示器上,不同的显示器读取的频率是不同的,通常情况下是 60HZ,也就是说显示器会每隔 1/60 秒就读取一次前缓冲区。

  如果浏览器要更新显示的图片,那么浏览器会将新生成的图片提交到显卡的后缓冲区中,提交完成后,GPU 会将后缓冲区和前缓冲区互换位置,也就是前缓冲区变成后缓冲区,后缓冲区变成前缓冲区,这就保证了显示器下次能读取到 GPU 中最新的图片。

  这时候会发现,显示器从前缓冲区读取图片,和浏览器生成新的图像到后缓冲区的过程是不同步的,如下:

VSync 时钟周期和渲染引擎生成图片不同步问题

这种显示器读取图片和浏览器生成图片不同步,容易造成众多问题。

  • 如果渲染进程生成的帧速比屏幕的刷新率慢,那么屏幕会在两帧之间显示同一个画面,当这种断断续续的情况持续发生时,用户将会很明显地察觉到动画卡住了。
  • 如果渲染进程生成的帧速率实际上比屏幕刷新率快,那么也会出现一些视觉上的问题,比如当帧数率在 100fps 而刷新率只有 60HZ 的时候,GPU 所渲染的图像并非全都被显示出来,这就会造成丢帧现象。
  • 就算屏幕的刷新频率和 GPU 更新图片的频率一样由于它们是两个不同的系统,所以屏幕生成帧的周期和 VSync 的周期也是很难同步起来的。

所以 VSync 和系统的时钟不同步就会造成掉帧、卡顿、不连贯等问题。

 

  为了解决这些问题,就需要将显示器的时钟同步周期和浏览器生成页面的周期绑定起来,Chromium 也是这样实现的,那么下面就来看看 Chromium 具体是如何实现的?

  当显示器将一帧画面绘制完成后,并在准备读取下一帧之前,显示器会发出一个垂直同步信号(vertical synchronization)给 GPU,简称VSync。这时候浏览器就会充分利用好 VSync 信号。

  具体地讲,当 GPU 接收到 VSync 信号后,会将 VSync 信号同步给浏览器进程,浏览器进程再将其同步到对应的渲染进程,渲染进程接收到 VSync 信号之后,就可以准备绘制新的一帧了,具体流程参考下图:

绑定 VSync 时钟同步周期和浏览器生成页面周期

   上面其实是非常粗略的介绍,实际实现过程也是非常复杂的,如果感兴趣可以参考:这篇文章(该链接我自己是打不开的)

  好了,介绍了 VSync 和页面中的一帧是怎么显示出来,有了这些知识就可以回到主线了,来分析下渲染进程是如何优化交互阶段页面的任务调度策略的?

  从上图可以看出,当渲染进程接收到用户交互的任务后,接下来大概率是要进行绘制合成操作,因此可以设置,当在执行用户交互的任务时,将合成任务的优先级调整到最高

  接下来处理完成 DOM,计算好布局和绘制,就需要将信息提交给合成线程来合成最终图片了,然后合成线程进入工作状态。现在的场景是合成线程在工作了,那么就可以把下个合成任务的优先级调整为最低,并将页面解析、定时器等任务优先级提升。

  在合成完成后,合成线程会提交给渲染主线程提交完成合成的消息,如果当前合成操作执行的非常快,比如从用户发出消息到完全合成操作只花了 8 毫秒,因为 VSync 同步周期是 16.66(1/60)毫秒,那么这个 VSync 时钟周期内就不需要再次生成新的页面了。那么从合成结束到下个 VSync 周期内,就进入了一个空闲时间阶段,那么就可以在这段空闲时间内执行一些不那么紧急的任务,比如 V8 的垃圾回收,或者通过 window.requestIdleCallback() 设置的回调任务等,都会在这段空闲时间内执行。

  问题: 任务饿死。一直有新的高优先级的任务加入到队列中,会导致其他低优先级的任务得不到执行。 解决方法:给每个队列设置 执行权重。

 

4. 第四次迭代:任务饿死

  以上方案看上去似乎非常完美了,不过依然存在一个问题,那就是在某个状态下,一直有新的高优先级的任务加入到队列中,这样就会导致其他低优先级的任务得不到执行,这称为任务饿死。

  Chromium 为了解决任务饿死的问题,给每个队列设置了执行权重,也就是如果连续执行了一定个数的高优先级的任务,那么中间会执行一次低优先级的任务,这样就缓解了任务饿死的情况。

 

总结

  本文的主要内容:

  首先分析了基于单消息队列会引起队头阻塞的问题,为了解决队头阻塞问题,引入了多个不同优先级的消息队列,并将紧急的任务添加到高优先级队列,不过大多数任务需要保持其相对执行顺序,如果将用户输入的消息或合成消息添加进多个不同优先级的队列中,那么这种任务的相对执行顺序就会被打乱,所以又迭代了第二个版本。

  在第二个版本中,按照不同的任务类型来划分任务优先级,不过由于采用的静态优先级策略,对于其他一些场景,这种静态调度的策略并不是太适合,所以接下来,又迭代了第三版。

  第三个版本,基于不同的场景来动态调整消息队列的优先级,到了这里已经非常完美了,不够依然存在着任务饿死的问题,为了解决任务饿死的问题,我们给每个队列一个权重,如果连续执行了一定个数的高优先级的任务,那么中间会执行一次低优先级的任务,这样就完成了 Chromium 的任务改造。

  通过整个过程的分析应该能理解,在开发一个项目时,不要试图去找最完美的方案,完美的方案往往是不存在的,我们需要根据实际的场景来寻找最适合我们的方案。

requestAnimationFrame 比起 setTimeout / setInterval 的优点:

  1、requestAnimationFrame 会把每一帧中的所有 DOM 操作 集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒 60 帧。

  2、在隐藏或不可见的元素中,requestAnimationFrame 将不会进行重绘或回流,这就意味着更少的CPU、GPU 和 内存使用量。

 

思考题

  我们知道 CSS 动画是由渲染进程自动处理的,所以渲染进程会让 CSS 渲染每帧动画的过程与 VSync 的时钟保持一致,这样就能保证 CSS 动画的高效率执行。

  但是 JS是由用户控制的,如果采用 setTimeout 来触发动画每帧的绘制,那么其绘制时机是很难和 VSync 时钟保持一致的,所以 JS 中又引入了 window.requestAnimationFrame,用来和 VSync 的时钟周期同步。

  问题:你知道 requestAnimationFrame 回调函数的执行时机吗?

 

1. 用 JS 实现一个无限循环的动画。

 

参考资料

下面是作者参考的一些资料:(反正我是一个都打不开)

 

记录

1、window.requestAnimationFrame 应该是在每一帧的开始就执行吧?

作者回复:应该说 raf 的回调任务会在每一帧的开始执行

 

2、如果 raf 的回调任务会在每一帧的开始执行,如果它执行时间很长(超过一帧),那就会阻碍后面所有任务的执行么?比如说用户的交互事件等高优先级任务也会受到影响导致卡顿么?

我在网上看到的资料:为啥是先执行用户的交互任务,在执行 raf 的回调?

作者回复:会啊,一个任务在执行的时候是不会被中断的,即使有再高优先级的任务,都需要等到当前的任务执行结束,所以如果 raf 回调函数中的代码过于耗时的话,那么会影响渲染帧率!

  等当前任务执行结束,循环系统才会挑下个优先级高的任务执行,因为用户输入的优先级高于 raf 的回调,所以会优先执行用户输入。

 

3、老师,我想问下在 promise.then 中执行宏任务(setTimeout 或 ajax),其中该宏任务应该加入哪个事件队列。

是说微任务队列都是按顺序执行,其中每个微任务又有新的事件循环(包括宏任务和微任务),类似于新的全局环境,这样理解对吗?

作者回复:不管在哪里请求 setTimeout,它的回调函数都是在宏任务中执行的。不过在微任务中产生了新的微任务,新的微任务还是在当前的微任务队列中,所以如果在微任务中不停产生新的微任务,是会阻塞页面的!

 

4、老师的图其实已经给出了答案,VSync 的开始就会执行 RAF 的回调。

 

5、讲的有问题,RAF 的回调在微任务执行完成之后才会进行。

付伟超:感觉讲的没错,老师讲的是以一个 VSync 为起始点,先执行 RAF,再执行消息队列中的任务,再查询微任务消息队列执行微任务。

new Promise( resolve => {
    console.log('promise')
    resolve();
})

requestAnimationFrame( () => {
    console.log('frame')
})

setTimeout( () =>{
    console.log('timer')
}, 0)

Liber -> 付伟超 : 老师这是看着源码讲的,肯定是没问题的,我也看过Udacity上的老外的课程,也是说rAF的回调是在每个帧的开头执行的。 至于这个代码为啥是先输出promise,个人分析是因为我们的代码被执行到的时候已经不是这一帧的开头的时候了

syne -> Liber:肯定先输出promise啊,promise同步执行,requestAnimationFrame和setTimeout异步执行,优先取requestAnimationFrame然后再执行setTimeout

justorez -> 付伟超:promise executor 是同步执行的,你的这段代码根本没有微任务

Geek_2a1e86: 开始我也怀疑讲的有点问题,后来做了下实验发现讲的应该没错。raf的回调是在下一个vsync之后才开始第一个执行的。具体可以参考我写的在线代码 https://jsbin.com/rimijogoyu/edit?js,output

舔命难违:搜索了下,好像是的:requestAnimationFrame姑且也算是宏任务吧,requestAnimationFrame在MDN的定义为,下次页面重绘前所执行的操作,而重绘也是作为宏任务的一个步骤来存在的,且该步骤晚于微任务的执行

刘至:如果非要比较 rAF 、微任务、宏任务的执行顺序,分析如下: 1. 本次事件循环中微任务执行顺序优于 下次事件循环中的宏任务。 2.rAF的回调发生在下次重绘前,如果页面渲染未被阻塞,浏览器一般16.7ms 发生一次重绘。然而 js 执行会阻塞页面渲染,也就是说本次事件循环中的宏任务、微任务会优于下次事件循环中的 rAF。 3. 那么下次事件循环的宏任务 和 rAF回调的执行顺序呢,由于一次事件循环过程中未必发生页面重绘,相反一次页面重绘必定发生在一次事件循环中。 因此,如果下一次页面重绘发生在下一次的事件循环中,rAF回调的执行顺序优于 下次事件循环的宏任务,当然也优于 下次事件循环的微任务; 否则, 下次事件循环的宏任务优于 下次事件循环的微任务,更优于执行在下次重绘前 rAF的回调了。

 

posted on 2022-04-12 17:39  bala001  阅读(361)  评论(0编辑  收藏  举报

导航