A common complaint of modern web apps is the concept of jank; web pages being unresponsive to user input and frame rates being low. Left unmitigated, this problem leads to a poor quality experience for end users of our web applications. You might ask: is this what causes jank? One common cause apart from complex/inefficient animations are blocking operations.
JavaScript is single threaded, and as such, it can only be executing one thing at a time, so if a long-running task blocks the browser’s main thread, the browser struggles to render. For example, if we were to traverse and perform operations on a large data structure, this might cause rendering to choke and our Frames Per Second (FPS) to nosedive.
To improve animation performance, we can use requestAnimationFrame
to schedule DOM and styling updates before a render. For shorter tasks, we can use requestIdleCallback
to schedule work when the browser is idle (usually between frame renders). However, because the browser attempts to render a frame every 16 milliseconds (to achieve 60 FPS) if the tasks are long in duration, an application ends up skipping frames. One solution to this problem is to use Web Workers, which are ideal for long-running tasks as they run in a separate thread, allowing the main thread to respond to user input and rendering frames. Web Workers allow parallel execution in the browser context. However, Web Workers are not without their drawbacks; for example, there is a cost of transferring data to and from the Worker thread. The browser uses the Structured Clone algorithm to duplicate an object. This cost, depending on circumstance, can outweigh the benefit of transferring to the Worker thread.
What if we could avoid copying data?
One way to circumvent this problem is by leveraging SharedArrayBuffer, part of the ECMAScript 2017 standard. For JavaScript developers, this construct allows us to create views on shared, typed memory. Rather than copying the data from the main thread to the Worker and back again, we can update the same shared memory from both sides. In practice creating a SharedArrayBuffer and corresponding shared array might look something like this:
// Creating a shared buffer
const length = 10;
// Get the size we want in bytes for the buffer
const size = Int32Array.BYTES_PER_ELEMENT * length;
// Create a buffer for 10 integers
const sharedBuffer = new SharedArrayBuffer(size);
const sharedArray = new Int32Array(sharedBuffer);
Now we have a shared buffer that we can pass to a worker context and also have an integer Array that leverages that shared buffer. To pass this buffer reference to the worker:
// main.js
worker.postMessage(sharedBuffer);
This buffer allows us to create another shared array on the worker side:
// worker.js
constsharedArray = new Int32Array(m.data);
We’ve created a corresponding array on both the main and worker side, but how do we access and sensibly update that array? In the next section, we look at the solution to accessing and updating SharedArrayBuffers from multiple contexts.
Introducing Atomics
The problem with being able to update things from multiple threads is that we can end up with a situation where we are racing around updating and reading memory in a difficult to control manner. This problem gets solved by Atomics. Atomic operations ensure that we have a standardized way of reading and writing data in a predictable order, waiting for such operations to have finished before starting the next. Essentially, it helps us handle race conditions.
Let’s say from the main thread we wanted to set an array of ten 0s; we could do that using Atomic.load
:
// main.js
const worker = new Worker('worker.js');
const length = 10;
const size = Int32Array.BYTES_PER_ELEMENT * length;
// Create a buffer for 10 integers
const sharedBuffer = new SharedArrayBuffer(size);
const sharedArray = new Int32Array(sharedBuffer);
for (let i = 0; i < 10; i++) {
Atomics.store(sharedArray, i, 0);
}
worker.postMessage(sharedBuffer);
You can then access the changed data from the worker:
// worker.js
self.addEventListener('message', (event) => {
const sharedArray = new Int32Array(event.data);
for (let i = 0; i < 10; i++) {
const arrayValue = Atomics.load(sharedArray, i);
console.log(`The item at array index ${i} is ${arrayValue}`);
}
}, false);
What if we wanted to update the array from the worker? We have two options for these updates using Atomics. We can use store
which we’ve seen used previously, or we can use exchange
. The difference here is that store
returns the value gets stored, and exchange
returns the value that gets replaced. Let’s see how that works in practice:
// worker.js
self.addEventListener('message', (event) => {
const sharedArray = new Int32Array(event.data);
for (let i = 0; i < 10; i++) {
if (i%2 === 0) {
const storedValue = Atomics.store(sharedArray, i, 1);
console.log(`The item at array index ${i} is now ${storedValue}`);
} else {
const exchangedValue = Atomics.exchange(sharedArray, i, 2);
console.log(`The item at array index ${i} was ${exchangedValue}, now 2`);
}
}
}, false);
Now we can read and update the array from both the main thread and the worker thread. Atomics have a few other methods we can use to our advantage for managing our new shared arrays. Two of the most useful methods are wait
and wake
. wait
allows us to wait on a change on an array index and then continue with operations. In practice this might look something like this on the worker side:
self.addEventListener('message', (event) => {
const sharedArray = new Int32Array(event.data);
const arrayIndex = 0;
const expectedStoredValue = 50;
// An optional 4th argument can be passed which is a timeout
Atomics.wait(sharedArray, arrayIndex, expectedStoredValue);
// Log the new value
console.log(Atomics.load(sharedArray, arrayIndex));
}, false);
Here we are waiting on a change on arrayIndex
0 where the expected stored value is to be 50. Then we can tell it to wake up from the main thread when we’ve changed the value at the index:
const newArrayValue = 100;
Atomics.store(sharedArray, 0, newArrayValue);
// The index that is being waited on
const arrayIndex = 0;
// The first agent waiting on the value
const queuePos = 1;
Atomics.wake(sharedArray, arrayIndex, queuePos);
Other functions get provided for convenience such as add
and sub
which add or subtract from the array index respectively. If you are interested in Bitwise operations, there are some provided including or
, and
and xor
.
What about transferring other data types?
Much of the time we might want to do operations with data that isn’t floats or integers. A typical case, for example, is strings. To pass strings, we need to convert them to a numeric representation, for example a known standard for encoding such as UTF-16. We can achieve this by following by doing the following:
function sharedArrayBufferToUtf16String(buf) {
const array = new Uint16Array(buf);
return String.fromCharCode.apply(null, array);
}
function utf16StringToSharedArrayBuffer(str) {
// 2 bytes for each char
const bytes = str.length *2;
const buffer = new SharedArrayBuffer(bytes);
const arrayBuffer = new Uint16Array(buffer);
for (let i = 0, strLen = str.length; i < strLen; i++) {
arrayBuffer[i] = str.charCodeAt(i);
}
return { array: arrayBuffer, buffer: buffer };
}
const exampleString = "Hello world, this is an example string!";
const sharedArrayBuffer = utf16StringToSharedArrayBuffer(exampleString).buffer;
const backToString = sharedArrayBufferToUtf16String(sharedArrayBuffer);
This approach works well, but it’s also constrained to UTF-16. Here some might ask about using native browser APIs and a more straightforward way of doing this. Valid native browser APIs exist for this behavior, namely TextDecoder and TextEncoder. Unfortunately, we cannot (currently) use these directly alongside SharedArrayBuffers. This limitation is a shame as it would allow us to do various encodings and leverage the speed of native execution. If you require UTF-8 the following should be sufficient for use with SharedArrayBuffers:
function encodeUf8StringToSharedArrayBuffer(string) {
// Calculate the byte size of the UTF-8 string
let bytes = string.length;
for (let i = string.length -1; i <= 0; i--) {
const code = string.charCodeAt(i);
if (code > 0x7f && code <= 0x7ff) {
bytes++;
else if (code > 0x7ff && code <= 0xffff) {
bytes+=2;
if (code >= 0xdc00 && code <= 0xdfff) {
i--; // trail surrogate
}
}
const buffer = new SharedArrayBuffer(bytes);
const arrayBuffer = new Uint8Array(buffer);
const encoded = unescape(encodeURIComponent(string));
for (var i = 0; i < encoded.length; i++) {
arrayBuffer[i] = encoded[i].charCodeAt(0);
}
return { array: arrayBuffer, buffer: buffer };
}
function decodeUtf8StringFromSharedArrayBuffer(array) {
var encodedString = String.fromCharCode.apply(null, array);
var decodedString = decodeURIComponent(escape(encodedString));
return decodedString;
}
In theory, you could take these methods a step further to break down functions or other types using toString
on the encoding side and new Function("return " + decodedFuncStr)()
on the returned value to generate a new function, but this would be unadvised unless you have strong reasons for doing so. Also, functions would have to be pure to work correctly.
Browser Support
Many browsers support SharedArrayBuffers, however, because of issues with the Spectre and Meltdown exploits disclosed in January 2018, most vendors have disabled them. As a reference, you can read the post from the Webkit (Safari) team about how it affects browsers. Mitigations have been worked on by many of the browser vendors, and Chrome recently switched SharedArrayBuffers back on, with Firefox turning it back on behind a flag from version 57. Edge plans to restore SharedArrayBuffers when they feel confident it cannot get exploited on the platform. Overall there is a willingness to support SharedArrayBuffers amongst browser vendors, but the hangover from these exploits is still apparent.
Conclusion
Here you can see how we can leverage SharedArrayBuffers to share memory between various thread contexts. We have also demonstrated how we can avoid race conditions and do predictable updates to an array using the Atomics methods. Furthermore, we explored how we can, with a bit of work, share strings between execution contexts allowing for more flexibility in dealing with data between execution contexts. Overall, the future of SharedArrayBuffers and Atomics looks bright. With mitigations being worked on for Spectre and Meltdown, and with SharedArrayBuffers and Atomics already being re-enabled in Chrome, we see a push for their comeback.
Next Steps
Are you looking for help building applications that leverage modern best practices? Contact us to discuss how we can help!