React Fiber中的调度思想

avatar
Web前端 @CVTE_希沃

希沃ENOW大前端

公司官网:CVTE(广州视源股份)

团队:CVTE旗下未来教育希沃软件平台中心enow团队

本文作者:

王轩名片.png

前言

关于时间片分片逻辑,或许我们大概都有所了解过,在React Fiber中,使用RequestIdelCallback(rIC)用来进行操作优化和时间分片。那么是否了解过具体是如何进行调度的?所谓的时分复用是什么?而这种调度思想是从哪发展而来的?对于我们开发者而言,有什么是可以借鉴的吗?

我们都知道在浏览器中,在主线程中,如果执行大量任务,会容易导致掉帧或者卡顿。而产生的原因是在浏览器中使用VSync通知页面进行重新渲染,但是在JS的事件帧中,由于时间不够,导致任务阻塞重新渲染所致。人的眼睛大约每秒可以看到 60 帧,所以我们一般将fps=60判断为用户体验是否优秀流畅的一个分水岭,一般fps<24的话,用户就会感到卡顿,因为人眼识别主要为24帧。

VSync信号

当一帧画面绘制完成后,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。

浏览器刷新率(帧) image.png 在浏览器中,一帧需要执行的任务有:

  1. 接受输入事件
  2. 执行事件回调
  3. 开始一帧
  4. 执行RAF(RequestAnimationFrame)
  5. 页面布局,样式计算
  6. 渲染
  7. 执行RIC(RequestIdelCallback)

因此如果存在任务运行时间过长,则会阻塞下一帧任务执行,就会造成卡顿和掉帧的现象。

那么在计算机的世界里,存在相似的现象吗?

单核

在现代计算机内,一般会有多核/多CPU架构存在。但是我们希望的是尽量压榨核心的性能,那么不妨考虑下极限场景下的优化,或者是说模拟浏览器中JS执行单线程的操作:只存在单核如何处理多进程。

那么如何提供有许多CPU的假象呢?

时分共享

让一个进程只运行一个时间片,然后切换到其他进程,提供了存在多个虚拟CPU的假象,这种做法称为时分共享。即允许资源有一个实体使用一小段时间,然后有另一个实体使用一小段时间,如此下去。

关注点分离

实际上,资源共享或者说切换进程需要消耗性能的,但是我们可以先将关注点放在如何实现共享上,让关注点分离。 正如CAP原则中,在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼,通常只能取其二。但是由于我们关注点的不同,可以拆解开来,优先实现我们所需要关注的。

当然,我们能想到的最简单的方式实现就是类似于轮询。毫无意义的做切换工作,只要时间到了自然交给下一个。 单纯的时分共享实际上是属于NOOB CODE。

image.png

我们需要有更智能的策略——在操作系统内作出某种决定的算法。 首先:我们对操作系统中运行的进程作出如下的假设:

  1. 每一个工作运行相同的时间;
  2. 所有工作同时到达;
  3. 一旦开始,每个工作都保持运行直到完成;
  4. 所有工作都只用CPU
  5. 每个工作的运行时间是已知的。

并且我们为此引入一个性能指标:周转时间,并且计算公式为 T(周转时间) = T(完成时间) - T(到达时间)

先进先出(FIFO)

假如有三个工作A、B、C,分别执行10s,那么从线性单任务的角度来看,平均周转时间即为(10 + 20 + 30) / 3 = 20s。

那么我们不妨极端一点,位于后面的B、C任务分别执行1s,A任务执行了100s,那么整个的周转时间即为(100 + 110 + 120)/ 3 = 110s。

这个问题被称为护航效应:一些耗时较少的潜在资源被排在重量级的资源消费之后。 image.png

最短任务优先(SJF)

用时最短的任务优先执行,是不是就能解决这个问题了呢?

假如将上面的极端例子举例就会发现,平均周转时间变为(10 + 20 + 120)/ 3 = 50s。

image.png 在考虑所有任务同时到达的情况下,最短任务优先是最优的算法。但是在现实计算机世界中,我们无法确定下一个到达的任务是否是最短的任务,倘若需要等待的话,那么花费的时间可能也会远低于其他算法。

那么假如我们使用抢占式的方法会不会更好呢?

最短完成时间优先(STCF)

在第一个任务开始运行,后续的的两个任务到达,这时候我们开始计算最短完成时间,并且将最短完成时间的任务调度到最前面执行。

平均周转时间为(10 + 20 + 120)/ 3 = 50s。

可以得知在任务中,抢占式的最短完成时间优先(STCF)算法可以获得较好的平均周转时间收益。

image.png

是的,基于系统而言,最短完成时间优先是一个很好的策略。然而对于用户而言,我们的关注点应该是放在交互性上面。同样的,在前端中,我们用户会更关心的是你的程序什么时候运行结束吗?更关心的应该是交互过程是否流畅。所以我们需要一个新的度量标准:响应时间T(响应时间) = T(首次运行) - T(到达时间)。

基于新的度量标准,我们会发现,对于最短完成时间优先算法并不友好:第三个任务必须等到前两个任务全部运行后才能运行。这对于用户体验来说无疑是糟糕的。

那么,我们如何构建对响应时间敏感的调度程序呢?

轮转(Round - Robin)

在一个时间片内运行一个任务,然后切换到运行队列中的下一个任务,而不是运行一个任务直到结束。操作系统的时间片长度是基于时钟中断(Timer Interrupt)的倍数而定。

时钟中断(Timer Interrupt)

从本质上说,时钟中断只是一个周期性的信号,完全是硬件行为,该信号触发CPU去执行一个中断服务程序,但是为了方便,我们就把这个服务程序叫做时钟中断。

image.png

以上面为例子: RR的平均响应时间为(0 + 1 + 2)/ 3 = 1s;SJF算法的平均响应时间是(5 + 10 + 15)/ 3 = 5s 在响应时间上RR具有更优秀的表现。

那么如果照上面的算法,是不是时间片越短越好呢,还是以上图为例子,假如把时间片缩短到0.5,那么RR的平均响应时间就会是 (0 + 0.5 + 1)/ 3 = 0.5s !

是的,假如从理论上来说,确实如此。不过放到实际中,在进程切换过程中,实际上是有切换成本的,因此我们需要权衡时间片的长度,用来摊销上下文切换成本。

前面大致说了计算机中的进程调度,不妨回过头看下React Fiber在运行时的调度设计。

React Fiber中的调度

以前的React是线性执行任务,从原生执行栈递归遍历VDOM。在执行栈中压入和弹出任务,实际上就是前面说的先进先出的方式。

Stack Reconciler,是一个无法中断的方式 image.png 而新的调度方式Fiber Reconciler,则显得更为智能 image.png 在Fiber中,核心特性可以概括为:

  1. 大型任务的中断和可拆解;
  2. 利用时间片做时间切割;
  3. 任务执行过程中利用优先级的可抢占;

React Fiber的运行时实际上就是RequestIdelCallback(rIC)+ 优先级抢占(当然因为RequestIdelCallback取决于设备的Vsync信号发射频率,会造成不同设备间的差异,因此优先使用polyfill,这个我们暂时不展开细讲)。

在使用VSync信号进行分片的逻辑实际上跟时钟中断是一样,都是由硬件发出信号来指导逻辑触发。然后在每个时间片上做任务的拆分和优先级的调度。

类比系统的进程调度就是轮转(RR)+ 最短完成时间优先(STCF),只是将最短完成时间优先替换为业务需要的优先级,在单个时间片内,寻找最优先级的抢占式调度。 ​

当然React Fiber本身还存在其他的优化策略,例如超时机制、任务的可中断,挂起,恢复、Concurrent模式等,我们在次不一一展开讨论。

实际上,无论是轮转或者是React Fiber中的时间分片,完成任务执行时长都是大于最简单算法执行时长的(在不考虑I/O的情况下和其他优化的情况下),因为在切换或者计算过程中会有消耗,但是基于关注点分离,我们可以将关注点聚焦在我们最迫切实现的功能上。

总结

由此,值得我们借鉴的思考是:对于大型任务,我们需要从自身的关注点出发,寻找相对合理的解决路径。可以进行合理的拆解,并使用更为智能的方式去执行单个的分解任务。并时刻站在巨人的肩膀上,看问题并寻找解决方案。

参考文章

  • 《操作系统导论 - 虚拟化CPU》