Communicating With a Web Worker Without Yielding To The Event Loop #

I recently came across James Friends’s work on porting the Basilisk II classic Mac emulator to run in the browser. One thing that I liked about his approach is that it uses SharedArrayBuffer to allow the emulator to run in a worker with minimal modifications. This system can also be extended to use Atomics.wait and Atomics.notify to implement idlewait support in the emulator, significantly reducing its CPU use when the system is in the Finder or other applications that are mostly waiting for user input.

James’s work is from 2017, which is from before the Spectre/Meltdown era. Browsers have since then disabled SharedArrayBuffer and then brought it back with better safety/isolation. The exception to this is (not surprisingly) Safari. Though there have been some signs of life in the WebKit repository, it’s unclear when/if it will arrive.

I was hoping to resurrect James’s emulator to run in all modern browsers, but having to support an entirely different code path for Safari (e.g. using Asyncify) did not seem appealing.

At a high level, this diagram shows what the communication paths between the page and the emulator worker are:

Page and worker communication

Sending the output is possible even without SharedArrayBufferpostMessage can be used even though the worker never yields to the event loop (because the receiving page does). The problem is going in the other direction — how can the worker know about user input (or other commands) if it can’t receive a message event.

I was going through the list of functions available to a worker when I was reminded of importScripts1. As its documentation says, this synchronously imports (and executes) scripts, thus it does not require yielding to the event loop. The problem then becomes: how can the page generate a script URL that encodes the commands that it wishes to send? My first thought was to have the page construct a Blob and then use URL.createObjectURL to load the script. However, blobs are immutable and the contents (passed into the constructor) are read in eagerly. This means that while it’s possible to send one blob URL to the worker (by telling it what the URL is before it starts its while (true) {...} loop), it’s not possible to tell it about any more (or somehow “chain” scripts together).

After thinking about it more, I wondered if it’s possible to use a service worker to handle the importScripts request. The (emulator) worker could then repeatedly fetch the same URL, and rely on the service worker to populate it with commands (if any). The service worker has a normal event loop, thus it can receive message events without any trouble. This diagram shows how the various pieces are connected:

Page, worker and service worker communication

This demo (commit) shows it in action. As you can see, end-to-end latency is not great (1-2ms, depending on how frequently the worker polls for commands), but it does work in all browsers.

I then implemented this approach as a fallback mode for the emulator (commit), and it appears to work surprisingly well (the 1-2ms of latency is OK for keyboard and mouse input). As a bonus, it’s even possible (commit) to use a variant of this approach to implement idlewait support without Atomics, thus reducing the CPU usage even in this fallback mode.

You can see the emulator at mac.persistent.info (you can force the non-SharedArrayBuffer implementation with the use_shared_memory=false query parameter). Input responsiveness is still pretty good, compared with the version (commit) that uses emscripten_set_main_loop and regularly yields to the browser. Of course, it would be ideal if none of these workarounds were necessary — perhaps WWDC 2022 will bring us cross-origin isolation to WebKit.

Update on 2022-03-31: Safari 15.2 added support for SharedArrayBuffer and Atomics, thus removing the need for this workaround for recent versions. We didn't have to wait for WWDC 2022 after all.

  1. It later occurred to me that synchronous XMLHttpRequests might be another communication mechanism, but the effect would most be the same (the only difference is more flexibility in the output format, e.g. the contents of an ArrayBuffer could be sent over, thus better replicating the SharedArrayBuffer experience)

1 Comment

SharedArrayBuffer and Atomics require COEP/COOP headers so your approach isn't entirely obsolete.

Service workers don't get events for Synchronous XHR on Chromium and Safari, so that mechanism doesn't work.

Post a Comment