likes
comments
collection
share

对Scheduler调度器的理解

作者站长头像
站长
· 阅读数 60

Scheduler

概览

react16以前,react有以下急需解决的问题

  • 一次更新带来的长时任务会滞后浏览器的渲染任务,从而导致页面出现卡顿的情况

虽然react团队已经将diff算法的优先级降到了O(n),但对于频繁且复杂的更新任务时,依然会显得有心无力

所以在react团队带来了全新的调度机制,也就是我们下面要讲到的scheduler调度器。

更新流程

		触发更新 ----- scheduler调度 ----- reconciler协调 ----- render渲染 

当我们发起一次更新时,会经历以下三个阶段

  1. 任务被注册到scheduler进行调度
  2. 调度完成,选择一个任务进入协调阶段,如果协调阶段被“打断”,重新1步骤
  3. 当上面两个阶段都已经完成,进入render渲染阶段,此阶段不可暂停

在整个任务过程中scheduler就好像地铁的调度员,决定了哪一趟车先走,哪一趟车后走,哪一趟车第一个走,哪一趟车最后一个走,而这一切都是让用户和浏览器的交互变得更加流畅。

时间切片

我们首先来理解时间切片的概念,上面说到react遇到了很大的困境

一次更新带来的长时任务会滞后浏览器的渲染任务,从而导致页面出现卡顿的情况

如果一次用户带来的长任务是不可避免的,那一次任务我们是否可以分成几次来做了,当然是可以的,在此之前我们先来看在一帧的时间范围内,会做些什么事情了

执行时机

对Scheduler调度器的理解

概览一下,一帧中大概要以下这些事情

一个task(宏任务) -- 队列中全部job(微任务) -- requestAnimationFrame -- 浏览器重排/重绘 -- requestIdleCallback

如果我们要不影响浏览器的渲染,我们有两个选择

  • 在一帧渲染后的空闲时间内执行JS,看上去原生提供的requestIdleCallback非常适合
  • 为了不影响当前帧的渲染,注册宏任务,放入下帧执行****

react采用的是第二种方式,看起来第一种方式很不错,为什么没有采用了,因为requestIdleCallback虽然看起来很美好,但却藏着几个无法忽视的缺点,而第二种方式其实对requestIdleCallback的polyfill。

  • 兼容性一般
  • requestIdleCallback并不是每帧都会触发,FPS只有20ms,正常情况下渲染一帧时长控制在16.67ms (1s / 60 = 16.67ms)。该时间是高于页面流畅的诉求

你可以点击这里看到scheduler中如何实现

通过代码可以到在scheduler中其实是使用了MessageChannel原生API来实现的,如果MessageChannel并不支持,会降级到setTimeout来执行;MessageChannelsetTimeout都执行的宏任务,可是setTimeout在启动的开始会有4ms的浪费,这在一帧中属于极大的开销,所以只能属于降级处理。

    const channel = new MessageChannel();
    const port = channel.port2;
    channel.port1.onmessage = performWorkUntilDeadline;

执行时长

执行时机说到为了不影响当前帧的渲染,我们会将真正的执行操作通过MessageChannel放到一个事件循环中,虽然这样可以避免当前帧的渲染卡顿,如果是过长的任务依然会阻塞下一帧的渲染,所以scheduler给每一个任务都分配了固定的执行时间,当执行时间结束的时候,需要交还控制权给浏览器

    forceFrameRate = function(fps) {
      if (fps < 0 || fps > 125) {
        console['error'](
          'forceFrameRate takes a positive int between 0 and 125, ' +
            'forcing frame rates higher than 125 fps is not supported',
        );
        return;
      }
      if (fps > 0) {
        yieldInterval = Math.floor(1000 / fps);
      } else {
        // reset the framerate
        yieldInterval = 5;
      }
    };

通过代码可以看到scheduler会根据当前设备的刷新率来确定一帧执行的时间,默认的时间为5ms,如果刷新率太高或者太低都会进入默认时长

如何交替控制权

关于scheduler如何与浏览器交替控制权,在我们讲完scheduler的整个流程最后来解答,你也可以点击这里直接跳到最后查看

队列

假设现在有很多任务在scheduler中,那么这些任务会分别存在在两个队列中,分别是

  • timerQueue
  • taskQueue

timerQueue存在的是延迟执行的任务,这里可以理解成任务优先级并不高的任务,taskQueue存在的是即将执行的任务,这里可以理解成优先级很高的任务,需要马上去执行。

小顶堆

scheduler中使用小顶堆的数据结构来实现优先级队列

你可以点击查看scheduler中实现的代码

我们这里在这里只需要记住以下几点和方法就可

  • 小顶堆的左右节点都小于它
  • push
  • pop
  • peek
  • siftUp
  • siftDown

优先级的概念

我们必须明确的是,在scheduler中优先级的概念和React中的优先级概念并不相同,两者进行交换的时候需要转换为对方的优先级

  export const NoPriority = 0; // 无任何优先级
  export const ImmediatePriority = 1; // 立即执行,优先级最高,Sync模式采用这种优先级进行调度
  export const UserBlockingPriority = 2; // 用户阻塞,用户操作引起的调度任务采用该优先级调度
  export const NormalPriority = 3; // 默认的优先级
  export const LowPriority = 4; // 低优先级
  export const IdlePriority = 5; // 优先级最低,闲置的任务

流程

在整个scheduler的过程中,我们将其划分为两个阶段

  • 调度阶段
  • 执行阶段

调度阶段我们需要从现存的任务中找到优先级最高的任务,并放入执行阶段,在执行阶段中我们需要对当前任务进行控制,具体表现为

  • 任务过长时暂停和恢复该任务
  • 有更高优先级来时替换该任务

调度阶段

ScheduleCallback

scheduleCallbackreactscheduler进行交互的重要方法,也是进入scheduler的入口函数,react中的任务通过该方法进入到scheduler中,在后面的流程中开启调度过程。

// priorityLevel 传入的优先级
// callback 执行的操作
// options-delay 延迟执行的时间

function unstable_scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: Callback,
  options?: {delay: number},
): Task {

  // 获取当前时间
  var currentTime = getCurrentTime();
  
  // 通过options中delay参数来确定是否任务开始时间
  var startTime;
  if (typeof options === 'object' && options !== null) {
    var delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }
  
  // ===== 根据优先级定义过期时间 =====
  var timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;
      break;
  }

  var expirationTime = startTime + timeout;
  // ===== end =====
  
  // 定义一个任务
  var newTask: Task = {
    id: taskIdCounter++,      // 任务id taskIdCounter为一个自增变量
    callback,                 // 执行的任务 传入回调函数
    priorityLevel,            // 任务优先级
    startTime,                // 开始时间
    expirationTime,           // 过期时间
    sortIndex: -1,            // 排序的索引
  };
  
  // 性能检测相关 不管
  if (enableProfiling) {
    newTask.isQueued = false;
  }
  
  // 如果任务并不是现在执行的任务 放入timerQueue队列中
  if (startTime > currentTime) {
    // This is a delayed task.
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    // 如果taskQueue中并没有待执行的任务而新任务刚好是timerQueue中的第一个
    // 开启一个调度流程
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // 如果有正在被调度的延时任务,取消它,因为当前注册的任务为更优先级的任务
			if (isHostTimeoutScheduled) {
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      // 开启一个延时任务调度
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // 如果当前任务为即刻任务
    newTask.sortIndex = expirationTime;
    // 首先推入到taskQueue中
    push(taskQueue, newTask);
    // 如果当前没有正在被调度的即刻任务 & 也没有正在执行的任务 立即执行
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }

  return newTask;
}

通过代码我们可以看到,scheduleCallback相当于一个注册方法,是调度一个事件的起点,它做了如下的几件事情

  1. 通过配置定义此次事件的开始时间startTime
  2. 根据事件的不同优先级分配设定过期时间生成expirationTime
  3. 创建一个任务对象newTask
  4. 判断当前任务是否延迟进行,如果延迟执行,放入timerQueue并判断timerQueue是否为空,如果为空,利用延时器调度该任务;如果不延迟执行,判断当前是否有正在进行的任务,如果没有,利用requestHostCallback开启调度

requestHostTimeout

scheduleCallback中我们看到如果进入延时任务的判断逻辑中,会进入requestHostTimeOut函数中

  requestHostTimeout = function(callback, ms) {
      taskTimeoutID = setTimeout(() => {
        callback(getCurrentTime());
      }, ms);
  };

可以看到requestHostTimeOut其实就是对setTimeout的封装,并传入当前事件进入callback,总结下来requestHostTimeOut的最主要作用

  1. 注册延时队列中优先级最高的任务
  2. 该任务会在定时器到期后进入taskQueue

handleTimeOut

    function handleTimeout(currentTime) {
      // 此次表明timerQueue的调度已经完成
      // 标志位 标记这次延时任务的调度已经完成
      isHostTimeoutScheduled = false;
      // 取出timerQueue中优先级最高的任务并放入taskQueue中
      // 每次进行一次任务调度前 都会判断timerQueue中是否有已经快过期的任务
      advanceTimers(currentTime);
      // 如果当前没有任务调度
      if (!isHostCallbackScheduled) {
        // 判断当前即刻任务队列中是否有任务
        if (peek(taskQueue) !== null) {
          isHostCallbackScheduled = true;
          // 立即开始一次任务调度
          requestHostCallback(flushWork);
        } else {
          // 如果当前有即刻任务正在被调度 & 顺便开启一个延时任务的调度
          const firstTimer = peek(timerQueue);
          // 如果有利用requestHostTimeout注册
          if (firstTimer !== null) {
            requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
          }
        }
      }
   }

从代码中可以看出handleTimeout是对调度结束的延时任务进行了后续处理,它主要做了以下几件事

  1. 标记此次调度结束,并利用advanceTimers找到timerQueue中优先级最高的任务并放入taskQueue
  2. 如果当前没有即刻任务正在被调度,判断当前是否有优先级最高的任务,如果有则立即开启即刻任务的调度执行requestHostCallback(flushWork),如果没有就从timerQueue中判断是否有待被调度的任务,如果有进入到延时任务的调度中

不难看到handleTimeout对延时任务的处理最终进入到requestHostCallback(flushWork)的流程中,在介绍requestHostCallback方法之前,我们需要先认识一下advanceTimers方法

advanceTimers

    function advanceTimers(currentTime) {
      // 选出timerQueue中优先级最高的任务并且添加到taskQueue中
      let timer = peek(timerQueue);
      while (timer !== null) {
        // 判断这个任务的回调函数是否为空,如果为空直接取消该任务
        // 不能接受一个没有回调函数的任务
        if (timer.callback === null) {
          pop(timerQueue);
        } else if (timer.startTime <= currentTime) {
          // 如果开始时间小于当前时间 说明任务已经过期需要立刻被执行
          // 将其从timerQueue中取出,放入taskQueue中
          pop(timerQueue);
          timer.sortIndex = timer.expirationTime;
          push(taskQueue, timer);
        } else {
          // 是个延时任务,还没有到执行时间,继续pending
          return;
        }
        // 如果不满足条件,继续循环
        timer = peek(timerQueue);
      }
  }

通过代码我们可以看到advanceTimers的主要目的是为了在timerQueue找到优先级最高并且能用的任务

requestHostCallback

在分析handleTimeout这个方法的时候,会发现调用完advanceTimers函数后会调用requestHostCallback这个方法,所以requestHostCallback方法到底有什么作用了

    // 设置任务
    requestHostCallback = function(callback) {
        // 设置scheduledHostCallback为将要执行的函数
        // *注意 这里的callback其实就是指代的flushWork 因为使用的时候requestHostCallback(flushWork)
        scheduledHostCallback = callback;
        // 开启新一轮的消息队列循环
        if (!isMessageLoopRunning) {
          isMessageLoopRunning = true;
          // messageChannel通知,将在下次循环的宏任务中执行scheduledHostCallback
          port.postMessage(null);
        }
    };
    // 取消任务
    cancelHostCallback = function() {
        scheduledHostCallback = null;
    };

可以看到代码其实非常简单,主要做了以下几件事情

  • 设置全局参数scheduledHostCallbackflushWork
  • 设置标记位isMessageLoopRunning为true,开始新一轮的消息队列循环
  • 触发port.postMessage,待执行事件将会在下个事件循环开始

requestHostCallback可以看成调度阶段的结束,因为这时候已经选出了优先级最高的任务,开启消息循环,待执行的任务将会在下个事件循环开始,开始执行阶段的过程,从这里我们也可以总结出以下

  • 所有任务执行的起点是requestHostCallback

执行阶段

上文讲到通过requestHostCallback开启了一个任务的执行流程,而任务真正的执行则是在下一轮事件循环中,而在事件循环中被触发的方法就是performWorkUntilDeadline

performWorkUntilDeadline

    
    const performWorkUntilDeadline = () => {
      // 在requestHostCallback里面注册的flushWork
      // scheduledHostCallback === flushWork
      if (scheduledHostCallback !== null) {
        // 设置一个当前时间
        const currentTime = getCurrentTime();
        // 确定任务在一帧的执行时间
        // dealline现在还看不到作用,但是在后面会作为判断的依据
        deadline = currentTime + yieldInterval;
        // 设置是否有剩余为true
        const hasTimeRemaining = true;
        try {
          // 执行注册的任务
          const hasMoreWork = scheduledHostCallback(
            hasTimeRemaining,
            currentTime,
          );
          // 判断 1. 如果还有多的任务 设置任务周期继续执行
          // 判断 2. 没有多的任务 结束任务周期 重设标志位 
          if (!hasMoreWork) {
            isMessageLoopRunning = false;
            scheduledHostCallback = null;
          } else {
            // If there's more work, schedule the next message event at the end
            // of the preceding one.
            port.postMessage(null);
          }
        } catch (error) {
          // If a scheduler task throws, exit the current browser task so the
          // error can be observed.
          port.postMessage(null);
          throw error;
        }
      } else {
        isMessageLoopRunning = false;
      }
      needsPaint = false;
    };

从方法的名字我们不难看住,performWorkUntilDeadline的执行一直到deadline截止,为此在performWorkUntilDeadline中做了下面的几件事

  • 判断scheduledHostCallback是否为空,为空结束,不为空继续
  • 确定deadline可以把这里的deadline当成在一帧中最大执行的时间,对应我们上面提到的时间切片
  • 执行scheduledHostCallback函数,返回一个hasMoreWork标记位,这里你可以把这个标记位当成任务是否完成的意思,如果返回true, 代表着任务并没有解析完成,于是利用port.postMessage(null)触发新一轮的消息循环,而我们通过上文知道

而在事件循环中被触发的方法就是performWorkUntilDeadline

所以相当于形成了一个循环,在没有结束的时候,在下一个事件循环继续没有完成的任务。

performWorkUntilDeadline的代码并不多,它更多的是流程控制,根据任务返回的结果决定做什么事情,真正执行的函数在scheduledHostCallback而我们在分析requestHostCallback讲到

设置全局参数scheduledHostCallbackflushWork

所以下一个流程我们需要进入flushWork

flushWork

function flushWork(hasTimeRemaining, initialTime) {
  // 正式进入执行过程 调度过程标志位关闭
  isHostCallbackScheduled = false;
  // 进入正式
  if (isHostTimeoutScheduled) {
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }
  // 设置执行的标志位
  isPerformingWork = true;
  const previousPriorityLevel = currentPriorityLevel;
  try {
    // 交由workLoop处理
    return workLoop(hasTimeRemaining, initialTime);
  } finally {
    // 复原之前的操作
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
  }
}

通过代码我们可以看到flushWork做的事情很少

  • 设置标记位标记执行要正式开始了,其实这里才代表着任务将会被真正的开始,设置标记位可以理解成上车前绑好的安全带,其余的事情都与我无关了

shouldYieldToHost

在介绍workLoop之前,我们先来熟悉一个非常重要的函数shouldYieldToHost

    shouldYieldToHost = function() {
      const currentTime = getCurrentTime();
      // deadline熟悉吗,我们在performWorkUntilDeadline任务开始执行前中设置了它
      // 如果超时
      if (currentTime >= deadline) {
        // 这里代表用户有更高优先级的任务进来,需要被打断
        if (needsPaint || scheduling.isInputPending()) {
          return true;
        }
        // 虽然超时了 但是没有更高优先级的任务 让子弹再飞一会儿
        return currentTime >= maxYieldInterval;
      } else {
        // 如果没有超时 告诉调用者该任务还可以继续进行
        return false;
      }
    };

虽然代码很简单,但这里却是scheduler让出控制权的关键,通过我们performWorkUntilDeadline设定的deadline进行判断

  • 已经超时,判断当前是否有更优先级的任务,如果有立即让出控制权;如果没有,耗时任务可以再执行一会儿知道超出最大的时间(这里可以理解成就算当前浏览器很空闲,但是超出了最大的时间也需要交还控制权)
  • 没有超时,无需交还控制权,继续执行

这里有个scheduling.isInputPendingapi,简单解释一下就是

isInputPending()是FackBook与Google合作在Chrome浏览器上加入的一个Scheduling API,也是第一个将中断这个操作系统概念用于网页开发的API,开发者可以使用这个API来平衡JS执行、页面渲染及用户输入之间的优先级,就像系统使用中断调度CPU处理IO输入一样。

你可以点击这里看到其详意

workLoop


function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {

    // 如果当前任务尚未过期 表明是一个延时任务 此刻并不需要执行
    // 通过shouldYieldToHost表明有更高优先级的任务或者时间切片已经 
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // This currentTask hasn't expired, and we've reached the deadline.
      break;
    }

    const callback = currentTask.callback;
    // 如果返回的是function说明解析并没有完成
    // task必须是个function 如果不是function的话 直接移出当前操作
    // 这里相当于一个暂停的操作
    
    if (typeof callback === 'function') {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      markTaskRun(currentTask, currentTime);
      // 开始执行执行操作
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      
      // 函数 任务并没有执行完
      // 不是函数 任务进行完

      // 如果返回的是个方法 将当前任务的回调依然标记为返回的函数
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback;
        markTaskYield(currentTask, currentTime);
      } else {
        // 如果不是的话 说明任务已经执行完成
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
      
      advanceTimers(currentTime);
    } else {
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
  }
  
  if (currentTask !== null) {
    return true;
  } else {
    // 如果已经没有当前的任务了 当前任务已经被执行 然后重新选择了一个更高优先级的任务替代
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

workLoop是一个非常重要的函数,我们可以把它理解成schedulerreact交互的集中地,在react中执行的函数是performConcurrentWorkOnRoot,对于这个函数我们现在并不多做了解,我们只需要知道这个函数的返回值

  • 如果返回的类型是一个Function是表示react中的任务并没有执行完
  • 如果返回的并不是Function表示任务已经完成

你可以点击这里看到performConcurrentWorkOnRoot的代码

我们接下来分析以下workLoop做了哪些事情

  • 判断任务是否是即刻任务 & 通过shouldYieldToHost函数来判断是否应该移交控制权
  • 如果需要移交控制权,结束判断,并返回true,这里的true其实代表着hasMoreWork,最终将返回到

performWorkUntilDeadline函数体内,进入performWorkUntilDeadline流程控制

流程图

对Scheduler调度器的理解

转载自:https://juejin.cn/post/7159939757554810911
评论
请登录