有比setTimeout(0)更快的方法让JavaScript让出事件循环吗?

8

我正在尝试编写一个可中断计算的Web Worker。 我所知道的唯一方法(除了Worker.terminate())是定期向消息循环屈服,以便它可以检查是否有任何新消息。 例如,此Web Worker计算从0到data的整数之和,但如果您在计算正在进行时发送新消息,则会取消计算并启动一个新的计算。

let currentTask = {
  cancelled: false,
}

onmessage = event => {
  // Cancel the current task if there is one.
  currentTask.cancelled = true;

  // Make a new task (this takes advantage of objects being references in Javascript).
  currentTask = {
    cancelled: false,
  };
  performComputation(currentTask, event.data);
}

// Wait for setTimeout(0) to complete, so that the event loop can receive any pending messages.
function yieldToMacrotasks() {
  return new Promise((resolve) => setTimeout(resolve));
}

async function performComputation(task, data) {
  let total = 0;

  while (data !== 0) {
    // Do a little bit of computation.
    total += data;
    --data;

    // Yield to the event loop.
    await yieldToMacrotasks();

    // Check if this task has been superceded by another one.
    if (task.cancelled) {
      return;
    }
  }

  // Return the result.
  postMessage(total);
}

这个方法可以运行,但速度非常慢。在我的机器上,while循环的每个迭代平均需要4毫秒!如果你想快速实现取消操作,这是一个巨大的开销。

为什么会如此缓慢?有没有更快的方法来完成这项任务?


2
“setTimeout”有最小延迟,所以最好不要使用它。你可以直接使用“new Promise(resolve => resolve())”,这将立即弹出该Promise。这样会更快。 - VLAZ
我实际上已经尝试过这个方法,但它并没有奏效。我认为原因是由于 Promise 被调度为微任务,在返回事件循环之前执行,因此它永远不会真正地将控制权让给新的消息。 - Timmmm
在Chrome-Windows上,进程初始化和取消之间的平均时间为1到2毫秒,很少会更长。是的,Promise.resolve不起作用。 - CertainPerformance
“进程初始化”是什么意思?我没有启动任何新的进程。 - Timmmm
我没有这样做,但我猜测它被downvote的原因是它比“MessageChannel”方法更加hackier和难以使用,并且在我看来没有任何好处。 - Timmmm
显示剩余9条评论
4个回答

4
是的,消息队列比超时队列具有更高的优先级,因此会以更高的频率触发。
您可以使用MessageChannel API轻松绑定到该队列。

let i = 0;
let j = 0;
const channel = new MessageChannel();
channel.port1.onmessage = messageLoop;

function messageLoop() {
  i++;
  // loop
  channel.port2.postMessage("");
}
function timeoutLoop() {
  j++;
  setTimeout( timeoutLoop );
}

messageLoop();
timeoutLoop();

// just to log
requestAnimationFrame( display );
function display() {
  log.textContent = "message: " + i + '\n' +
                    "timeout: " + j;
  requestAnimationFrame( display );
}
<pre id="log"></pre>

现在,您可能还希望在每个事件循环中批量执行多轮相同的操作。
以下是这种方法有效的几个原因:
  • 根据规格, 在第五次调用后,setTimeout将被限制为至少4ms,也就是在OP的循环的第五次迭代之后。
    消息事件不受此限制。

  • 一些浏览器会使由setTimeout启动的任务具有较低的优先级,在某些情况下。
    即,Firefox在页面加载时会这样做, 以便在此时调用setTimeout的脚本不会阻塞其他事件;它们甚至会创建一个专门的任务队列来处理这个问题。
    尽管还没有规范,但至少在Chrome中,似乎消息事件具有“用户可见” 优先级,这意味着某些UI事件可能会先到达,但仅此而已。(使用Chrome中即将推出的scheduler.postTask() API进行测试)

  • 大多数现代浏览器在页面不可见时会限制默认超时时间,这甚至适用于Workers
    消息事件不受此限制。

  • 根据OP的发现, Chrome即使在前5次调用中也会设置最低1ms。


但请记住,如果对setTimeout施加了所有这些限制,那是因为以这样的速度安排那么多任务是有代价的。

仅在Worker线程中使用!

在Window上下文中执行此操作将会限制浏览器处理的所有常规任务,但它们会认为这些任务不太重要,例如网络请求、垃圾回收等。
此外,发布新任务意味着事件循环必须以高频率运行,并且永远不会空闲,这意味着更多的能源消耗。


哦,有意思!我会试一试的,谢谢!顺便提一下,这个缓慢并不是因为消息优先级(JS事件循环是否有优先级?) - 只是因为在Chrome上setTimeout()有一个最小的4毫秒超时时间(请看我的回答)。 - Timmmm
我猜优先级也可能会影响事情,但即使它们没有,setTimeout()也有最小超时时间,这意味着它永远不可能非常快。无论如何,感谢您的回答! - Timmmm
@Timmmm 自从我写了这个答案以来,我实际上深入研究了Chrome和Firefox中任务优先级的工作原理,结果证明你是对的,这里真正重要的不是“优先级”(仅适用于FF和页面加载时的第一个调用),而是显然的4ms阈值最为关键。然后,setTimeout仍然比我说的更不“重要”,但更多的是在它会比消息事件受到更严格的限制(可能是因为它实际上更常被Web作者使用)。 - Kaiido

2

为什么速度这么慢?

Chrome (Blink)实际上将最小超时时间设置为4毫秒

// Chromium uses a minimum timer interval of 4ms. We'd like to go
// lower; however, there are poorly coded websites out there which do
// create CPU-spinning loops.  Using 4ms prevents the CPU from
// spinning too busily and provides a balance between CPU spinning and
// the smallest possible interval timer.
static constexpr base::TimeDelta kMinimumInterval =
    base::TimeDelta::FromMilliseconds(4);

编辑:如果您在代码中继续阅读,那么这个最小值仅在嵌套级别超过5时才使用,但它仍将在所有情况下将最小值设置为1毫秒:

  base::TimeDelta interval_milliseconds =
      std::max(base::TimeDelta::FromMilliseconds(1), interval);
  if (interval_milliseconds < kMinimumInterval &&
      nesting_level_ >= kMaxTimerNestingLevel)
    interval_milliseconds = kMinimumInterval;

显然,WHATWG和W3C规范对于最小4毫秒的应用是否始终适用或仅适用于某个嵌套级别存在分歧,但WHATWG规范是HTML中重要的规范,而Chrome似乎已经实现了该规范。
不过我不确定为什么我的测量结果显示仍需要4毫秒。

有更快的方法吗?

基于Kaiido的好主意,可以使用另一个消息通道来执行类似以下操作:

let currentTask = {
  cancelled: false,
}

onmessage = event => {
  currentTask.cancelled = true;
  currentTask = {
    cancelled: false,
  };
  performComputation(currentTask, event.data);
}

async function performComputation(task, data) {
  let total = 0;

  let promiseResolver;

  const channel = new MessageChannel();
  channel.port2.onmessage = event => {
    promiseResolver();
  };

  while (data !== 0) {
    // Do a little bit of computation.
    total += data;
    --data;

    // Yield to the event loop.
    const promise = new Promise(resolve => {
      promiseResolver = resolve;
    });
    channel.port1.postMessage(null);
    await promise;

    // Check if this task has been superceded by another one.
    if (task.cancelled) {
      return;
    }
  }

  // Return the result.
  postMessage(total);
}

我并不完全满意这段代码,但它似乎能够工作,并且速度 极快。在我的机器上,每次循环大约需要0.04毫秒。


如果你读一下这个值被用在哪里,你会发现它只有在嵌套级别高于5时才被使用,这实际上就是规范中的[步骤11](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers:concept-task-2)。 - Kaiido
setTimeout的最小毫秒值是多少? - VLAZ
@Kaiido 哦,是的,这很奇怪,因为它需要4毫秒...还有VLAZ发布的链接。它确实总是设置最小值为1毫秒。而且评论明显是打算应用于所有计时器。也许这是一个错误! - Timmmm

0

看着我的另一个答案中的负评,我尝试用我新学到的知识挑战这个答案中的代码,即setTimeout(..., 0)在Chromium上至少有4毫秒的强制延迟。我在每个循环中放入了100毫秒的工作负载,并在工作负载之前安排了setTimeout(),以便setTimeout()的4毫秒已经过去。我对postMessage()也做了同样的处理,只是为了公平起见。我还改变了日志记录方式。

结果令人惊讶:观察计数器时,消息方法在开始时比超时方法多了0-1次迭代,但它保持不变,甚至达到3000次迭代。这证明了具有并发postMessage()setTimeout()可以在Chromium中保持其份额。

将iframe滚动出范围后,结果发生了变化:与基于超时的工作负载相比,几乎处理了10倍数量的消息触发工作负载。这可能与浏览器的意图有关,即在视野之外或另一个选项卡中向JS分配更少的资源。

在Firefox上,我看到7:1的消息处理工作负载与超时相比。观察它或将其保留在另一个选项卡上似乎并不重要。

现在,我将(稍作修改的)代码移至Worker。结果表明,通过超时调度处理的迭代次数与基于消息的调度完全相同。在Firefox和Chromium上,我得到了相同的结果。

let i = 0;
let j = 0;
const channel = new MessageChannel();
channel.port1.onmessage = messageLoop;

timer = performance.now.bind(performance);

function workload() {
  const start = timer();
  while (timer() - start < 100);
}

function messageLoop() {
  i++;
  channel.port2.postMessage("");
  workload();
}
function timeoutLoop() {
  j++;
  setTimeout( timeoutLoop );
  workload();
}

setInterval(() => log.textContent =
  `message: ${i}\ntimeout: ${j}`, 300);

timeoutLoop();
messageLoop();
<pre id="log"></pre>


-2

我可以确认setTimeout(..., 0)的往返时间为4毫秒,但不是一致的。我使用了以下工作线程(从let w = new Worker('url/to/this/code.js'开始,用w.terminate()停止)。

在前两轮中,暂停时间小于1毫秒,然后我得到一个8毫秒左右的范围,然后每次迭代都保持在4毫秒左右。

为了减少等待时间,我将yieldPromise执行器移到了工作负载的前面。这样,setTimeout()可以保持其最小延迟,而不会使工作循环暂停时间超过必要的时间。我想工作负载必须长于4毫秒才能有效。除非捕获取消消息是工作负载... ;-)

结果:仅有约0.4毫秒的延迟。即至少减少10倍。1

'use strict';
const timer = performance.now.bind(performance);

async function work() {
    while (true) {
        const yieldPromise = new Promise(resolve => setTimeout(resolve, 0));
        const start = timer();
        while (timer() - start < 500) {
            // work here
        }
        const end = timer();
        // const yieldPromise = new Promise(resolve => setTimeout(resolve, 0));
        await yieldPromise;
        console.log('Took this time to come back working:', timer() - end);
    }
}
work();


1 浏览器不是限制了计时器的分辨率吗?那就没有办法测量更进一步的改进了...


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接