React任务调度器

1. 基本概念

调度器的作用

在React15及以前,React是没有任务调度器概念,当外部事件引发React产生渲染任务的时候,一旦开始执行,除了产生错误之外,会一直执行到结束。当应用比较庞大,往往会造成占用JS线程时间过长,导致浏览器渲染频率降低,从而导致浏览器掉帧等。

在React16+之后,React采用了调度器概念,调度器的作用是将用户所产生的所有React任务都设定一个优先级别,再交由调度器执行,调度器会根据用户设定的优先级别顺序执行任务,并且不会像之前一样一旦开始执行就必须执行到结束,而是将大任务切分成小任务,并且采用时间切片的方式利用空闲时间执行任务,避免因为长时间占用JS线程从而影响浏览器处理优先级别更加高的任务。

调度器基本概念

上面提到两个名词:优先级别、时间切片。分别解释一下这两个概念

  • 优先级别:有多种方式产生React任务,如用户操作、网络IO、定时器等等。用户对于这些任务延时的容忍度是不同的,耐心程度从高到低大概是:网络IO>定时器>用户操作。如果在短时间内产生这三种任务,那么处理的优先级别应当是用户操作>定时器>网络IO。如果两个任务同时产生,优先级别决定了先执行哪个任务。
  • 时间切片:React15前React Task要么不做,要么直接做完,并没有中断任务执行这一说法,也就是说粒度过大,往往会带来用户体验问题。而React16将任务细粒度降低,一个大任务拆分成多个小任务,在JS线程空闲的时候会产生事件执行这些任务,并且限定了每次空闲时候执行任务的时间(默认是5ms,如果超时会出让JS执行权给浏览器),这样能够很大程度避免浏览器掉帧现象。

下面是React16之后执行代码的Performance记录,图中可以发现在DragStart和DragEnd之间JS主线程有很多碎片化的任务,这些就是React采用时间切片,将较大块的任务拆解,并利用碎片化时间处理这些小任务:

image.png

2. 调度器实现原理

React调度器是按照浏览器的requestIdleCallback这个API实现的(因为兼容性原因没有直接使用该API),现在分别从存储结构以及调用方式讲解实现原理:

任务存储结构

每个React任务都有开始时间(StartTime)和任务到期时间(expirationTime),React调度器中使用两个优先队列存储任务,这两个队列分别是:TaskQueue、TimerQueue,前者存放即将执行的任务,后者则存放延时执行任务:

  • TaskQueue是以任务到期时间为优先级别排序依据,到期时间小的排在前面。在任务队列中的任务会不断执行(在任务执行规定时间内执行)。
  • TimerQueue中任务是以任务的开始时间(任务产生时间 + delay)为优先级别排序依据,在等待队列中的任务会采用setTimeout定时器,等到任务等待时间过后再放到任务队列中。

任务调用方式

上面讲到一个任务从注册到运行,可能会经历两个步骤:先放到等待队列中等待执行,等到时候到了放到任务队列中执行(说可能的原因是如果任务不设置delay属性则不会放到等待队列中,而是直接放到任务队列中),那么调度器分别采用什么方式执行这两个任务的呢?
答案是:

  • 采用setTimeout方式通知内部模块将等待队列中已经开始的任务放到任务队列中。这里采用setTimeout作为异步任务通知API应该没有什么异议。
  • 采用MessageChannel产生的异步任务通知任务队列的每次执行。

浏览器能够产生异步任务的有宏观任务和微观任务:

image.png

既然有那么多可以产生异步任务的方式,为什么只选择MessageChannel?

浏览器渲染是穿插在两个宏观任务之间的,如果采用微观任务异步执行任务,那么无法结束本次宏观任务,从而影响浏览器渲染页面。具体可以看深入解析 EventLoop 和浏览器渲染、帧动画、空闲回调的关系
所以异步调用只能从触发宏观任务API中找出,那么仅可以从以下三个API中选择产生异步任务(reqeustIdleCallback存在兼容性问题):

  1. setTimeout
  2. requestAnimationFrame
  3. MessageChannel

为什么选择MessageChannel?

  • setTimeout:setTimeout嵌套5层以上的时候,最少的定时时间为4ms(React希望任务尽快在空闲时候执行,所以要求定时时间为0ms),而React的任务会存在嵌套关系,所以明显该API不适合。
  • requestAnimationFrame:该API注册的事件会在浏览器渲染页面之前调用,如果浏览器渲染页面,那么是不会执行回调的,那么明显不符合“任务尽快在空闲时候执行”。

3. 调度器调度流程

任务入队

入队操作的时候,会判断任务是否延时,如果有delay属性(delay大于0),那么会计算其任务开始时间(startTime = 当前系统时间 + delay),以开始时间大小为顺序执行任务。如果没有delay属性,那么计算到期时间后直接放到任务队列中等待执行。

入队操作.gif

当任务没有delay属性的时候,直接进入任务队列等待执行,否则放到等待队列中等待delay时间过后再放到任务队列中。

等待队列任务入队

在不考虑任务队列执行的情况下(实际上每次从等待队列中将任务放到任务队列的时候,任务队列前的任务已经执行完毕了),具体的流程图如下:

动画2.gif

利用定时器setTimeout API,设定的定时时间:等待队列中第一个任务的startTime – 当前系统时间(这样触发回调的时候第一个任务已经开始了,便可以移动到任务队列中)。

任务队列执行

在此case下并不考虑任务从等待队列开始执行,只考虑任务队列的执行。在任务队列中存在4个任务,每个任务都有自己的花费时间和到期时间,并且TimeSlice片段大小为5ms。并且系统时间在第6-7ms期间浏览器有其他任务花费1ms,调度器运行情况如下:

动画3.gif

这个例子是比较典型的例子,有两个地方很特殊:

  1. 时间片段为5ms,也就是每5ms会出让js线程执行权,为什么等到第6ms秒的时候才出让线程控制权?

调度器执行任务的最小粒度是任务级别,该任务是以函数方式决定,而JS层面上无法中断函数执行(除非遇到异常或return),所以在第6ms的时候才交出线程控制权。

  1. 第10ms的时候,应当出让线程控制权,但是并没有出让控制权,反而是执行第4个任务?

第4个任务的到期时间是9ms,而系统时间是10ms,说明该任务是饥饿的,急需要执行,此时调度器是不会出让线程控制权的。也就是说Schedule在每个执行时间片段结束的标志是:当前队列第一个任务还没有过期并且占用线程时间超过设定值。

4. 思考

React Schdule执行任务的过程中可以出让程序控制权,很容易会想到ES6的Generator函数,该函数也实现这种功能,具体的代码如下:

const timeSliceSize = 5;
const channel = new MessageChannel();

function schedule(gen) {
  if (typeof gen === 'function') gen = gen();
  if (!gen || typeof gen.next !== 'function') return;
  let startTime = Date.now();
  
  return function next() {
    const res = gen.next();
    
    if (res.done) {
      return ;
    }
    
    if (Date.now() - startTime > timeSliceSize) {
      console.log('交出线程控制权');
      channel.port2.onmessage = () => {
        console.log('恢复线程控制权');
        startTime = Date.now();
        next();
      }
      channel.port1.postMessage(null);
    } else {
      next();
    }
  }
}

const tasks = [];

for (let i = 0; i < 5; i++) {
  tasks.push(() => {
    for (let j = 0; j < Math.pow(10, i); j++) {
      console.log('');
    }
    
    console.log(`task${i + 1}完成了`);
  });
}

function* task() {
  for (let i = 0; i < tasks.length; i++) {
    tasks[i]();
    yield;
  }
}

schedule(task)();
复制代码

执行结果如图:

image.png

说明使用Generator函数也是可以实现时间切片执行任务这个需求的,那么为什么不采用Generator函数呢?React官方给出了两个原因:

Fiber Principles: Contributing To Fiber · Issue #7942 · facebook/react

  1. 性能问题:采用Generator实现,那么需要每个传进来的task都是使用Generator函数包裹(包括其调用的子函数),而Generator函数开销比普通函数要大,这是因为Generator内部需要维护一个状态等,具体可以看一下async/await函数的ES5实现

  2. Generator每次执行是具有内部指针,其实可以理解为内部状态机状态,一旦执行就无法恢复到之前状态(除非重新执行Generator函数),他们希望Schedule是没有状态的。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享