ECMAScript proposal: Synchronization Primitives

This proposal aims to enable developers to wrap JavaScript values in synchronization primitives to allow sending them across threads & JavaScript contexts.

Status

Stage: Not proposed yet

Authors:

  • David Alsh

Champions:

Motivation: Empowering Multi-threading

Community Usage and Demand

Limits of postMessage and cloning

Proposal

Mutex

The addition of a Mutex primitive that is considered "transferrable" (like SharedArrayBuffer).

A Mutex captures a JavaScript value and ensures that access to that value can only occur when a lock for it is acquired.

While a lock is held, all additional attempts to access the lock will wait until the lock is released.

This allows for mutable data to be defined in one JavaScript context and interacted with from external JavaScript contexts.

const m = new Mutex({
  i: 0,
  increment() {
    this.i++
  }
})

// Acquire a lock, no one else can use the value
const guard = await m.lock()
console.log(guard.value.i)

// Complex values like class instances, functions can be stored
guard.value.increment()
console.log(guard.value.i)

// Release the lock so others can access the value
guard.release()

In the example above, a Mutex is created that wraps an object. The value within the Mutex can be obtained via Mutex.lock() and the lock released with Mutex.release()

While a lock is held, all additional attempts to access the lock will wait until the lock is released.

Once a value is captured by a Mutex, all additional references become null (input wanted) ensuring the value can only be accessed via the lock

// main.js
const m = new Mutex({
  i: 0,
})

// Spawn a worker thread and transfer the lock by reference
const worker = new Worker('./worker.js')
worker.postMessage(null, [m])

// Poll the lock and inspect the value
setInterval(async () => {
  const guard = await m.lock()
  console.log(guard.value.i)
  guard.release()
}, 100)
// worker.js
onmessage = async (m) => {
  const guard = await m.lock()
  guard.value.i++
  guard.release()
}

In the example above the Mutex is transferred into the context of a Worker which obtains the value, mutates it, and releases the lock.

RwLock

The addition of a RwLock primitive that is considered "transferrable". This captures a JavaScript value and ensures that access to that value can only occur when a read or write lock for it is acquired.

A RwLock can have unlimited reads OR only one write.

This allows for mutable data to be defined in one JavaScript context and interacted with from external JavaScript contexts. This is for use cases where the data is constructed in one phase of an application's life cycle and read from multiple threads later.

// main.js
const rw = new RwLock([])

// Do "work"
const guard = await rw.write()
for (let i = 0; i < 100; i++) guard.value.push(i)
guard.release()

// Spawn workers that will read the contents
for (let i = 0; i < 10; i++) {
  const worker = new Worker('./worker.js')
  worker.postMessage(null, [rw])
}
// worker.js
onmessage = async (m) => {
  // The workers can all "read" at the same time
  // as unlimited "read" locks can be granted simultaneously
  const guard = await m.read()
  console.log(guard.value.i)
  guard.release()
}

Symbol.dispose / using

This proposal stacks nicely with Symbol.dispose

async function main() {
  const m = new Mutex(42)

  await using guard = m.lock()
  console.log(guard.value)
  // guard is automatically unlocked
  // at the end of the scope
}

Notes on Garbage Collection

Due to the implementations of various JavaScript engines using isolated heaps with independent garbage collectors, it would be best if these synchronization primitives were implemented using reference counted smart pointers that would persist until all references were removed.

This would allow the values to work across multiple session contexts; tabs/windows via BroadcastChannel, in the background via ServiceWorker and in the current session via Worker contexts.

Up For Debate

What do do if there are already references to an object after it has been wrapped?

const a = {}
const b = a

const m = new Mutex(a)
console.log(b) // ??

What to do if a value escapes from a lock?

let a

const m = new Mutex({})
const b = await m.lock()
a = b
m.release()

console.log(a) // ??

FAQ

Does JavaScript need synchronization primitives?

What about SharedArrayBuffer?