Understanding Concurrency in JavaScript Web Workers

When working with multi-threaded JavaScript using Web Workers and SharedArrayBuffer, synchronizing access to shared memory is critical. A common pattern is the Read-Write Lock (or Shared/Exclusive Lock), where multiple worker threads can concurrently write or read data (shared access), while a coordinator or main thread can request exclusive access ( exclusive lock) to perform sensitive operations like taking a consistent snapshot.

The Problem with Naive Atomic Checks

A frequent mistake when trying to lock a SharedArrayBuffer is separating the check condition and state modification into multiple steps. For instance:

// Worker thread (Problematic Approach)
Atomics.wait(i32a, LOCK, 1); // Check lock
Atomics.add(i32a, WRITERS, 1); // Increment writer count

This creates a classic race condition. The main thread can set LOCK = 1 immediately after the worker checks Atomics.wait() but before the worker executes Atomics.add(). The worker then proceeds to write to the memory buffer while the main thread assumes it has exclusive access to take a snapshot, resulting in corrupted or inconsistent data reads.

The Solution: Bit-Packed Lock State with Compare-And-Swap

To eliminate this race condition, we must make state updates atomic using Atomics.compareExchange() (Compare-And-Swap / CAS). Instead of separating the lock status and the active worker count across multiple array indices, we can store both pieces of state inside a single 32-bit integer using bit manipulation:

  • Bit 30 (Exclusive Bit): Set to 1 when the main thread requests or holds exclusive access.
  • Bits 0–29 (Active Workers Count): Keeps track of how many workers currently hold shared access.

Implementation: Shared/Exclusive Lock

Below is a clean, reliable implementation of a shared lock pattern for Web Workers and the main thread.

const LOCK_INDEX = 0;
const EXCLUSIVE_BIT = 1 << 30;

// --- WORKER METHODS (Shared Lock) ---

function acquireSharedLock(i32a) {
  while (true) {
    const current = Atomics.load(i32a, LOCK_INDEX);
    
    // 1. If exclusive bit is set, wait for main thread to unlock
    if ((current & EXCLUSIVE_BIT) !== 0) {
      Atomics.wait(i32a, LOCK_INDEX, current);
      continue;
    }
    
    // 2. Atomically increment worker count ONLY if exclusive bit is still 0
    if (Atomics.compareExchange(i32a, LOCK_INDEX, current, current + 1) === current) {
      break; // Successfully acquired shared lock!
    }
  }
}

function releaseSharedLock(i32a) {
  const prev = Atomics.sub(i32a, LOCK_INDEX, 1);
  
  // If main thread is waiting for exclusive access and we are the last active worker, notify it
  if ((prev & EXCLUSIVE_BIT) !== 0 && (prev & ~EXCLUSIVE_BIT) === 1) {
    Atomics.notify(i32a, LOCK_INDEX, 1);
  }
}

// --- MAIN THREAD METHODS (Exclusive Lock) ---

async function acquireExclusiveLock(i32a) {
  // 1. Signal intent for exclusive lock by setting the exclusive bit
  while (true) {
    const current = Atomics.load(i32a, LOCK_INDEX);
    if ((current & EXCLUSIVE_BIT) === 0) {
      if (Atomics.compareExchange(i32a, LOCK_INDEX, current, current | EXCLUSIVE_BIT) === current) {
        break;
      }
    } else {
      break;
    }
  }
  
  // 2. Wait until all active workers finish
  while (true) {
    const current = Atomics.load(i32a, LOCK_INDEX);
    const activeWorkers = current & ~EXCLUSIVE_BIT;
    if (activeWorkers === 0) {
      break; // Fully acquired exclusive access!
    }
    // Yield execution (using scheduler or async wait) until notified
    await new Promise(resolve => setTimeout(resolve, 1));
  }
}

function releaseExclusiveLock(i32a) {
  // Clear the exclusive bit
  while (true) {
    const current = Atomics.load(i32a, LOCK_INDEX);
    const next = current & ~EXCLUSIVE_BIT;
    if (Atomics.compareExchange(i32a, LOCK_INDEX, current, next) === current) {
      break;
    }
  }
  // Wake up all waiting worker threads
  Atomics.notify(i32a, LOCK_INDEX, Infinity);
}

How This Meets All Lock Goals

  • Worker Concurrency: Workers can simultaneously increment the active worker count and execute calculations in parallel without blocking each other.
  • Preventing New Workers: Once the main thread sets the EXCLUSIVE_BIT, any worker attempting to call acquireSharedLock will hit `Atomics.wait()` and yield execution.
  • Safe Snapshotting: Main waits until the worker count drops to 0 before taking a snapshot of the buffer, guaranteeing consistent memory state.

Alternative Architecture: Double Buffering

If lock contention heavily degrades your application's performance, consider Double Buffering or ping-pong buffers instead of locking. In a double-buffering architecture, workers write to Buffer A while the main thread reads from Buffer B. Once processing completes, the main thread simply swaps the pointers or references to the buffers without stopping active worker loops.