有没有比 setTimeout(0) 更快的让 Javascript 事件循环的方法?

IT技术 javascript settimeout web-worker event-loop
2021-02-20 05:37:16

我正在尝试编写一个执行可中断计算的网络工作者。Worker.terminate()我知道这样做的唯一方法(除了)是定期让步给消息循环,以便它可以检查是否有任何新消息。例如,这个网络工作者计算从 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 毫秒!如果您希望取消快速发生,那将是一个相当大的开销。

为什么这么慢?有没有更快的方法来做到这一点?

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在第 5 级调用之后,即在 OP 循环的第五次迭代之后将被限制到最少 4 毫秒。
    消息事件不受此限制。

  • setTimeout在某些情况下,某些浏览器会使由 发起的任务具有较低的优先级。
    也就是说,Firefox 在页面加载setTimeout时会这样做,因此此时调用的脚本不会阻止其他事件;他们甚至为此创建了一个任务队列。
    即使仍然未指定,似乎至少在 Chrome 中,消息事件具有“用户可见”优先级,这意味着某些 UI 事件可能会先出现,但仅此而已。(使用scheduler.postTask()Chrome 中即将推出的API对此进行了测试

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

  • 正如 OP 所发现的那样即使前 5 次调用Chrome 也确实设置了至少 1 毫秒。


但请记住,如果所有这些限制都已设置setTimeout,那是因为以这样的速度安排这么多任务是有成本的。

仅在 Worker 线程中使用它!

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

啊有趣!我会试试这个,谢谢!顺便说一句,缓慢并不是因为消息优先级(JS 事件循环甚至有优先级吗?) - 这只是因为setTimeout()Chrome 上的最小超时时间为 4 毫秒(请参阅我的回答)。
2021-04-21 05:37:16
@Timmmm 因为我写了这个答案,我实际上深入研究了任务优先级在 Chrome 和 Firefox 中的工作方式,结果证明你是对的,这并不是真正重要的“优先级”(它只在 FF 中并且仅在第一次调用时起作用)在页面加载时)。明显的 4ms 阈值是最大的罪魁祸首,然后 setTimeout 仍然没有我所说的“重要性”,但更多的是它会比消息事件受到更严重的限制(可能是因为它实际上更常被网络作者使用)。
2021-05-01 05:37:16
我想优先级也会影响事情,但即使他们没有,setTimeout()也有最小超时时间,这意味着它不可能很快。无论如何感谢您的回答!
2021-05-20 05:37:16

为什么这么慢?

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

// 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);
}

我不是与此代码完全满意,但它似乎工作,是waaay更快。在我的机器上,每个循环大约需要 0.04 毫秒。

@Kaiido 哦,是的,这很奇怪,因为它需要 4 毫秒......并且发布了 VLAZ 链接。它肯定总是设置至少 1ms。并且该评论明确表示适用于所有计时器。也许这是一个错误!
2021-05-03 05:37:16
如果你读到哪里使用了这个值,只有当嵌套级别高于 5 时,这实际上只是根据规范:第 11 步
2021-05-07 05:37:16

查看我的其他答案中的否决票,我试图用我的新知识挑战此答案中的代码,知识setTimeout(..., 0)具有大约 4 毫秒的强制延迟(至少在 Chromium 上)。我在每个循环中放置了 100 毫秒setTimeout()的工作负载,在工作负载之前进行调度,因此setTimeout()4 毫秒已经过去了。postMessage()为了公平起见,我对 做了同样的事情我也改变了日志记录。

结果令人惊讶:在观察计数器时,消息方法在开始时比超时方法获得了 0-1 次迭代,但它保持不变,甚至高达 3000 次迭代。– 这证明setTimeout()了并发的 apostMessage()可以保持其份额(在 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>

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

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

为了减少等待,我将yieldPromise执行程序移到工作负载之前。这种方式setTimeout()可以保持最小延迟,而不会暂停工作循环超过必要的时间。我想工作负载必须超过 4ms 才能有效。这应该不是问题,除非捕获取消消息是工作量...... ;-)

结果:仅 ~0.4ms 延迟。即至少减少 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浏览器不是将计时器分辨率限制在该范围内吗?没有办法衡量进一步的改进然后......