Fast inline semaphores and mutexes. See comparisons and benchmarks.
Semaphores limit simultaneous access to code and resources (e.g. a file) among multiple concurrent tasks.
Create or retrieve a semaphore by calling getSemaphore
with optional key and concurrency limit.
const sem = getSemaphore('key', 1)
Use the acquire
and release
methods to limit access.
await sem.acquire()
// access here is limited to one task at a time
sem.release()
We use semaphores here to prevent multiple requests to an API for the same resource.
The first calls to fetchPokemon
will acquire access to the protected code. Subsequent calls will wait, then return the data from the cache.
We use a key to allow access based on the name. This lets ditto
and snorlax
run simultaneously.
import { getSemaphore } from '@henrygd/semaphore'
const cache = new Map()
for (let i = 0; i < 5; i++) {
fetchPokemon('ditto')
fetchPokemon('snorlax')
}
async function fetchPokemon(name) {
// get semaphore with key based on name
const sem = getSemaphore(name)
// acquire access from the semaphore
await sem.acquire()
try {
// return data from cache if available
if (cache.has(name)) {
console.log('Cache hit:', name)
return cache.get(name)
}
// otherwise fetch from API
console.warn('Fetching from API:', name)
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`)
const json = await res.json()
cache.set(name, json)
return json
} finally {
// release access when done
sem.release()
}
}
/**
* Creates or retrieves existing semaphore with optional key and concurrency level.
*
* key - Key used to identify the semaphore. Defaults to `Symbol()`.
* concurrency - Maximum concurrent tasks allowed access. Defaults to `1`.
*/
function getSemaphore(key?: any, concurrency?: number): Semaphore
interface Semaphore {
/** Returns a promise that resolves when access is acquired */
acquire(): Promise<void>
/** Release access to the semaphore */
release(): void
/** Returns the total number of tasks active or waiting for access */
size(): number
}
Keyed semaphores are held in a Map
and deleted from the Map
once they've been acquired and fully released (no waiting tasks).
If you need to reuse the same semaphore even after deletion from the Map
, use a persistent variable instead of calling getSemaphore
again.
Concurrency is set for each semaphore on first creation via getSemaphore
. If called again using the key for an active semaphore, the concurrency argument is ignored and the existing semaphore is returned.
Note that we're looking at libraries which provide a promise-based locking mechanism, not callbacks.
Library | Version | Bundle size (B) | Keys | Weekly Downloads |
---|---|---|---|---|
@henrygd/semaphore | 0.0.1 | 267 | yes | ¯\_(ツ)_/¯ |
async-mutex | 0.5.0 | 4,758 | no | 1,639,071 |
async-sema | 3.1.1 | 3,532 | no | 1,258,877 |
await-semaphore | 0.1.3 | 1,184 | no | 60,449 |
@shopify/semaphore | 3.1.0 | 604 | no | 29,089 |
If there's a library you'd like added to the table or benchmarks, please open an issue.
All libraries run the same test. Each operation measures how long it takes a binary semaphore with 1,000 queued acquire
requests to allow and release all requests.
This test was run in Chromium. Chrome and Edge are the same. Safari is more lopsided with Vercel's async-sema
dropping to third. Firefox, though I love and respect it, seems to be hard capped by slow promise handling, with async-mutex
not far behind.
You can run or tweak for yourself here: https://jsbm.dev/8bBxR1pBLw0TM
Note:
await-semaphore
is extremely slow for some reason and I didn't want to include it in the image because it seems excessive. Not sure what's happening there.
Uses oha to make 1,000 requests to each worker. Each request creates a semaphore and resolves 5,000 acquisitions / releases.
This was run locally using Wrangler. Wrangler uses the same workerd runtime as workers deployed to Cloudflare, so the relative difference should be accurate. Here's the repo for this benchmark.
Library | Requests/sec | Total (sec) | Average | Slowest |
---|---|---|---|---|
@henrygd/semaphore | 941.8135 | 1.0618 | 0.0521 | 0.0788 |
async-mutex | 569.5130 | 1.7559 | 0.0862 | 0.1251 |
async-sema | 375.7332 | 2.6615 | 0.1308 | 0.1818 |
@shopify/semaphore | 167.8239 | 5.9586 | 0.2925 | 0.4063 |
await-semaphore* | n/a | n/a | n/a | n/a |
*
await-semaphore
does not work with concurrent requests.
@henrygd/queue
- Tiny async queue with concurrency control. Like p-limit or fastq, but smaller and faster.