Skip to content

Latest commit

 

History

History
executable file
·
186 lines (142 loc) · 8.7 KB

任务调度的基本原理.md

File metadata and controls

executable file
·
186 lines (142 loc) · 8.7 KB
title date summary tags draft authors
任务调度的基本原理
2023-08-01
本文从案例出发结合 chrome performance 工具一步步剖析`任务调度`的原理
canvas
false
default

前言

React fiber 架构中引入了任务调度的概念并实现了scheduler,但是任务调度是框架无关的 很多文章分析的时候都会搬来计算机中操作系统调度,其他语言中的进程管理等概念,对于前端来说知识密度比较高不易理解。因此本文打算从案例出发结合 chrome performance 工具一步步剖析任务调度的原理。

本文尝试解答以下问题:

  • 浏览器的渲染流程与长任务的影响
  • 为什么需要任务调度,与什么是合作任务调度
  • 为什么需要使用宏任务而不是微任务进行调度
  • 使用 setTimeout 调度的问题
  • message Channel 作为调度方案
  • 什么是任务切片?什么是时间切片

同步长任务堵塞页面更新

我们知道,浏览器是一个中的 JS 线程和 UI 渲染线程是互斥的。长时间的 JS 同步任务会阻塞浏览器渲染,导致页面卡顿(动画执行卡顿)甚至失去相应(用户输入不响应).

这里通过一个 Demo 进行效果演示。当点击点击模拟任务的按钮时,我们执行一段 JS 代码,这段代码会动态创建 3000 个任务区执行,每个任务的执行时间为 2ms, 总体的任务时间为 6000ms(可能有误差,但整体不大)

// ----------任务创建----------
const createWorks = () => {
  const taskDuration = 2;
  let works = [];
  for (let i = 0; i < 3000; i++) {
    const work = () => {
      let startTime = Date.now();
      while (Date.now() - startTime < taskDuration) {
        // 模拟任务执行时间
      }
    };
    works.push(work);
  }
  return works;
};
const works = createWorks();

//  -------更新页面------
const flushWorks = () => {
  works.forEach((work) => work());
};

通过观察可以发现,在点击了 button 之后 JS 执行的过程中,整个页面是处于 freeze 状态,这必然是很影响用户体验的。

通过 Performance 面板我们可以看到,整个 JS 的任务执行时间非常长,因此我们得想办法优化这部分的逻辑。

👉 在线效果预览, 或者下载源码后打开(为避免浏览器插件影响,请在无痕模式下打开)

setTimeout 异步更新

既然我们的同步的长任务不可接受,那么我们就要想办法将任务变成可中断的。同时使用宏任务 API setTimeout 进行异步更新(当然在实际过程中我们要考虑更多的因素,比如执行上下文的保存以及中断之后的恢复, 如 React Fiber 架构)

注意此处不可使用微任务 API 如 Promise,因为微任务会在一个宏任务执行完成之后全部执行,实际效果就和同步执行差不多了,关于宏任务微任务的相关知识点可以看这里的介绍

// --------任务调度--------
const workLoop = () => {
  const work = works.shift();
  if (work) {
    work();
    setTimeout(workLoop, 0);
  } else {
    updateView(Date.now() - startTime);
  }
};

const flushWorks = () => {
  setTimeout(workLoop, 0);
};

点击按钮观察页面可以发现,经过 setTimeout 进行异步更新后,现在任务执行的时候已经不会阻塞页面动画渲染了。但是这里存在一个问题就是 setTimeout 的调度时机问题,虽然我们在代码里面写了setTimeout(workLoop, 0), 但是实际上浏览器每次调度的时候的都会有一个 4ms 的延迟,从图中我们可以看出在一帧的时间内仅执行了 3 个任务,这也导致了总任务执行时长变为了 18s,那么有没有什么办法可以提高调度的频率呢?

👉 在线效果预览, 或者下载源码后打开

messageChannel 异步更新

上面提到了我们需要提高浏览器的调度频率,这里我们使用 messageChannel 来实现对应的功能。实际上如果我们不考虑任务执行的时间, 单纯测试 messageChannel 的调用次数的话,其表现效果还是很亮眼的,1s 内的调用次数可以达到 16 万次(取决于机器的性能。)

👉 在线效果预览, 或者下载源码后打开

有了上面的数据,我们使用 messageChannel 来改造我们的示例,这里为了方便不熟悉 messageChannel 的朋友理解,我们模拟 setTimeout 的使用方式来使用 messageChannel 进行任务调度,其余代码保持不变。

const _setTimeout = ((workLoop) => {
  let channel = null;
  return (onMessageCb) => {
    if (!channel) {
      channel = new MessageChannel();
      channel.port1.onmessage = onMessageCb;
    }
    channel.port2.postMessage(null);
  };
})();

const workLoop = () => {
  const work = works.shift();
  if (work) {
    work();
    _setTimeout(workLoop);
  } else {
    updateView(Date.now() - startTime);
  }
};

通过 Performance 面板可以发现,在每个任务执行时间为 2ms 的情况下,一帧内可以执行的任务数为 8。但即便如此,依然存在一定的调度耗时。

👉 在线效果预览, 或者下载源码后打开

任务切片

在上面的基础上,我们很容易有这样的想法:“尽量减少任务调度的次数”不就可以了吗?我们可以改造下 workLoop 函数,让尽可能多的任务在一帧内执行。

const workLoop = () => {
  let workExecutedCount = 0;
  while (workExecutedCount < 7) {
    const work = works.shift();
    if (work) {
      work();
      workExecutedCount++;
    } else {
      updateView(Date.now() - startTime);
      break;
    }
  }
  if (works.length) {
    _setTimeout(workLoop);
  }
};

通过 Performance 面板可以看到,性能略微有提升。

时间切片

上述的“任务切片”方案是基于我们已知单个任务执行时间的前提下,但是在实际业务中每个任务的执行时间是不确定的。因此我们可能会想到另外一种切片的方式:“时间切片”。 给定一个预定的时间比如 5ms, 每次任务执行完成之后都会去判断一下是否超出了时间分片的限制,如果是的话就让出执行权交给浏览器去渲染。

这里我们来改造下 createWorks 方法,每次生成一个在 0-1 毫秒之内的任务,然后执行,查看效果。

const createWorks = () => {
  const taskDuration = Math.random(); // 随机生成 0-1
  let works = [];

  for (let i = 0; i < 3000; i++) {
    works.push(() => {
      const start = performance.now();
      const time = Math.random();
      while (performance.now() - start < time) {}
    });
  }
  return works;
};
const works = createWorks();

对应的效果如下:

👉 在线效果预览, 或者下载源码后打开

至此,我们已经初步了解了调度相关的知识,下一篇会讲讲优先级调度相关的东西。

本文首发于个人博客前端开发笔记,由于笔者能力有限,文章难免有疏漏之处,欢迎指正