希沃ENOW大前端
公司官网:CVTE(广州视源股份)
团队:CVTE旗下未来教育希沃软件平台中心enow团队
本文作者:
前言
关于时间片分片逻辑,或许我们大概都有所了解过,在React Fiber
中,使用RequestIdelCallback(rIC)
用来进行操作优化和时间分片。那么是否了解过具体是如何进行调度的?所谓的时分复用是什么?而这种调度思想是从哪发展而来的?对于我们开发者而言,有什么是可以借鉴的吗?
我们都知道在浏览器中,在主线程中,如果执行大量任务,会容易导致掉帧或者卡顿。而产生的原因是在浏览器中使用VSync
通知页面进行重新渲染,但是在JS的事件帧中,由于时间不够,导致任务阻塞重新渲染所致。人的眼睛大约每秒可以看到 60
帧,所以我们一般将fps=60
判断为用户体验是否优秀流畅的一个分水岭,一般fps<24
的话,用户就会感到卡顿,因为人眼识别主要为24
帧。
VSync信号
当一帧画面绘制完成后,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync
。显示器通常以固定频率进行刷新,这个刷新率就是 VSync
信号产生的频率。
浏览器刷新率(帧) 在浏览器中,一帧需要执行的任务有:
- 接受输入事件
- 执行事件回调
- 开始一帧
- 执行
RAF
(RequestAnimationFrame
) - 页面布局,样式计算
- 渲染
- 执行
RIC
(RequestIdelCallback
)
因此如果存在任务运行时间过长,则会阻塞下一帧任务执行,就会造成卡顿和掉帧的现象。
那么在计算机的世界里,存在相似的现象吗?
单核
在现代计算机内,一般会有多核/多CPU
架构存在。但是我们希望的是尽量压榨核心的性能,那么不妨考虑下极限场景下的优化,或者是说模拟浏览器中JS执行单线程的操作:只存在单核如何处理多进程。
那么如何提供有许多CPU
的假象呢?
时分共享
让一个进程只运行一个时间片,然后切换到其他进程,提供了存在多个虚拟CPU
的假象,这种做法称为时分共享。即允许资源有一个实体使用一小段时间,然后有另一个实体使用一小段时间,如此下去。
关注点分离
实际上,资源共享或者说切换进程需要消耗性能的,但是我们可以先将关注点放在如何实现共享上,让关注点分离。
正如CAP原则中,在一个分布式系统中,Consistency
(一致性)、 Availability
(可用性)、Partition tolerance
(分区容错性),三者不可得兼,通常只能取其二。但是由于我们关注点的不同,可以拆解开来,优先实现我们所需要关注的。
当然,我们能想到的最简单的方式实现就是类似于轮询。毫无意义的做切换工作,只要时间到了自然交给下一个。 单纯的时分共享实际上是属于NOOB CODE。
我们需要有更智能的策略——在操作系统内作出某种决定的算法。 首先:我们对操作系统中运行的进程作出如下的假设:
- 每一个工作运行相同的时间;
- 所有工作同时到达;
- 一旦开始,每个工作都保持运行直到完成;
- 所有工作都只用
CPU
; - 每个工作的运行时间是已知的。
并且我们为此引入一个性能指标:周转时间,并且计算公式为 T(周转时间) = T(完成时间) - T(到达时间)
先进先出(FIFO)
假如有三个工作A、B、C,分别执行10s,那么从线性单任务的角度来看,平均周转时间即为(10 + 20 + 30) / 3 = 20s。
那么我们不妨极端一点,位于后面的B、C任务分别执行1s,A任务执行了100s,那么整个的周转时间即为(100 + 110 + 120)/ 3 = 110s。
这个问题被称为护航效应:一些耗时较少的潜在资源被排在重量级的资源消费之后。
最短任务优先(SJF)
用时最短的任务优先执行,是不是就能解决这个问题了呢?
假如将上面的极端例子举例就会发现,平均周转时间变为(10 + 20 + 120)/ 3 = 50s。
在考虑所有任务同时到达的情况下,最短任务优先是最优的算法。但是在现实计算机世界中,我们无法确定下一个到达的任务是否是最短的任务,倘若需要等待的话,那么花费的时间可能也会远低于其他算法。
那么假如我们使用抢占式的方法会不会更好呢?
最短完成时间优先(STCF)
在第一个任务开始运行,后续的的两个任务到达,这时候我们开始计算最短完成时间,并且将最短完成时间的任务调度到最前面执行。
平均周转时间为(10 + 20 + 120)/ 3 = 50s。
可以得知在任务中,抢占式的最短完成时间优先(STCF)算法可以获得较好的平均周转时间收益。
是的,基于系统而言,最短完成时间优先是一个很好的策略。然而对于用户而言,我们的关注点应该是放在交互性上面。同样的,在前端中,我们用户会更关心的是你的程序什么时候运行结束吗?更关心的应该是交互过程是否流畅。所以我们需要一个新的度量标准:响应时间,T(响应时间) = T(首次运行) - T(到达时间)。
基于新的度量标准,我们会发现,对于最短完成时间优先算法并不友好:第三个任务必须等到前两个任务全部运行后才能运行。这对于用户体验来说无疑是糟糕的。
那么,我们如何构建对响应时间敏感的调度程序呢?
轮转(Round - Robin)
在一个时间片内运行一个任务,然后切换到运行队列中的下一个任务,而不是运行一个任务直到结束。操作系统的时间片长度是基于时钟中断(Timer Interrupt)的倍数而定。
时钟中断(Timer Interrupt)
从本质上说,时钟中断只是一个周期性的信号,完全是硬件行为,该信号触发CPU去执行一个中断服务程序,但是为了方便,我们就把这个服务程序叫做时钟中断。
以上面为例子: 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,是一个无法中断的方式 而新的调度方式Fiber Reconciler,则显得更为智能 在Fiber中,核心特性可以概括为:
- 大型任务的中断和可拆解;
- 利用时间片做时间切割;
- 任务执行过程中利用优先级的可抢占;
React Fiber
的运行时实际上就是RequestIdelCallback(rIC)
+ 优先级抢占(当然因为RequestIdelCallback
取决于设备的Vsync
信号发射频率,会造成不同设备间的差异,因此优先使用polyfill
,这个我们暂时不展开细讲)。
在使用VSync
信号进行分片的逻辑实际上跟时钟中断是一样,都是由硬件发出信号来指导逻辑触发。然后在每个时间片上做任务的拆分和优先级的调度。
类比系统的进程调度就是轮转(RR
)+ 最短完成时间优先(STCF
),只是将最短完成时间优先替换为业务需要的优先级,在单个时间片内,寻找最优先级的抢占式调度。
当然React Fiber
本身还存在其他的优化策略,例如超时机制、任务的可中断,挂起,恢复、Concurrent
模式等,我们在次不一一展开讨论。
实际上,无论是轮转或者是React Fiber
中的时间分片,完成任务执行时长都是大于最简单算法执行时长的(在不考虑I/O
的情况下和其他优化的情况下),因为在切换或者计算过程中会有消耗,但是基于关注点分离,我们可以将关注点聚焦在我们最迫切实现的功能上。
总结
由此,值得我们借鉴的思考是:对于大型任务,我们需要从自身的关注点出发,寻找相对合理的解决路径。可以进行合理的拆解,并使用更为智能的方式去执行单个的分解任务。并时刻站在巨人的肩膀上,看问题并寻找解决方案。
参考文章
- 《操作系统导论 - 虚拟化CPU》