blog icon indicating copy to clipboard operation
blog copied to clipboard

Scheduler 源码解析

Open mengxiong10 opened this issue 4 years ago • 0 comments

Scheduler 的作用

它实现的是一个任务调度器, 让任务可以在合适的时间执行,不阻塞渲染, 保证页面流畅

类似于浏览器自带的requestIdleCallback 回调函数会在浏览器空闲时间执行不影响渲染

虽然是空闲时间, 但是如果回调函数本身执行时间很长, 还是会阻塞渲染, 所以一般来说使用任务调度会先将执行时间长的大任务分成一个个执行时间比较短的小任务, 分散到不同的帧去调度执行

这也是React重构成Fiber的原因, 因为JSX天然的嵌套结构, 之前任务是递归执行无法将任务打断再继续, 重构成Fiber链表结构之后, 就可以分散成以Fiber为单位的小任务, 随时打断然后重新执行,让时间分片成为可能

专业文档的做法是将普通函数重构成generator函数, 也可以将一个大任务分成很多小任务

requestIdleCallback

requestIdleCallback 用法

interface RequestIdleCallbackDeadline {
  imeRemaining?(): DOMHighResTimeStamp;
  didTimeout?: boolean;
}

requestIdleCallback(callback: (deadline: RequestIdleCallbackDeadline) => any, options?: { timeout: number })

第一个参数是一个回调函数, 回调被调用时会有一个deadline对象, 通过调用deadline.timeRemaining()可以知道还有多少空闲时间, 如果没有空闲时间, 但是还有任务可以继续执行requestIdleCallback下一帧继续调度

function callback(deadline) {
  while (deadline.timeRemaining() > 0 && tasks.length > 0) doWorkIfNeeded()

  if (tasks.length > 0) requestIdleCallback(myNonEssentialWork)
}

还有一种情况是一直没有空闲时间, 为了避免回调永远不被调用, 还可以设置第二个参数, 如果设置了 timeout, 回调超时之后deadline.didTimeouttrue

requestIdleCallback(callback, { timeout: 2000 })

function callback(deadline) {
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0) doWorkIfNeeded()

  if (tasks.length > 0) requestIdleCallback(myNonEssentialWork)
}

requestIdleCallback 问题

  1. 兼容性 Safari 不支持
  2. requestIdleCallback依赖屏幕刷新率, 且 timeRemaining 最大为 50ms,FPS 为 20 (因为这个函数是为了执行低优先级任务开发的, 50ms 是处理用户响应的理想时间, 如果需要响应动画的话, 理想时间是 10ms,动画执行的回调应该使用requestAnimationFrame)

Scheduler 实现

Scheduler 实现了类似requestIdleCallback的功能, 在合适的时机执行回调函数, 同时告诉回调函数执行时间是否超时

Scheduler 期望实现一个更加通用的浏览器任务的调度器, 并不只是一个在空闲时间执行的低优先级任务, 所以在上面的基础上增加了任务优先级, 延时任务等功能

基础知识

Performance.now()

返回一个从页面加载到当前的时间,不同于 Date.now(), 它不会受到系统时间的影响

Scheduler里面用来计算时间, 获取当前时间

const getCurrentTime = () => performance.now()

MessageChannel

上面说了任务调度器会在合适的时机去执行任务, 什么是合适的时机?

首先启动调度一定得是异步任务, 不能阻塞当前任务执行, 然后不能是微任务, 因为前面会有宏任务执行后面还有渲染, 无法知道帧的开始时间和已经执行时间, 那么最好就是在当前帧渲染之后立刻开始调度, 这样可以记录开始时间

setImmediate(cb): 在浏览器完成其他任务后立刻执行这个回调函数, 但是这个方法并没有成为浏览器的标准.

setTimeout(cb, 0): 按照 HTML 规范, 嵌套深度超过 5 级的定时器, 会有最低 4ms 延迟的设定

MessageChannel 用法

MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据

const { port1, port2 } = new MessageChannel()

port1.onmessage = function () {}

port2.postMessage()

一个端口的 onmessage 的回调函数, 另一个端口 postMessage 会在下一个宏任务立刻执行, MessageChannel 也常被用来模拟 setImmediate

其实这个和window.onmessagewindow.postMessage的执行是一样的, 但是因为window.onmessage还会被用户其他的程序监听,所以改用MessageChannel PR

优先队列

Scheduler 调度任务队列的时候, 每次都只是需要执行当前优先级最高的任务, 任务队列是由二叉堆实现的优先队列

因为数据结构不是这里的重点, 实现略过, 感兴趣的可以去看一下源码

实现的接口:

  • peek(): 查看最高优先级的任务
  • push(task): 插入带有优先级的任务
  • pop(): 删除最高优先级的任务

任务优先级

Scheduler 分了 5 种任务优先级, 数字越小优先级越高, 每种优先级分别对应不同的任务超时时间, 类似于requestIdleCallbacktimeout, 会通过任务的过期时间(加入队列时间+超时时间)去排序优先队列的优先级, 越早过期的任务越先执行

export const ImmediatePriority = 1
export const UserBlockingPriority = 2
export const NormalPriority = 3
export const LowPriority = 4
export const IdlePriority = 5

// Math.pow(2, 30) - 1
// 0b111111111111111111111111111111
var maxSigned31BitInt = 1073741823

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250
var NORMAL_PRIORITY_TIMEOUT = 5000
var LOW_PRIORITY_TIMEOUT = 10000
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt

scheduleCallback

主要使用的 api, 类似于 requestIdleCallback, 作用就是将不同优先级的任务加入到任务队列等待调度执行

// 去掉延时任务的简化版本
function unstable_scheduleCallback(priorityLevel, callback) {
  // 获取当前时间
  var startTime = getCurrentTime()
  // 根据优先级获取timeout
  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

  var newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  }
  // 根据过期时间排序
  newTask.sortIndex = expirationTime

  // 加入任务队列
  push(taskQueue, newTask)

  // ...
}

上面的逻辑就是怎么将任务加入到任务列表, 接下来就是实现怎么在合适的时机去执行任务队列里面的任务

schedulePerformWorkUntilDeadline

触发开始调度的函数, 创建一个消息通道, port1 监听调度函数, post2 发送消息异步开始调度

const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = performWorkUntilDeadline
schedulePerformWorkUntilDeadline = () => {
  port.postMessage(null)
}

// 回退版本
schedulePerformWorkUntilDeadline = () => {
  setTimeout(performWorkUntilDeadline, 0)
}

那什么时候会执行这个函数呢? 会有下面两种情况

  1. 当一个任务加入到任务队列, 这个时候如果还没有开始调度的话, 会执行这个函数触发调度
// 上面加入任务的函数
function unstable_scheduleCallback(priorityLevel, callback) {
  //...

  // 加入任务队列
  push(taskQueue, newTask)

  if (!isHostCallbackScheduled && !isPerformingWork) {
    isHostCallbackScheduled = true
    requestHostCallback(flushWork)
  }
}

function requestHostCallback(callback) {
  // 将flushWork赋值给scheduledHostCallback, flushWork函数就是后面的调度器了
  scheduledHostCallback = callback
  // 消息通道没有运行就触发调度
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true
    schedulePerformWorkUntilDeadline()
  }
}
  1. 就是在一次调度之后如果还有任务没有被执行, 就继续执行这个函数, 在下个宏任务继续调度

performWorkUntilDeadline

消息通道监听的函数,会在渲染之后触发, 开始调度, 在这里记录任务队列执行的开始时间, 计算出截止时间

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime()
    // yieldInterval 就是这次调度可以执行的总时间, 默认是5ms, 可以通过api`forceFrameRate`动态调整, 不过并没有太大必要
    // 因为并不需要每一次调度都对应一次渲染, 而且一帧之内也可以多次调度, 所以可以不用调整到和一帧的时间对齐(16.67ms)
    deadline = currentTime + yieldInterval
    const hasTimeRemaining = true

    let hasMoreWork = true
    try {
      // scheduledHostCallback 就是下面的 flushWork, 通过 `requestHostCallback`赋值的
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime)
    } finally {
      if (hasMoreWork) {
        // 还有任务就继续调度
        schedulePerformWorkUntilDeadline()
      } else {
        isMessageLoopRunning = false
        scheduledHostCallback = null
      }
    }
  } else {
    isMessageLoopRunning = false
  }
}

shouldYieldToHost

是否到达帧尾, deadline 就是上面开始调度的时候计算出来的

这个 api 类似于 requestIdleCallback 回调函数的 deadline.timeRemaining() > 0

Scheduler 里面影响任务队列是否打断跳出, 也可以在外部自己调用, 确定当前执行的任务是否打断

React 就是执行这个方法是否打断Fiber Tree, 之后再恢复执行

function shouldYieldToHost() {
  return getCurrentTime() >= deadline
}

flushWork/workLoop

任务队列调度函数, 流程就是取当前优先级最高的任务执行, 每次执行之后检查是否到达 deadline, 没有就继续取最高优先级任务执行,不断循环

当然还会判断任务的过期时间, 如果任务过期了, 就算到达 deadline 也会将这个任务执行

function flushWork(hasTimeRemaining, initialTime) {
  // We'll need a host callback the next time work is scheduled.
  isHostCallbackScheduled = false
  isPerformingWork = true
  try {
    return workLoop(hasTimeRemaining, initialTime)
  } finally {
    currentTask = null
    isPerformingWork = false
  }
}

function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime
  // 取优先级最高的任务
  currentTask = peek(taskQueue)
  while (currentTask !== null) {
    if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost())) {
      // 如果已经到了这次调度的截止时间, 而且当前优先级最高的任务没有到过期时间, 就跳出下次继续调度
      break
    }
    const callback = currentTask.callback
    if (typeof callback === 'function') {
      currentTask.callback = null
      currentPriorityLevel = currentTask.priorityLevel
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime
      const continuationCallback = callback(didUserCallbackTimeout)
      currentTime = getCurrentTime()
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback
      } else {
        // 防止在callback的时候加入了更高优先级的任务
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue)
        }
      }
    } else {
      pop(taskQueue)
    }
    currentTask = peek(taskQueue)
  }
  // 返回是否还有任务需要执行, 还有任务就触发下次调度
  if (currentTask !== null) {
    return true
  } else {
    return false
  }
}

unstable_cancelCallback

类似于cancelIdleCallback, 调用 scheduleCallback 会返回当前任务

取消任务执行直接将任务的回调置为 null, 调度到当前任务就不执行

不直接从任务队列删除的原因是任务队列是二叉堆, 只能删除第一个任务, 不能删除任意任务

function unstable_cancelCallback(task) {
  task.callback = null
}

延时任务

并不是需要马上就开始调度的任务, 类似于setTimeout(() => scheduleCallback(callback), 2000), Scheduler 为了更好的时间调度, 增加一个timerQueue队列去管理

用法

scheduleCallback 的时候可以传入第三个可选参数对象{ delay: 2000 }, 这个时候会将这个任务暂时加入到timerQueue

最后还是会找时机从timerQueue转移到taskQueue去调度执行

function unstable_scheduleCallback(priorityLevel, callback, options) {
  var currentTime = exports.unstable_now()
  var startTime

  if (typeof options === 'object' && options !== null) {
    var delay = options.delay
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay
    }
    //...
  }
  //...
  // 如果是延时任务
  if (startTime > currentTime) {
    newTask.sortIndex = startTime
    push(timerQueue, newTask)
    // ...
  }
}

advanceTimers

这个函数就是检查所有timerQueue队列里面到期的任务转移到taskQueue

function advanceTimers(currentTime) {
  // Check for tasks that are no longer delayed and add them to the queue.
  var timer = peek(timerQueue)

  while (timer !== null) {
    if (timer.callback === null) {
      // Timer was cancelled.
      pop(timerQueue)
    } else if (timer.startTime <= currentTime) {
      // Timer fired. Transfer to the task queue.
      pop(timerQueue)
      timer.sortIndex = timer.expirationTime
      push(taskQueue, timer)
    } else {
      // Remaining timers are pending.
      return
    }

    timer = peek(timerQueue)
  }
}

转移的时机

  1. 如果同步任务队列为空, 那么同步任务不会开始调度, 这个时候调用scheduleCallback加入一个延时任务, 就直接创建一个定时器 setTimeout 去检查
const requestHostTimeout = (cb, ms) => setTimeout(cb, ms)

function unstable_scheduleCallback(priorityLevel, callback, options) {
  // ...
  // 如果是延时任务
  if (startTime > currentTime) {
    // ...
    push(timerQueue, newTask)
    // ...
    // 如果同步任务队列为空 且当前任务是`timerQueue`优先级最高任务, settimtout 去执行handleTimeout 调度延时任务
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // ...
      requestHostTimeout(handleTimeout, startTime - currentTime)
    }
  }
}

function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false
  // 转移任务
  advanceTimers(currentTime)

  if (!isHostCallbackScheduled) {
    // 如果有同步任务就开始调度
    if (peek(taskQueue) !== null) {
      isHostCallbackScheduled = true
      requestHostCallback(flushWork)
    } else {
      // 没有同步任务就继续定时器
      var firstTimer = peek(timerQueue)

      if (firstTimer !== null) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime)
      }
    }
  }
}
  1. 在开始调度同步任务之前和执行完一个同步任务之后(也就是调度器时间更新的时候), 会调用advanceTimers转移任务, 这里的逻辑应该是主要场景, 使用调度器内部的时间管理, 脱离 settimeout
function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime
  // 转移任务
  advanceTimers(currentTime)
  currentTask = peek(taskQueue)
  while (currentTask !== null) {
    // ...
    const callback = currentTask.callback
    if (typeof callback === 'function') {
      // ...
      callback(didUserCallbackTimeout)
      // ...
      // 执行完一个任务后转移timerQueue
      advanceTimers(currentTime)
    }
    // ...
  }
  // Return whether there's additional work
  if (currentTask !== null) {
    return true
  } else {
    const firstTimer = peek(timerQueue)
    // 如果同步任务队列执行完了, 走第一种调用场景
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime)
    }
    return false
  }
}

总体流程图

Scheduler

mengxiong10 avatar Jun 25 '21 13:06 mengxiong10