Vue3 → 深入理解Vue3调度器与调度系统

673 阅读15分钟

调度器

简介

在计算机领域,调度是指将一种任务(work)分配给资源的方法,任务可能是虚拟的计算任务,如线程、进程、数据流等,这些任务会被调度到硬件上进行执行,如处理器CPU等; 其最主要的目的就是实现如何对资源高效的分配和调度以达到我们的目的,如最小化成本、资源的合理利用、快速匹配供给和需求等;

可调度性是响应式系统中非常重要的特性,在Vue3中调度器是指当响应式系统触发trigger副作用函数执行时,可以控制副作用函数的执行时机、次数和方式;
在Vue3中,是允许用户在注册副作用函数的Effect函数中传入第二个参数options,可以指定scheduler调度函数,当传入调度函数时则只执行调度函数(将当前的副作用函数当参数传递给调度函数),不执行以往的副作用函数,当没有调度函数时,就只执行副作用函数了;

  • 调度器执行任务时将任务分为三个阶段
    • 前置刷新阶段
    • 刷新阶段
    • 后置刷新阶段
  • 每个阶段两种状态
    • 正在等待刷新
    • 正在刷新
  • 每次刷新时,通过Promise.resolve启动一个微任务,调用flushjob函数,按照前置回调任务池、当前任务池、后置回调任务池进行依次更新,直到三个任务池中的回调都刷新结束

调度器的作用实现

  • 调度器可以控制副作用函数的执行时机、次数和方式
    • 控制执行时机:通过传入调度函数配置项,将副作用函数放入宏任务等方式中进行执行,这样可以间接的控制副作用函数的执行时机

      // 响应式系统中的调度器内部实现
      function trigger(target, key) {
        const depsMap = bucket.get(target);
        if (!depsMap) return;
        const effects = depsMap.get(key);
      
        const effectsToRun = new Set();
        effects &&
          effects.forEach((effectFn) => {
            if (effectFn !== activeEffect) {
              effectsToRun.add(effectFn);
            }
          });
        effectsToRun.forEach((effectFn) => {
          // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
          if (effectFn.options.scheduler) {
            // 新增
            effectFn.options.scheduler(effectFn); // 新增
          } else {
            // 否则直接执行副作用函数(之前的默认行为)
            effectFn(); // 新增
          }
        });
      }
      
      //EG
      const data = { foo: 1 };
      const obj = new Proxy(data, {
        /* ... */
      });
      
      effect(
        () => {
          console.log(obj.foo);
        },
        // options
        {
          // 调度器 scheduler 是一个函数
          scheduler(fn) {
            // 将副作用函数放到宏任务队列中执行
            setTimeout(fn);
          },
        }
      );
      
      obj.foo++;
      
      console.log("结束了");
      
      // SoDo 1 结束了 2
      
    • 调度器控制执行次数

      控制执行次数可以实现过滤掉中间的过渡态,从而实现只关注结果而不关心执行过程

      • 实现原理是通过任务队列来实现,该任务队列需要使用Set的数据结构,原因是该数据结构具有去重能力
      // 定义一个任务队列,Set结构具有去重能力
      const jobQueue = new Set();
      // 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列 在任务队列内完成对jobQueue的遍历执行
      const p = Promise.resolve();
      
      // 一个标志代表是否正在刷新队列  通过锁的方式进行控制目标函数的执行逻辑
      let isFlushing = false;
      function flushJob() {
        // 如果队列正在刷新,则什么都不做
        if (isFlushing) return;
        // 设置为 true,代表正在刷新 无论调用多少次flushJob都会执行一次
        isFlushing = true; 
        // 在微任务队列中刷新 jobQueue 队列
        p.then(() => {
          jobQueue.forEach((job) => job());
        }).finally(() => {
          // 所有队列中的任务都执行完后 重置 isFlushing
          isFlushing = false;
        });
      }
      
      effect(
        () => {
          console.log(obj.foo);
        },
        {
          scheduler(fn) {
            // 每次调度时,将副作用函数添加到 jobQueue 队列中
            jobQueue.add(fn);
            // 调用 flushJob 刷新队列
            flushJob();
          },
        }
      );
      
      obj.foo++;
      obj.foo++;
      

调度系统深入理解

概念解析

  • 调度
    • 操作系统中的调度:目的是为了解决计算机资源分配的问题,由于计算机资源有限,必须按照一定的原则进行执行
    • Vue中的调度:也是按照一定的原则来执行,但目的是:保证Vue组件渲染过程中的正确性以及API的执行顺序的正确性,如生命周期、DOM更新需要在响应式数据变化后更新、watch的callback需要在组件更新前调用

常见的调度系统分析

操作系统中的调度系统

在操作系统的进程调度器中,待调度的任务就是线程,这种任务一般只会处于正在执行或未执行的状态,而处理这些任务的CPU是不可再分的,同一个CPU在同一时间只能执行一个任务;

  • 相关特点
    • 任务:状态简单,只会处于正在执行或未被执行两种状态,状态不同优先级也不同,需要在逻辑执行时考虑到相关问题;
      • 待执行的任务是操作系统调度的基本单元 - 线程,可分配的资源是CPU时间
      • 任务可以有优先级的区别,也可以加入状态的区别,优先级高的可以抢占优先级低的,有状态的需要依赖于持久存储卷;
    • 资源:CPU时间,资源不可再分
      • 同一时间只可执行一个任务,不同节点上的资源类型不同,包括CPU、GPU、内存等;

调度的相关原理

调度器的目的大多都是优化与业务联系紧密的指标,包括成本和质量,如何在成本和质量之间达到平衡是需要仔细思考和设计的;还有另外一个需要考虑的是:调度器在执行时的延时和吞吐量造成的额外开销;

相关概念浅析
  • 协作式(Cooperative)与抢占式(Preemptive)
    协作式和抢占式调度是操作系统中常见的多任务运行策略,两者有质的区别;
    • 协作式:允许任务执行任意长的时间,直到任务主动通知调度器让出资源,每个任务在执行结束后都会主动让出资源
      • 当任务完成后没有主动让出资源,就会导致其他任务等待和阻塞
      • 当某一个任务时间过长,会导致其他任务无法按时快速的完成相关任务
      • 当执行的任务时间不长且都差不多,协作式可以减少任务切换和中断导致的额外开销,即会有更好的调度性能 协作式调度.png
    • 抢占式:允许任务在执行过程中被调度器挂起,由调度器决定下一个要运行的任务
      • 在存在较长时间的任务时,会导致超过调度器分配的上限,该任务会等待其他任务获得资源,然后再继续执行 image.png
    • 在决定使用哪种方式时需要考虑到任务的执行时间和任务上下文切换的额外开销
  • 单调度器和多调度器
    • 多调度器:可以有多个进程、一个进程上多个调度线程、也可以在多核上并行调度,可以在单核上并发调度,也可以利用并行和并发提高性能
      image.png
      • 多调度器的并发调度可以极大提升调度器的整体性能,例如Go语言中的调度器就是将多个CPU交给不同处理器分别调度,这样通过并行调度可以提升调度器的性能
      • 多调度器中的每个调度器都会面对无关的资源,对于同一个分区的资源,调度还可以时串行的
      • 负载均衡就可以看作是多线程和多进程的调度器
    • 单调度器:单调度器的串行调度可以精确调度资源,其利用不同渠道收集调度需要的上下文,并在收集到调度请求后会根据任务和资源情况做出最优的决策 image.png
      • 单调度器在后续演进中,其性能和吞吐量可能会受到限制,需要引入并行或并发调度来解决性能上的瓶颈,此时需要将待调度的资源分区,让多个调度器分别负责调度不同区域的资源
        image.png
  • 调度范式(任务再分配策略) - 工作分享和工作窃取
    • 在多个调度器调度正在等待处理的相关任务时,会出现任务分配不均的问题,此时就需要引入任务再分配策略,即引入工作分享和工作窃取策略
    • 两种策略都会对系统造成额外的开销,相比之下工作窃取引入的额外开销会更小;
    • 工作分享:调度器在创建了新任务时,会将一部分任务分配给其他调度器;
    • 工作窃取:当调度器的资源没有被充分利用时,就会从其他调度器中窃取一些待分配的任务 image.png
      • 工作窃取只会在当前调度器的资源没有被充分利用时才会触发,其造成的额外开销会更小,常用于生产环境

调度器架构设计

调度器内部架构设计

image.png

当调度器收到调度任务时,会根据采集到的状态和待调度任务的规格(Spec)做出合理的调度决策,常见的调度器一般由两部分组成,分别是用于收集状态的状态模块和负责做决策的决策模块

  • 状态模块
    • 状态模块从不同途径收集尽可能多的信息为调度器提供丰富的上下文,其中可能包括资源的属性、利用率和可用性等信息。根据场景的不同,上下文可能需要存储在MySQL等持久存储中,一般也会在内存中缓存一份以减少调度器访问上下文的开销;
  • 决策模块
    • 决策模块会根据状态模块收集的上下文和任务的规格做出调整决策,但现在做出的调度决策只是在当下有效,在未来可能会导致之前的决策不符合任务的需求,例如某个状态、任务规格和网络等变化导致不符合需求;
调度器调度资源解析
  • 通过优先级、任务创建时间等信息确定不同任务的调度顺序
    • 确定优先级
  • 通过过滤和打分两个阶段为任务选择合适的资源
    • 对闲置资源进行打分
  • 不存在满足条件的资源时,选择牺牲的抢占对象
    • 确定抢占资源的优先级
    • 可选的步骤,部分调度系统不需要支持抢占式调度的功能

调度器外部架构设计

  • 多调度器
    • 可以将待调度的资源进行区分,让多个调度器线程或进程分别负责各个区域中资源的调度,充分利用多核CPU的并行能力
  • 反调度器
    • 可以移除决策不再正确的调度,降低系统中的熵,让调度器根据当前的状态重新决策
    • 调度器负责根据当前的状态做出正确的调度决策,反调度器根据当前的状态移除错误的调度决策,两者的逻辑看似相反,但是目的都是为任务调度更合适的资源
    • 反调度器使用没有太广泛,因为移除调度关系可能会影响到正在运行的任务

Vue中的调度系统

前置知识

操作系统中引入调度是为了解决计算机资源的分配问题,是一个优先级的调度,在Vue中,行为上也是按照一定的原则,选择任务来占用资源执行,但目的并不是解决资源分配问题(操作系统已经解决),其在Vue中的目的是保证Vue组件渲染过程中的正确性以及API的执行顺序的正确性
Vue规定,id越小,优先级越高

  • Vue中的异步回调API和相关规则
    • watch的callback函数,需要在组件更新前调用
      • watch在一般情况下,是加入到Pre队列(DOM更新前队列)等待执行,但在组件更新时,watch也是加入队列,但会立即执行并清空Pre队列
    • 组件DOM的更新,需要在响应式数据(Ref、reactive、data等)变化之后更新
    • 父组件需要先更新,子组件后更新
    • mounted生命周期,需要在组件挂载之后执行
    • updated生命周期,需要在组件更新之后执行
    • 队列会在当前浏览器任务的所有JS代码执行完成后,才开始依次执行pre队列、queue队列和post队列

调度算法是对整个调度算法的抽象,只关注执行的顺序,不关注执行任务的内部逻辑

  • 调度算法有两个基本数据结构:队列(queue)和任务(job)
    • 入队:将任务加入队列,等待执行
    • 出对:将任务取出队列,立即执行
    • 调度算法有很多,目的和内部实现有所差异,但是其基本数据结构都相同,不同点在于入队和出队的方式 image.png
  • 常见的调度算法 image.png
    • 先来先服务(FCFS):先入队的job先执行,这种算法常见于job平等,没有优先级的场景
    • 优先级调度算法:优先级高的job先执行
    • 一般只有入队的过程是由我们自己控制的,整个队列的执行(如何出队)是由队列自身控制的,我们可以通过控制调度算法间接控制入队过程;
    • 队列的相关执行逻辑不会关注于任务的具体内容是什么,只会按照规定的顺序进行执行队列中的相关内容(先进先出or优先级执行),这样可以极大的减少Vue API和队列间的耦合
  • Vue3中的调度算法(只能控制入队过程,出队过程由队列自身控制)
    • Vue3中的队列及组件更新方式
      • 组件DOM更新前的队列(Pre队列)
        • queuePreFlushCb API加入Pre队列
      • 组件DOM更新的队列(queue队列)
        • queueJob API加入queue队列
      • 组件DOM更新后的队列(Post队列)
        • queuePostFlushCb API加入Post队列
      • 数据的修改是立即生效的,但是DOM的修改是延迟执行的
        • 数据更新后,此时DOM还没有更新,会立即执行queueJob(instance.update),将组件DOM更新任务加入到队列中
    • 队列更新特点
      • 任务去重:防止多次重复的执行更新操作,需要进行入队时的去重操作
        • if(!queue.includes(job)){queue.push(job)}
      • 优先级机制:只有queue队列和Post队列具有优先级机制,job.id越小,越优先执行
        • queue队列的优先级机制:
          • 该队列中的job是执行组件的DOM更新,而组件存在父子组件的情况,因此需要先更新父组件再更新子组件(有可能涉及到传参等情况) image.png
          • 组件DOM的更新会深度递归更新子组件,组件的创建过程也是一样的,也会深度递归创建子组件;
          • 在进行queue队列的job优先级时,只需要实现入队和插队功能即可
            • 入队即是普通的类push操作,插队则需要采用二分查找等手段获取到job.id,计算出要插入的位置,然后进行交换即可
            • queue.splice(findInsertionIndex(job.id),0,job)
        • post队列优先级机制
          • 常见的Post队列的job有:mounted、update、watchPostEffect API等,前两者是需要在DOM更新之后再执行,后一个需要用户手动设置watch回调在DOM更新之后执行
          • 用户设定的回调之间,并不存在依赖关系,但在Post队列中,有一种内部的Job,需要提前执行,其目的是更新模板引用;因为在用户的回调中可能涉及到模板的引用,因此必须要在用户编写的函数执行前把模板引用的值更新
          • 案例是用户设定的data值会控制DOM模板的引用展示,然后在生命周期中进行调用该模板,如果该模板没有更新,则会收到错误的值
      • 组件卸载 → 失效任务
        • 当组件被卸载(unmounted)时,其对应的Job会失效,因此不需要再更新组件了,也就没必要再执行收集到的Job了。即失效的任务在取出队列时不会被执行,

调度算法简介

调度算法有两个基本的数据结构:队列(queue)和任务(job),相关操作有:入队:将任务加入队列,等待执行;出对:将任务取出队列,立即执行

常见库推荐

轻量级JS任务调度工具 → bobolink
使用可观察序列来编写异步和基于事件的程序 → RxJS

推荐文献

图解Vue响应式原理 👍🏻👍🏻👍🏻
Vue3 调度系统的深度剖析
Vue3 任务调度器 scheduler 源码分析
调度系统设计精要 👍🏻👍🏻👍🏻