Important
This proposal has been subsumed by the Concurrency Control Proposal.
Stage: 0
Champion: Luca Casonato (@lucacasonato)
Author: Luca Casonato (@lucacasonato)
This is a proposal for adding semaphores to JavaScript.
Semaphores are a synchronization primitive that can be used to control access to a shared resource. In many languages, these are used to coordinate access to shared resources across multiple threads. In JavaScript, this is less common - but you often want to coordinate access to a shared resource across multiple asynchronous operations on the same thread.
For example, you might want to limit the number of concurrent HTTP requests you're making to a server, or the number of concurrent disk writes you're performing. You might want to ensure that only one operation is reading from a file at a time, or that only one operation is writing to a file at a time.
Most languages have a semaphore construct.
- In C#, the
Semaphore
class is used to control access to a shared resource. - In Python, the
threading.Semaphore
class is used to control access to a shared resource. - In Java, the
Semaphore
class is used to control access to a shared resource. - In Rust, the
tokio::sync::Semaphore
struct is used to control access to a shared resource.
There is a good mix here between synchronous and asynchronous APIs - Java, Python, and C# all provide synchronous APIs that block the current thread until a token is available, while Rust provides an asynchronous API that returns a future that resolves when a token is available.
There are multiple NPM packages that provide a semaphore implementation for JavaScript.
- The
semaphore
package provides a callback based semaphore API. - The
@shopify/semaphore
package provides a promise based semaphore API that looks very similar to the one proposed here. The main difference is thatrelease()
returns a promise that resolves when the token is re-acquired - I have not seen this pattern in other semaphore implementations, so it is not included in this proposal.
This proposal adds a Semaphore
class to JavaScript. A Semaphore
has a
limit
property, which is the maximum number of "tokens" that can be acquired
from the semaphore at once.
A Semaphore
has an acquire()
method. This method returns a promise that
resolves when a token is available from the semaphore. If the semaphore is at
its limit, the promise will not resolve until a token is available. The
acquire()
method returns a Sempahore.Permit
object.
The Sempahore.Permit
object has a synchronous release()
method, which
releases the token back to the semaphore. Additionally, the Sempahore.Permit
object also implements the disposable protocol by having a [Symbol.dispose]
method (which is an alias for release()
).
const semaphore = new Semaphore(5);
async function doWork() {
const guard = await semaphore.acquire();
// Do some work
guard.release();
}
The Semaphore
class also has a with()
method, which is a convenience method
for acquiring a token, doing some work, and then releasing the token.
const semaphore = new Semaphore(5);
async function doWork() {
await semaphore.with(async () => {
// Do some work
});
}
The Semaphore
class also has a wrap()
method, which is a convenience method
for wrapping a function to limit concurrency with this semaphore.
const semaphore = new Semaphore(5);
const wrappedFunction = semaphore.wrap(async () => {
// Do some work
});
async function doWork() {
await wrappedFunction();
}
interface Semaphore {
new (limit: number): Semaphore;
limit: number;
acquire(): Promise<Semaphore.Permit>;
with<T>(fn: () => Promise<T>): Promise<T>;
wrap<Args, T>(fn: (...args: Args) => T | Promise<T>): (...args: Args) => Promise<T>;
}
namespace Semaphore {
interface Permit {
release(): void;
[Symbol.dispose](): void;
}
}
An explicit acquire()
and release()
mechanism is more flexible, but it has
the footgun of a user forgetting to release the token. This can lead to
deadlocks if the release()
method is not called.
This also poses the question of whether if Semaphore.Permit
is GC'd, should
the token be released back to the semaphore? This would prevent deadlocks, but
it would also make it harder to reason about the code - and it would expose GC
in a very prominent way.
Many languages provide a way to query the number of tokens available in a semaphore. This can be useful for debugging purposes, but it can also be useful for making decisions based on the number of tokens available. For example, if you have a best effort tracing system, you might want to only trace a request if there are tokens available in the tracing semaphore.
Should the Semaphore
have a way to "try acquire" a token, returning null
if the semaphore is at its limit?
Motivation for this is similar to the previous question - it can be useful for making decisions based on the number of tokens available.
Should the Semaphore
be sharable across agents in an agent cluster to allow for coordination across multiple agents (threads)?
This would enable simple cross agent coordination. On the web platform, this means coordination between web workers.
If so, this would only be allowed on the web platform when shared memory is available.
This would allow for a way to cancel an acquisition if the reason for acquisition is no longer valid. For example, a timeout could be implemented by cancelling the acquisition if the timeout is reached.
This is not strictly necessary, as one can just immediately dispose the permit
object when acquire()
resolves and the task is no longer needed, or by
immediately disposing the permit object in the with()
block.
This would allow for synchronous code to acquire a token from the semaphore. This may be useful for Wasm code that is compiled from native code that uses semaphores.
On the web platform, like Atomics.wait
this would not be allowed on the main
thread.
Many languages provide a way to acquire multiple tokens at once. This can be useful when not all operations have the same cost. For example, you might want to acquire 1 tokens for a read operation and 4 tokens for a write operation.
Semaphore
vsAtomics.Semaphore
Semaphore.Permit
vsSemaphore.Token
vsSemaphore.Guard