Atomics in Javascript
tldr; Atomics object ensures indivisible operations, avoiding concurrency bugs.
const buffer = new SharedArrayBuffer(4);
const arr = new Int32Array(buffer);
arr[0] = 0;
// Non-atomic version
setTimeout(() => { arr[0]++; }, 0); // Non-atomic
setTimeout(() => { arr[0]++; }, 0); // Race condition risk
setTimeout(() => console.log(arr[0]), 100); // May log 1 or 2
// Atomic version
setTimeout(() => { Atomics.add(arr, 0, 1); }, 0);
setTimeout(() => { Atomics.add(arr, 0, 1); }, 0);
setTimeout(() => console.log(Atomics.load(arr, 0)), 100); // Always 2
Before we dive into Atomics, we need to understand SharedArrayBuffer. SharedArrayBuffer
is a fixed-length raw buffer that can be shared between threads. Unlike ArrayBuffer
, it can be shared across threads, requiring us to think about race conditions.
Atomic methods (such as add, store) makes sure operations on SharedArrayBuffer
are indivisible, preventing race conditions in multi-threaded environments, guaranting atomicity.
In the above snippet, Atomics.add
ensures both increment apply, always logging 2.
Note: Without proper headers (e.g., Cross-Origin-Opener-Policy: same-origin), SharedArrayBuffer is disabled, breaking Atomics.
Sychronize with wait and notify
Atomics.wait
pauses a thread until Atomics.notify
wakes it, giving us Linux-like synchronization. The nuance here is wait requires exact value.
const buffer = new SharedArrayBuffer(4);
const arr = new Int32Array(buffer);
// shared Int32Array, index in the array to store a value, value to store
Atomics.store(arr, 0, 0);
// Worker-like thread waits
setTimeout(() => {
console.log('Waiting...');
// shared Int32Array, index to watch, expected value to wait on, timeout
Atomics.wait(arr, 0, 0, 5000); // Waits for value 0
console.log('Woken!');
}, 0);
// Notify after delay
setTimeout(() => {
Atomics.store(arr, 0, 1);
// shared Int32Array, index to watch, index in array to notify on, The number of waiters to wake up.
Atomics.notify(arr, 0, 1); // Wakes 1 waiter
}, 2000);
Bitwise operations - or, xor and and.
Setting bits to 1 if currentValue and value are 1 (and), if exactly one value is 1 (xor), if either is 1 (or).
const buffer = new SharedArrayBuffer(4);
const arr = new Uint8Array(buffer);
arr[0] = 5; // 0101
console.log(Atomics.or(arr, 0, 2)); // 5 (0101 | 0010 = 0111)
console.log(Atomics.load(arr, 0)); // 7
console.log(Atomics.and(arr, 0, 3)); // 7 (0111 & 0011 = 0011)
console.log(Atomics.load(arr, 0)); // 3
Output:
5 // 5 (0101) OR 2 (0010) = 7 (0111)
7
7 // 7 (0111 & 0011 = 0011)
3
arr[0] = 5
is 0101 (8 bit representation for Uint8Array. Similarly, 2 is 0010.
Atomics.or(arr, 0, 2)
performs OR between current value 0101 and input value 0010.
Position 1: 0 | 0 = 0
Position 2: 1 | 0 = 1
Position 3: 0 | 1 = 1
Position 4: 1 | 0 = 1
Result: 0111 (decimal 7).
Result 7 is stored in the array, but the method returns 5 (old value).
Usage:
Atomics
object is handy when dealing with shared buffers like canvas animations or synchronizing states in multiplayer games. Also, it can be used to offload some heavy tasks like image processing.
Here is an example scenario:
Two counters increment randomly, One worker waits for the counter to reach a threshold before resetting it, using Atomics.wait
. The main thread updates the UI with the counter's value, polled via Atomics.load
.
let counter = 0;
const buffer = new SharedArrayBuffer(4);
const arr = new Int32Array(buffer);
Atomics.store(arr, 0, 0);
// Initialize workers
const worker1 = new Worker("/workers/worker1.js");
const worker2 = new Worker("/workers/worker2.js");
// Pass buffer to workers
worker1.postMessage({buffer});
worker2.postMessage({buffer});
// Poll counter for UI
const interval = setInterval(() => {
const value = Atomics.load(arr, 0);
counter = value;
}, 10);
// worker1.js
const arr = new Int32Array(buffer);
const increment = () => {
Atomics.add(arr, 0, 1);
setTimeout(increment, Math.random() * 1000);
};
// worker2.js
const arr = new Int32Array(buffer);
const check = () => {
if (Atomics.load(arr, 0) >= 10) {
Atomics.store(arr, 0, 0);
Atomics.notify(arr, 0, Infinity);
setTimeout(check, 100);
} else {
Atomics.wait(arr, 0, arr[0], 5000);
setTimeout(check, 100);
}
};
Final thoughts:
Atomic operations can seem complex and daunting, especially with a simpler alternative - PostMessage
where data can be shared among threads by sending messages. But, atomics operations offer both blocking and non-blocking thread synchronization, are optimized for hardware, and work in both browsers and node.js (with worker_threads).
It opens a portal to a multi-threaded powerhouse, giving us an ability to build thread-safe and high-performance applications.