React 饥饿保护算法:调度器如何计算任务的 expirationTime 并强制提升低优先级任务

各位同学,大家晚上好!

欢迎来到“React 内部宇宙”的深空探险课。我是你们今天的向导,一个在 React 代码里摸爬滚打多年的老程序员。

今天我们要聊的话题,听起来有点枯燥,但它是 React 能够保持流畅、不卡顿的基石——饥饿保护算法

想象一下,你是一家餐厅的经理。你的厨房(CPU)只有两个厨师(线程)。现在,客人们(任务)源源不断地进来点菜。

  • 客人 A 点了一份“即时上桌的汤”(同步任务,比如 setState)。
  • 客人 B 点了一份“稍微慢点的牛排”(高优先级任务,比如点击事件)。
  • 客人 C 点了一份“睡前读物”(低优先级任务,比如后台数据更新或非关键的渲染)。

如果来了个超级有钱的大款客人 D,点了份“百年陈酿红酒”,并且要求必须先上,你会怎么做?你会把 A、B、C 全部晾在一边,去给 D 做酒吗?

当然不会!那样的话,A 会饿死(程序崩溃),B 会投诉(交互卡顿),C 会睡着(页面不更新)。

这就是饥饿。在计算机科学里,如果低优先级的任务永远等不到 CPU 资源,它就“饿死”了。

React 的调度器(Scheduler)就是那个聪明的经理,它有一套复杂的算法,专门防止这种情况发生。这套算法的核心,就是计算 expirationTime(过期时间),并在任务过期时强制提升优先级

来,我们搬个小板凳,坐下来,打开源码,咱们一探究竟。


第一部分:任务界的“阶级制度”

在讲算法之前,我们必须先搞清楚 React 里的“阶级”。在 React 18 之前,任务优先级比较简单,但在 React 18 引入并发特性后,调度器变得更加精细。

Scheduler 这个包里,优先级被分成了 5 个等级(从高到低):

// Scheduler 内部定义的优先级常量
const ImmediatePriority = 1;   // 最高优先级:同步任务,必须马上做
const UserBlockingPriority = 2; // 高优先级:用户输入(点击、滚动),需要立刻响应
const NormalPriority = 3;      // 普通优先级:常规渲染
const LowPriority = 4;         // 低优先级:后台任务
const IdlePriority = 5;        // 空闲优先级:浏览器空闲时才做

我们的目标是:确保高优先级的任务(比如用户点击)不会被低优先级的任务(比如一次巨大的数据计算)无限期地挡在门外。


第二部分:任务的“出生证”——计算 expirationTime

当一个任务被创建(比如你调用了一个 setState),调度器会立刻给它发一张“出生证”。这张证上写着两个关键信息:

  1. startTime:这个任务什么时候开始排队。
  2. expirationTime:这个任务什么时候“过期”。

过期时间是怎么算出来的?这就有意思了。React 不会给所有任务一个固定的等待时间,而是根据任务的优先级动态计算。

这就是“饥饿保护”的第一步:给低优先级任务设定一个“死线”

如果到了死线任务还没做,它就会变身为“过期任务”,然后被踢进“紧急处理通道”。

让我们看一段简化版的代码,模拟 scheduleCallback 的逻辑:

// 模拟时间流逝
let currentTime = 0;

// 定义不同优先级的超时时间(单位:毫秒)
const timeoutTable = {
  ImmediatePriority: 0,           // 立即执行,超时为 0
  UserBlockingPriority: 250,      // 用户阻塞优先级,250ms 后如果还没做,就强制提升
  NormalPriority: 5000,           // 普通优先级,5000ms 后如果还没做,就强制提升
  LowPriority: 10000,             // 低优先级,10000ms 后如果还没做,就强制提升
  IdlePriority: 15000             // 空闲优先级,15000ms 后如果还没做,就强制提升
};

function scheduleCallback(priorityLevel, callback) {
  // 1. 获取当前绝对时间
  currentTime = getCurrentTime(); // 假设现在是 100ms

  // 2. 查表获取当前优先级对应的超时时间
  const timeout = timeoutTable[priorityLevel];

  // 3. 计算过期时间 = 当前时间 + 超时时间
  // 这里的 expirationTime 就像一张“入场券”,上面的时间一到,票就作废了
  const expirationTime = currentTime + timeout;

  // 4. 将任务推入任务队列(这里简化了队列结构,实际是一个堆结构)
  const newTask = {
    id: Math.random(),
    priorityLevel,
    callback,
    startTime: currentTime,
    expirationTime,
    isExpired: false // 初始状态肯定没过期
  };

  pushTimerQueue(newTask);

  return newTask;
}

看懂了吗? 这就是魔法所在。

假设你是一个普通用户,你在页面上点击了一个按钮。这触发了 UserBlockingPriority(高优先级)任务。React 会给它发一张 250ms 的入场券。

假设此时,React 正在后台执行一个 LowPriority(低优先级)任务,比如正在渲染一个巨大的图表。

  • 如果没有饥饿保护:后台任务可能会一直霸占 CPU,直到它自己做完,然后才轮到你的点击事件。这会导致你的按钮点击没有任何反应,用户会以为手机坏了。
  • 有了饥饿保护:后台任务(低优先级)被设定了 10000ms 的超时。而你的点击事件只有 250ms 的超时。

当时间走到 250ms 时,你的点击任务过期了。调度器会怎么做?它会把你的点击任务从“普通队列”里拿出来,插到“同步队列”的最前面!这就是强制提升


第三部分:调度器的“巡逻”机制

光计算 expirationTime 是不够的,调度器还得有个巡逻的保安,每隔一段时间(比如每帧 5ms)就去检查一下队列里有没有“过期”的任务。

这个巡逻机制主要在 shouldYieldToHostadvanceTimers 这两个函数里体现。

1. advanceTimers:检查谁过期了

每当 CPU 被切走(即 shouldYieldToHost 返回 true)或者有新任务加入时,调度器都会调用这个函数:

function advanceTimers(currentTime) {
  // 遍历所有在队列里的任务
  const timers = getTimers();

  for (const task of timers) {
    // 核心判断:当前时间 >= 过期时间
    if (currentTime >= task.expirationTime) {
      // 哎呀,这个任务过期了!
      task.isExpired = true;
    }
  }
}

2. shouldYieldToHost:检查 CPU 是否空闲

React 需要遵守浏览器的节流阀。如果浏览器说“我累了,你歇会儿吧”,React 就得停下来。

function shouldYieldToHost() {
  const currentTime = getCurrentTime();

  // 1. 先检查有没有过期的任务
  advanceTimers(currentTime);

  // 2. 检查当前队列里是否有任务需要执行
  const firstTask = peekTimerQueue();
  if (firstTask) {
    // 如果有任务,并且当前时间已经超过了任务的到期时间,或者浏览器需要休息
    // 那就返回 true,告诉 React "你该停下来了"
    if (currentTime >= firstTask.expirationTime) {
      return true;
    }
  }

  // 3. 如果队列为空,或者浏览器空闲,返回 false
  return false;
}

第四部分:强制提升——饥饿保护的“雷霆手段”

这是最精彩的部分。当调度器发现一个任务 isExpired 为 true 时,它不会直接扔掉这个任务,而是会强行改变它的命运

这就是 runExpiredTasks 的作用。

function runExpiredTasks() {
  const timers = getTimers();
  const currentTime = getCurrentTime();

  // 找到所有过期的任务
  const expiredTasks = timers.filter(task => task.isExpired);

  if (expiredTasks.length === 0) {
    return;
  }

  console.log(`🔥 警告!发现了 ${expiredTasks.length} 个过期的饥饿任务,正在强制提升优先级!`);

  // 遍历并处理这些过期任务
  for (const task of expiredTasks) {
    // 核心逻辑:重新计算它的过期时间
    // 不管原来它是几级优先级,现在它都要被当作最高优先级处理
    // 我们可以把它视为 ImmediatePriority (1)
    const newPriority = ImmediatePriority;

    // 更新任务的优先级
    task.priorityLevel = newPriority;

    // 更新它的过期时间(设为 0,意味着马上就要做)
    task.expirationTime = currentTime;

    // 把它推入同步执行队列
    pushSyncTask(task);
  }
}

代码演示:模拟饥饿场景

让我们写一个完整的微型模拟器,看看饥饿保护是如何工作的。

// --- 模拟环境 ---
let currentTime = 0;
let timerQueue = [];
let syncQueue = [];
let isRunning = false;

function getCurrentTime() {
  return currentTime;
}

function pushTimerQueue(task) {
  timerQueue.push(task);
  // 简单的排序,为了演示方便,按优先级排序(实际是堆结构)
  timerQueue.sort((a, b) => a.priorityLevel - b.priorityLevel);
}

function pushSyncTask(task) {
  syncQueue.push(task);
}

// --- 核心逻辑 ---

// 1. 调度器入口
function scheduleTask(priorityLevel, taskName) {
  console.log(`⏰ [${taskName}] 正在排队,优先级: ${priorityLevel}`);

  const timeout = priorityLevel === 1 ? 0 : 10000; // 模拟:只有 Immediate 是 0,其他都是 10秒
  const expirationTime = getCurrentTime() + timeout;

  const task = {
    id: Math.random(),
    name: taskName,
    priorityLevel,
    expirationTime,
    isExpired: false
  };

  pushTimerQueue(task);
  requestIdleCallback(workLoop); // 触发调度循环
}

// 2. 工作循环(模拟 CPU 执行)
function workLoop() {
  if (isRunning) return;
  isRunning = true;

  while (true) {
    // 模拟时间流逝 1ms
    currentTime += 1;

    // 检查是否有同步任务(最高优先级)
    if (syncQueue.length > 0) {
      const task = syncQueue.shift();
      console.log(`⚡️ [${task.name}] 正在执行(同步)`);
      continue; // 必须执行完
    }

    // 检查 Timer 队列
    if (timerQueue.length > 0) {
      const task = timerQueue[0];

      // --- 饥饿保护核心逻辑 ---
      if (currentTime >= task.expirationTime) {
        console.log(`⚠️ [${task.name}] 时间到了!当前时间(${currentTime}) >= 过期时间(${task.expirationTime})`);

        // 强制提升!
        task.priorityLevel = 1; // 变为最高级
        task.expirationTime = currentTime; // 立即过期
        task.isExpired = true;

        console.log(`🚀 [${task.name}] 被强制提升为最高优先级,准备执行!`);

        // 把它移入同步队列
        syncQueue.push(task);
        continue;
      }

      // 如果没过期,执行任务
      console.log(`🐢 [${task.name}] 正在执行(普通)`);
      timerQueue.shift();
    } else {
      // 队列空了,休息一下
      console.log("💤 CPU 空闲,休息中...");
      break;
    }
  }

  isRunning = false;
}

// --- 演示开始 ---

// 1. 用户点击按钮(高优先级,250ms 超时,我们模拟为 5ms)
console.log("--- 时刻 0ms ---");
scheduleTask(2, "用户点击按钮");

// 2. 后台开始渲染(低优先级,10000ms 超时)
console.log("--- 时刻 0ms ---");
scheduleTask(4, "后台渲染大图表");

// 3. 模拟时间流逝
console.log("--- 时刻 5ms ---");
// 此时,用户的点击任务应该过期了,被强制提升
// 后台的渲染任务还在排队

// 让我们手动触发一下循环看看
workLoop(); 

// 结果预期:
// ⏰ [用户点击按钮] 正在排队,优先级: 2
// ⏰ [后台渲染大图表] 正在排队,优先级: 4
// ⚡️ [用户点击按钮] 正在执行(同步)
// ⚠️ [后台渲染大图表] 时间到了!当前时间(6) >= 过期时间(10)
// 🚀 [后台渲染大图表] 被强制提升为最高优先级,准备执行!

在这个演示中,你会看到:

  1. 先是两个任务排队。
  2. 因为时间到了(5ms),用户的“点击按钮”任务虽然优先级只有 2,但它被强制提升到了 1 级,直接插队到了同步队列。
  3. 后台的“渲染大图表”任务虽然优先级很低,但它也过期了,所以也被强制提升了。

这保证了无论后台有多忙,用户的交互永远能被响应


第五部分:React 的具体实现细节

在真实的 React 源码中,逻辑稍微复杂一点,因为它结合了 Fiber 树和 Concurrency(并发)。

1. Scheduler.js 中的时间计算

React 使用了一个叫做 Scheduler 的独立包。它定义了 computeExpirationForTime 函数。

// React Scheduler 源码片段
function computeExpirationForTime(currentTime, priorityLevel) {
  // 根据优先级决定超时时间
  switch (priorityLevel) {
    case ImmediatePriority:
      return NoPriority; // 0
    case UserBlockingPriority:
      return 250; // 250ms
    case NormalPriority:
      return 5000; // 5000ms
    case LowPriority:
      return 10000; // 10000ms
    case IdlePriority:
      return 15000; // 15000ms
    default:
      return 5000;
  }
}

2. performConcurrentWorkOnRoot 中的强制提升

当 React 调用 performConcurrentWorkOnRoot 时,它会检查当前的任务是否过期。

function performConcurrentWorkOnRoot(root, didTimeout) {
  // didTimeout 是关键参数!
  // 它告诉 React:"嘿,外部调度器发现任务过期了,强行把你的 root 拿过来执行吧!"

  if (didTimeout) {
    // 如果过期了,我们需要强制渲染
    // 这意味着我们可能会跳过一些中间状态的更新,直接呈现最终结果
    // 这就是为什么 React 18 的自动批处理和 Suspense 能工作
    console.log("⚠️ 检测到任务过期,强制提升渲染优先级!");
    forceRootYielded(root);
  }

  // 正常的并发渲染逻辑...
}

3. 为什么是 250ms 和 5000ms?

你可能会问,为什么是 250ms?为什么不是 100ms?

这其实是一个用户体验与性能的权衡

  • 250ms (UserBlockingPriority):这是人眼能察觉到卡顿的临界值。如果用户点击后超过 250ms 没反应,或者动画卡顿超过 250ms,用户就会觉得“这破玩意儿好慢”。所以,高优先级任务必须在这个时间内搞定。
  • 5000ms (NormalPriority):这是普通的渲染时间。如果页面渲染超过 5 秒还没完成,说明你的组件树太大了,或者计算太复杂了。这时候,React 宁可让这个渲染任务过期,然后强制提升它,也要保证用户的点击操作能先响应用户,而不是死等这个渲染。

第六部分:深入剖析——expirationTime 的数学之美

React 的调度器不仅仅是一个简单的队列,它还利用了时间膨胀

Scheduler 内部,时间不是用 Date.now() 这种真实时间,而是用 SchedulerTime(一个基于 10ms 基准的递增数字)。这样做是为了避免浮点数计算误差,并让时间计算更高效。

计算公式非常精妙:

// 伪代码
function computeExpirationForTime(currentTime, priorityLevel) {
  let timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = NEVER;
      break;
    case UserBlockingPriority:
      timeout = 250;
      break;
    case NormalPriority:
      timeout = 5000;
      break;
    // ...
  }

  // 这里有一个“时间膨胀”的魔法
  // 如果当前时间 < 100ms,我们实际上给任务多一点时间
  // 如果当前时间 > 100ms,我们给任务的时间稍微少一点(避免无限等待)
  // 这是一个防止“饥饿”的动态调节机制
  if (currentTime < 100) {
    return currentTime + timeout;
  } else {
    return currentTime + timeout - (currentTime - 100);
  }
}

等等,上面的数学可能太抽象了。我们回到最直观的逻辑:过期时间 = 当前时间 + 超时

这个公式保证了:即使你是一个低优先级任务,只要你等得够久,你就会变成高优先级任务。

这就是饥饿保护的本质——用时间换公平


第七部分:实战中的“坑”与“解”

理解了饥饿保护算法,你在写代码时就能避免很多坑。

坑 1:不要滥用 setTimeout 来模拟异步

很多新手喜欢写这样的代码:

setTimeout(() => {
  this.setState({ data: heavyComputation() });
}, 1000);

这看起来像是异步了。但如果在 1 秒内用户疯狂点击了 100 次,你会产生 100 个 setTimeout。虽然它们是异步的,但它们在队列里排队。如果 heavyComputation() 很慢,后面的点击事件可能会阻塞前面的。

正确做法:使用 Scheduler 的 API,或者使用 useDeferredValue

// React 18 的 useDeferredValue
// 它内部就是利用了 Scheduler 的优先级机制
// 它会把低优先级的更新推后,让高优先级的更新先走
const deferredValue = useDeferredValue(newValue);

坑 2:在 useEffect 里做耗时操作

useEffect(() => {
  // 这里跑一个 5 秒的循环
  for(let i=0; i<5000000000; i++) {}
}, []);

这会阻塞主线程,导致整个页面(包括点击事件)都卡死。因为没有饥饿保护,你的点击事件被这个死循环“饿死”了。

正确做法:使用 setTimeout 把它扔到下一个宏任务,或者使用 requestIdleCallback


第八部分:总结——调度器的智慧

好了,同学们,我们今天把 React 的调度器讲透了。

React 的调度器不仅仅是一个“任务管理器”,它更像是一个精明的交通指挥官

  1. 它有等级:它知道什么是急事(点击),什么是闲事(后台计算)。
  2. 它有耐心:对于急事,它给 250ms,时间一到必须做。
  3. 它有底线:对于闲事,它给 5000ms,时间一到必须做。
  4. 它有雷霆手段:一旦任务过期,它不惜一切代价(强制提升优先级)也要把它塞进同步队列执行。

这种机制,就是饥饿保护算法

它确保了:

  • 交互流畅性:你的点击永远能被响应。
  • 渲染稳定性:页面不会因为一次计算就卡死。
  • 资源合理分配:高优先级任务优先,低优先级任务不抢占资源。

下次当你看到 React 那么丝滑地处理动画和点击事件时,别忘了感谢那个藏在 Scheduler.js 里的算法大师。它用简单的数学公式(expirationTime = currentTime + timeout),解决了计算机科学里最棘手的问题之一——公平调度

下课!记得去把你的 useEffect 检查一遍,别让你的任务“饿死”在后台了!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注