8

I am trying to write a web worker that performs an interruptible computation. The only way to do that (other than Worker.terminate()) that I know is to periodically yield to the message loop so it can check if there are any new messages. For example this web worker calculates the sum of the integers from 0 to data, but if you send it a new message while the calculation is in progress it will cancel the calculation and start a new one.

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

This works but it is appallingly slow. On average each iteration of the while loop takes 4 ms on my machine! That is a pretty huge overhead if you want cancellation to happen quickly.

Why is this so slow? And is there a faster way to do this?

Timmmm
  • 88,195
  • 71
  • 364
  • 509
  • 2
    `setTimeout` has a minimum delay, so you're better off not using it. You can just do `new Promise(resolve => resolve())` which will pop that Promise immediately. It should be faster. – VLAZ Apr 21 '20 at 08:11
  • I actually already tried that and it doesn't work. I think it's because promises are scheduled as microtasks which get executed *before* returning to the event loop, so it never really yields to new messages. – Timmmm Apr 21 '20 at 08:17
  • On Chrome-Windows, the average time between initialization of a process and its cancellation is 1 to 2 ms, very rarely more. Yep, `Promise.resolve` doesn't work – CertainPerformance Apr 21 '20 at 08:19
  • What do you mean by "initialization of a process"? I'm not starting any new processes. – Timmmm Apr 21 '20 at 08:21
  • Nothing will guarantee that your code will run immediately after 0-1ms. It queues your task to run whenever the thread is available to process it. It must be doing something else in those 4ms, your code or other 3rd party code must be using the thread. – Fasani Apr 21 '20 at 08:22
  • a) It's a web worker with literally no other code in it, and b) Surely `setTimeout(0)` is special-cased to immediately queue a task? In which case the event loop shouldn't yield the thread - it should immediately process it. – Timmmm Apr 21 '20 at 08:24
  • Had an idea for the worker to await a message to itself, but `self.postMessage('resolve');` doesn't seem to register in the onmessage listener. There may not be a better way than `setTimeout` in a worker, but there might be one *outside* a worker – CertainPerformance Apr 21 '20 at 08:27
  • @Timmmm A new process gets started when an old one is canceled - that's what your current code does. – CertainPerformance Apr 21 '20 at 08:27
  • @CertainPerformance: No it doesn't. All of this code runs in a single thread. I suggest you read up about the Javascript event loop. – Timmmm Apr 21 '20 at 08:30
  • When you receive a new message, your `onmessage` handler cancels the prior running process if there is one by reassigning `currentTask`, and then a new computation for `total` starts. That's what your code does, and it makes perfect sense, I don't get why you're denying it..? – CertainPerformance Apr 21 '20 at 08:34
  • Ah sorry I thought you were talking about [*processes*](https://en.wikipedia.org/wiki/Process_(computing)), given that we are talking about threads and whatnot. :-) – Timmmm Apr 21 '20 at 08:36
  • I didn't, but I'm guessing it is downvoted because it's a lot hackier and harder to use than the `MessageChannel` method and doesn't offer any benefits as far as I can see. – Timmmm Jun 11 '20 at 18:26
  • Are you kidding me? You use a promise based `setTimeout()` in the question yourself. I assume you are extremely familiar with such code.—I would also argue that the use of `setTimeout()` is more common to yield and more readable than sending an dummy message to yourself.—Nobody has to like my answer, but calling it “a lot hackier” is far-fetched. – Robert Siemer Jun 13 '20 at 00:16
  • Yeah exactly - I was looking for a faster solution than `setTimeout()`, because it is really slow. Sending a dummy message is much faster and better. I think arranging for `setTimeout()` to be called at least 4ms before you actually need it is not very readable. The dummy message is clearly the best solution. – Timmmm Jun 13 '20 at 17:37

4 Answers4

4

Yes, the message queue will have higher importance than timeouts one, and will thus fire at higher frequency.

You can bind to that queue quite easily with the 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>

Now, you may also want to batch several rounds of the same operation per event loop.

Here are a few reasons why this method works:

  • Per specs, setTimeout will get throttled to a minimum of 4ms after the 5th level of call, that is after the fifth iteration of OP's loop.
    Message events are not subject to this limitation.

  • Some browsers will make the task initiated by setTimeout have a lower priority, in some cases.
    Namely, Firefox does that at page loading, so that scripts calling setTimeout at this moment don't block other events ; they do even create a task queue just for that.
    Even if still un-specced, it seems that at least in Chrome, message events have a "user-visible" priority, which means some UI events could come first, but that's about it. (Tested this using the up-coming scheduler.postTask() API in Chrome)

  • Most modern browsers will throttle default timeouts when the page is not visible, and this may even apply for Workers.
    Message events are not subject to this limitation.

  • As found by OP, Chrome does set a minimum of 1ms even for the first 5 calls.


But remember that if all these limitations have been put on setTimeout, it's because scheduling that many tasks at such a rate has a cost.

Use this only in a Worker thread!

Doing this in a Window context will throttle all the normal tasks the browser has to handle, but which they'll consider less important, like Network requests, Garbage Collection etc.
Also, posting a new task means that the event loop has to run at high frequency and will never idle, which means more energy consumption.

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Ah interesting! I will give this a try, thanks! Btw, the slowness wasn't because of message priorities (does the JS event loop even have priorities?) - it is just because `setTimeout()` has a minimum timeout of 4 ms on Chrome (see my answer). – Timmmm Apr 21 '20 at 08:42
  • I guess priorities could affect things too but even it they didn't, `setTimeout()` has a minimum timeout which means it can't ever be very fast. Anyway thanks for your answer! – Timmmm Apr 21 '20 at 09:15
  • @Timmmm Since I wrote this answer I actually [deeply looked](https://stackoverflow.com/a/62188756/3702797) into how tasks priorities work in Chrome and Firefox, and turns out you were right, it's not really "priorities" that do matter here (it does only in FF and only for the first call at page load). The obvious 4ms threshold is the biggest culpirt, and then, setTimeout still has less "importance" as I said, but more in the sense that it will get throttled more heavily than message events (probably because it is actually more commonly used by web authors). – Kaiido Jun 15 '20 at 03:38
2

Why is this so slow?

Chrome (Blink) actually sets the minimum timeout to 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);

Edit: If you read further in the code, that minimum is only used if the nesting level is more than 5, however it does still set the minimum to 1 ms in all cases:

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

Apparently the WHATWG and W3C specs disagree about whether the minimum of 4 ms should always apply or only apply above a certain nesting level, but the WHATWG spec is the one that matters for HTML and it seems like Chrome has implemented that.

I'm not sure why my measurements indicate it still takes 4 ms though.


is there a faster way to do this?

Based on Kaiido's great idea to use another message channel you can do something like this:


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

I'm not totally happy with this code, but it does seem to work and is waaay faster. Each loop takes around 0.04 ms on my machine.

Timmmm
  • 88,195
  • 71
  • 364
  • 509
  • If you read where this value is used, it's only when the nesting level is higher than 5, which is actually just per specs: [step 11](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers:concept-task-2) – Kaiido Apr 21 '20 at 08:42
  • [What is minimum millisecond value of setTimeout?](https://stackoverflow.com/q/9647215) – VLAZ Apr 21 '20 at 08:42
  • @Kaiido Oh yeah, that's weird given that it is taking 4ms... and the link VLAZ posted. It does definitely always set a minimum of 1ms. And the comment clearly intended it to be for all timers. Maybe it is a bug! – Timmmm Apr 21 '20 at 08:46
0

Looking at the downvotes in my other answer, I tried to challenge the code in this answer with my new knowledge that setTimeout(..., 0) has a forced delay of about 4ms (on Chromium at least). I put a workload of 100ms in each loop and and scheduled setTimeout() before the workload, so that setTimeout()’s 4ms would already have passed. I did the same with the postMessage(), just to be fair. I also changed the logging.

And the result was surprising: while watching the counters the message method gained 0-1 iterations over the timeout method at the beginning, but it stayed constant even up to 3000 iterations. – That proves that a setTimeout() with a concurrent postMessage() can keep its share (in Chromium).

Scrolling the iframe out of scope changed the outcome: there were almost 10 times as many message-triggered workloads processed compared to timeout-based ones. That has probably to do with the browser‘s intention to hand less resources to JS out of view or in another tab etc.

On Firefox I see a workload processing with 7:1 message against timeout. Watching it or leaving it running on another tab does not seem to matter.

Now I moved the (slightly modified) code over to a Worker. And it turns out that the iterations processed via timeout-scheduling is exactly the same as the message-based-scheduling. On Firefox and Chromium I get the same results.

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>
Robert Siemer
  • 32,405
  • 11
  • 84
  • 94
-2

I can confirm the 4ms round trip time of setTimeout(..., 0), but not consistently. I used the following worker (start with let w = new Worker('url/to/this/code.js', stop with w.terminate()).

In the first two rounds the pause is sub 1ms, then I get one in the range of 8ms and then it stays around 4ms each further iteration.

To reduce the wait I moved the yieldPromise executor in front of the workload. This way setTimeout() can keep it’s minimum delay without pausing the work loop longer than necessary. I guess the workload has to be longer than 4ms to be effective. That should not be a problem, unless catching the cancel message is the workload... ;-)

Result: ~0.4ms delay only. I.e. reduction by at least factor 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 Isn’t the browser limiting the timer resolution to that range? No way to measure further improvements then...

Robert Siemer
  • 32,405
  • 11
  • 84
  • 94