Your database accepts 20 concurrent connections. Your service has 200 worker threads. You need the other 180 to wait rather than fail.
A mutex is the wrong tool: it permits exactly one. You want exactly twenty.
The pattern
A counter of permits. acquire() takes one or blocks; release() returns one
and wakes a waiter.
const pool = new Semaphore(20);
async function query(sql: string) { await pool.acquire(); try { return await db.run(sql); } finally { pool.release(); // ← in a finally, always }}Three permits, five arrivals:
3 permits
A takes a free permit and proceeds.
Two genuinely different uses
Counting — limit concurrency. The connection pool above. The count is a resource budget, and it starts full.
Signalling — one thread tells another something happened. The count starts at zero, and it is not a limit at all:
const ready = new Semaphore(0);
// Producerdata = compute();ready.release(); // "there is one item"
// Consumerawait ready.acquire(); // blocks until there isuse(data);This is a semaphore used as a one-way notification, and it is where the different-thread rule matters — the releaser and the acquirer are never the same thread.
The failure modes
Releasing more than you acquired. Nothing stops you, and the count silently exceeds the real limit. Now 25 connections are open against a 20-connection database and the failures appear over there.
Deadlock by permit. A task holding a permit that waits for another task needing one is the same cycle as any other deadlock — here the permits are the resource.
Unfairness. A plain semaphore makes no ordering promise; a thread can be skipped indefinitely under load. Use a fair semaphore if latency tails matter, and accept slightly lower throughput.
Prefer a bounded queue
For the producer–consumer case specifically, a bounded channel or blocking queue does the same job with none of the manual pairing:
const work = new BoundedQueue<Task>(20);await work.put(task); // blocks when full — that is the semaphore, built inTwo semaphores — one counting free slots, one counting items — is the classic implementation of exactly this, and hand-rolling it is how you meet all three failure modes above. Use the queue.
The place semaphores stay useful is the one at the top: a budget over something that is not a queue, like connections, API rate, or memory. There the count is the whole point, and there is nothing to enqueue.