Javascript 长时间运行任务 - 利用 CPU 的空闲时间

2025-06-04

Javascript 长时间运行任务 - 利用 CPU 的空闲时间

为了提供流畅的用户体验,浏览器需要能够每秒渲染 60 帧,这意味着每 16 毫秒渲染一帧。如果您有长时间运行的 JavaScript 任务,那么您将开始丢帧,这在用户滚动或渲染动画时会更加明显。

有一些技术可以避免 UI 卡顿,最常见的方法是将这类任务移至 Web Worker。在本文中,我将探讨一种不同的方法:如何将工作拆分成多个块,并利用 CPU 的空闲时间进行处理。React 团队在其 Fiber 架构中使用了这项技术:可以中断树的协调工作,让位于优先级更高的工作,从而提升用户感知的性能

注意:本文所有内容都深受 React Fiber 架构的启发(但采用了非常简化的方法)。如果您直接跳到资源部分,您将获得一些资源,帮助您了解 React 的工作原理。

测试用例

一个包含 100,000 个节点的列表,其中每个节点的值都是根据前一个节点的值计算的 - 当用户更改第一个节点时,该链中的每个节点都必须重新计算,从而产生 99,999 个执行阻塞计算的节点。

具有以下接口的节点:

interface INode {
    id: string;
    value: number | null;
    previousId: string | null;
    nextId: string | null;
}
Enter fullscreen mode Exit fullscreen mode

创建节点图:

const nodes = new Map<INode>();
nodes.set('A1', {
  id: 'A1',
  nextId: 'A2',
  previousId: null,
  value: 99
});
nodes.set('A2', {
  id: 'A2',
  nextId: 'A3',
  previousId: 'A1',
  value: null
});

...

nodes.set('A100000', {
  id: 'A100000',
  nextId: null,
  previousId: 'A99999',
  value: null
});
Enter fullscreen mode Exit fullscreen mode

要求

我们的解决方案应支持以下要求:

  • 没有丢帧,页面应该始终响应
  • 处理应该是可中断的(因为引入了新数据或用户想要离开页面)
  • 在考虑到先前的限制的情况下应该尽可能快(如果我们将执行分成几块,处理时间会更长一些,但页面会响应,因此感知到的性能会更好)

如何衡量我们方法的质量?

  • 创建一个简单的应用程序——我将使用带有 Create React App 的应用程序;
  • 添加可滚动区域和一些动画以便测试用户交互;
  • 使用async-render-toolbox chrome 扩展程序来直观地了解 CPU 延迟;
  • 使用 devtools 进行一些额外的性能检查;

是的,这不是很科学......但我们真正想要改进的是感知性能,这更像是一种感官体验。

利用 CPU 的空闲时间

window.requestIdleCallback ()方法会将一个函数加入队列,以便在浏览器空闲期间调用。这使得开发者能够在主事件循环中执行后台低优先级的工作,而不会影响动画和输入响应等对延迟至关重要的事件。函数通常按先进先出的顺序调用;但是,如果需要,可以乱序调用指定了超时的回调函数,以便在超时时间结束之前运行它们。

通过调用 requestIdleCallback,我们可以为下一个 CPU 空闲周期安排一个回调。在该回调中,我们可以通过调用 来检查空闲周期结束前还剩下多长时间deadline.timeRemaining()。最长空闲时间为 50 毫秒,但大多数情况下,根据 CPU 的繁忙程度,实际空闲时间会少于 50 毫秒。

使用 timeRemaining 和一个常数最大时间,我们可以检查是否有空闲时间进行下一次计算或重新安排到下一个空闲周期。我们会安排一个新的回调,直到没有其他任务需要执行。通过这种方式处理节点,我们确保不会中断延迟关键事件,并提供流畅的用户体验。

安排工作

由于我们利用的是 CPU 的空闲时间,用户可以随时与页面交互并安排新的工作。这意味着我们应该维护一个待处理工作队列。

如果正在处理给定节点并且为同一节点安排了新工作,我们应该中止当前工作并将该节点再次推送到队列的末尾:

interface IUnitOfWork {
    triggerNodeId: string;
    node: INode;
}

let workQueue: INode[] = [];
let nextUnitOfWork: IUnitOfWork | null = null;

function scheduleWork(node: INode): void {
    /**
     * Verify if there is already a work being
     * process that was triggered by the same node
     */
    const isInProgress = nextUnitOfWork && nextUnitOfWork.triggerNodeId === node.id;

    if (isInProgress) {
        nextUnitOfWork = null;
    }
    workQueue.push(node);

    requestIdleCallback(performWork);
}
Enter fullscreen mode Exit fullscreen mode

我们的方法基于 CPU 的可用时间,但如何知道可用时间足以完成一个工作单元呢?嗯,这真是个难题!目前解决这个问题的方法是假设处理每个工作单元所需的中位时间,并将其存储在一个常量中ENOUGH_TIME。这需要进行一些调整,并且会根据你在应用中需要完成的工作进行调整。

const ENOUGH_TIME = 2; // in ms
Enter fullscreen mode Exit fullscreen mode

正如我们在上一个代码片段中所看到的,当我们安排工作时,我们会调用 ,requestIdleCallback最终会调用我们的performWork函数。在这个函数中,我们启动了workLoop

获取workLoop下一个工作单元,如果没有,则从 workQueue 中选择一个新节点。然后开始performUnitOfWork在 while 循环中调用我们的函数,直到我们认为没有剩余时间或没有其他工作单元为止。这performUnitOfWork是处理每个节点的函数(这里不详细介绍这个函数,因为在本例中它主要是虚拟计算)。

一旦workLoop完成,我们就会回到performLoop函数,如果 workQueue 中仍然有 nextUnitOfWork 或节点,那么我们会安排一个新的空闲回调并重新开始整个过程​​。

function resetNextUnitOfWork() {
    const node = workQueue.shift();
    if (!node) return;

    nextUnitOfWork = { triggerNodeId: node.id, node };
}

function workLoop(deadline: number): void {
    if (!nextUnitOfWork) {
        resetNextUnitOfWork();
    }

    while (nextUnitOfWork && deadline.timeRemaining() > ENOUGH_TIME) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
}

function performWork(deadline: number): void {
    workLoop(deadline);

    if (nextUnitOfWork || workQueue.length > 0) {
        requestIdleCallback(performWork);
    }
}
Enter fullscreen mode Exit fullscreen mode

结果

阻塞迭代方法执行速度更快,但如下图所示,存在大量丢帧现象。页面会暂时无响应:

普通版本会出现一段时间的 UI 卡顿

空闲回调方法需要更长的时间来执行,它的执行时间是不可预测的,因为它取决于 CPU 的繁忙程度,但页面始终响应,因此感知到的性能可能会好得多:

使用空闲周期的版本不会显示可察觉的卡顿周期

查看此视频以查看撰写本文时创建的示例的输出结果。

结论

在这个独立的测试中,似乎使用requestIdleCallback 的方法检查了我们的要求。

如果我们处理 100 次计算,使用空闲模式的执行时间与常规阻塞操作相差不大;但如果处理 10 万次计算,空闲模式会花费更长时间,但会更流畅。这是一种权衡,我个人认为这是值得的。

不过,需要注意的是,浏览器支持情况还不够理想……IE Edge 和 Safari 都还不支持……这两个浏览器都支持,对吧?😞 有一些方法可以解决这个问题,比如这个简单的gistreact 的方法,后者更复杂,也更健壮。

但是,有几个主题需要进一步探讨:

  • 这项工作与 React 的调度程序集成得如何?
  • @sebmarkbage表示,大多数 requestIdleCallback shim 并不能准确体现 requestIdleCallback 的功能。我们能找到一个好的 shim,或者直接用 React 用的那个吗?
  • 这与使用 webworker(或其他可能的方法)相比如何? - 我希望能够在以后的文章中回答这个问题。

资源

免责声明:观点仅代表我个人,不代表我雇主的观点。

如果您发现任何错误,无论是我的英语不好还是技术细节方面的问题,请尽管发推文告诉我。我会努力不断改进这篇博文:simple_smile:

文章来源:https://dev.to/canastro/javascript-long-running-tasks-use-cpu-s-idle-periods-58g2
PREV
如何寻找良好实践项目的想法
NEXT
别再自欺欺人了。我们所谓的 CI/CD 其实只是 CI。但 CI 工具还不够吗?云原生 CD 是可能的