How to Build a Shared/Exclusive Lock with JavaScript Atomics and SharedArrayBuffer
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
1when 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 callacquireSharedLockwill hit `Atomics.wait()` and yield execution. - Safe Snapshotting: Main waits until the worker count drops to
0before 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.