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.

Posted on Jan 20, 2024