38 - Semaphore: Bounded Concurrency
A semaphore is a generalized mutex: it issues a fixed number of permits. An effect wrapped with withPermits(k) cannot run until k permits are free, and the permits are returned when the effect completes, on success, failure, or interruption.
Use it to bound the concurrency of a resource: connection pools, rate-limited APIs, mutexes.
What you’ll work through
index.test.ts walks three beats: constructing a semaphore with Semaphore.make, gating a single effect with withPermits(1), and combining the semaphore with Effect.all + concurrency: "unbounded" to cap a pool of concurrent workers.
By the end you should be able to bound any resource — a connection pool, a rate-limited API, a mutex — without writing release logic, because permits return automatically on success, failure, and interruption.
Why this matters. Permits return on interruption thanks to the finalizer machinery in step 37. A semaphore cannot deadlock from a fiber that died holding a permit.
Concept
makeSemaphore(N) N permits
withPermits(1)(effect) borrow 1 permit, run effect, return permit withPermits(k)(effect) borrow k permits, run effect, return permits
permits = 3: [✓][✓][✓] three active tasks ◀──── 1 finishes one permit returned; the next waiter resumesA new permit is returned automatically when the effect ends, including when it is interrupted. You cannot leak a permit by forgetting to release it.
Semaphore.make — the mutex case
Semaphore.make(n) builds a semaphore holding n permits and returns it as an Effect<Semaphore>. The most common shape is n = 1 — a mutex — wrapped around a critical section with sem.withPermits(1)(...) so only one fiber at a time runs the guarded effect.
Permits are returned automatically on success, failure, and interruption.
import { Effect } from "effect"
const program = Effect.gen(function* () { // ┌─── Effect.Semaphore // ▼ const sem = yield* Semaphore.make(1) // 1 permit = mutex
// Two fibers race for the critical section; only one runs at a time. yield* Effect.all( [criticalSection, criticalSection].map((eff) => sem.withPermits(1)(eff)), { concurrency: "unbounded" }, )})Rule of thumb. Build the semaphore once — at service initialization or at the top of a scoped program — and share it. A semaphore constructed inside the guarded function is a new semaphore on every call and enforces nothing.
Your turn build a 1-permit semaphore, wrap two concurrent fibers with `sem.withPermits(1)(...)`, and assert their critical sections never overlap.
semaphore.withPermits
Wraps an effect so that it can only run when k permits are available.
// ┌─── Effect<string>// ▼const program = Effect.gen(function* () { const sem = yield* Semaphore.make(1) const critical = sem.withPermits(1)(Effect.succeed("inside")) return yield* critical})Details
withPermits(k) accepts an Effect<A, E, R> and returns an Effect<A, E, R>. Permits are acquired in FIFO order; pending callers form a queue.
When to Use
withPermits(1)on a 1-permit semaphore, a mutex.withPermits(1)on an N-permit semaphore, connection pool, rate limit.withPermits(k)for tasks that occupy multiple slots (e.g. an expensive operation that reserves two connections).
Composite Example: Connection Pool Cap
// ┌─── Effect<ReadonlyArray<number>>// ▼const program = Effect.gen(function* () { const pool = yield* Semaphore.make(5) const query = (id: number) => pool.withPermits(1)( Effect.sleep("5 millis").pipe(Effect.as(id * 2)), )
return yield* Effect.all( Array.from({ length: 20 }, (_, i) => query(i)), { concurrency: "unbounded" }, )})// 20 results, but at most 5 queries were in flight at any moment.The Effect.all concurrency is unbounded, but the semaphore is the real cap.
Bounding a batch with Semaphore.make
The standard pattern for bounding a batch of work — a pool of HTTP requests, parallel DB writes, concurrent calls to a rate-limited API — is Semaphore.make(N) paired with withPermits(1) around each unit of work and Effect.all with concurrency: "unbounded".
import { Effect } from "effect"
const program = Effect.gen(function* () { const sem = yield* Semaphore.make(3) // pool size 3
return yield* Effect.all( requests.map((req) => sem.withPermits(1)(fetchOne(req))), { concurrency: "unbounded" }, )})Effect.all would normally fan out all requests at once. The semaphore is the real cap — at most 3 of them are actually in flight; the rest wait for a permit.
Permits are returned automatically on success, failure, and interruption.
Your turn wrap each item in `sem.withPermits(1)(...)` and confirm the observed in-flight count never exceeds the semaphore size, even with unbounded outer concurrency.
Rules of thumb
- One semaphore per resource pool. Don’t share permits across logically distinct workloads — they will contend for the wrong reason.
withPermits(1)is the common case. Higher counts weight a task more heavily within the same pool.- Pair with
Effect.all+ unbounded concurrency. The semaphore does the bounding; let the scheduler hand out work eagerly.
Common Pitfalls
1, Constructing the semaphore inside the guarded function. A new semaphore on every call enforces nothing. Build it once at service initialization and share it.
2, Using a semaphore where a queue would be clearer. A semaphore bounds active work. A queue buffers pending work.
If producers can outpace consumers, you want a queue (possibly fed into a semaphore-bounded worker pool).
3, Asking for more permits than the semaphore holds. withPermits(k) with k greater than the total permits will suspend forever. Validate at construction time.
4, Forgetting that permits are returned on interruption. This is a feature, not a risk: a fiber interrupted while holding a permit returns it before it dies. No deadlocks from forgotten releases.
access — the test asserts ordering, not just correctness. - [ ] Scale up to an N-permit semaphore and observe that at most N effects run concurrently even when the surrounding `Effect.all` uses `concurrency: "unbounded"`. - [ ] Confirm permits are returned on interruption — the semaphore is the cap, the scheduler is the firehose.
See Also
- Previous,
37-interruption: the finalizer machinery that returns permits when a fiber dies. - Next,
39-ref: shared mutable state between the fibers a semaphore arbitrates between. - Related,
42-queue: buffering pending work vs capping active work. - Related,
35-concurrency: theconcurrencyoption onEffect.all.
Run the tests: pnpm test 38-semaphore
The full test suite
The index.test.ts for this lesson, verbatim from steps/38-semaphore/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Pool, Ref, Semaphore } from "effect"
// =============================================================================// 41 - Semaphore: Bounded Concurrency// =============================================================================//// Semaphore.make(N) Effect<Semaphore>// semaphore.withPermits(k)(eff) borrow k permits for the duration of eff
describe("41 - Semaphore", () => { // @beat // id: mutex // shape: one-todo // title: One-permit semaphore is a mutex // teaches: Semaphore.make(1) returns a Semaphore; semaphore.withPermits(1)(eff) serializes the critical section. // hint: Semaphore.make(1), then mutex.withPermits(1)(criticalSection) describe("withPermits(1) — mutex", () => { it("serializes the critical section", async () => { const program = /* @stub */ Effect.gen(function* () { const log = yield* Ref.make<ReadonlyArray<number>>([]) const mutex = yield* Semaphore.make(1)
const task = (id: number) => mutex.withPermits(1)( Effect.gen(function* () { yield* Ref.update(log, (arr) => [...arr, id]) yield* Effect.sleep("5 millis") yield* Ref.update(log, (arr) => [...arr, -id]) }), )
yield* Effect.all([task(1), task(2), task(3)], { concurrency: "unbounded", }) return yield* Ref.get(log) }) /* @end */
const events = await Effect.runPromise(program) // Every positive id is immediately followed by its negative — no interleaving. expect(events.length).toBe(6) const active = new Set<number>() for (const entry of events) { if (entry > 0) { expect(active.size).toBe(0) active.add(entry) } else { active.delete(-entry) } } }) })
// @end-beat
// @beat // id: pool-cap // shape: one-todo // title: N-permit semaphore caps active concurrency // teaches: Semaphore.make(N) issues N permits; withPermits(1) on it bounds simultaneous workers to N. // hint: Semaphore.make(3) then sem.withPermits(1)(task) — at most 3 tasks run at once describe("withPermits(1) — N permits", () => { it("caps active tasks to the permit count", async () => { const program = /* @stub */ Effect.gen(function* () { const active = yield* Ref.make(0) const peak = yield* Ref.make(0) const sem = yield* Semaphore.make(3)
const task = sem.withPermits(1)( Effect.gen(function* () { yield* Ref.update(active, (n) => n + 1) const current = yield* Ref.get(active) yield* Ref.update(peak, (m) => Math.max(m, current)) yield* Effect.sleep("10 millis") yield* Ref.update(active, (n) => n - 1) }), )
yield* Effect.all(Array.from({ length: 6 }, () => task), { concurrency: "unbounded", }) return yield* Ref.get(peak) }) /* @end */
const peak = await Effect.runPromise(program) expect(peak).toBeLessThanOrEqual(3) expect(peak).toBeGreaterThan(0) }) }) // @end-beat
// @beat // id: multi-permit // shape: multi-todo // title: withPermits(k) reserves multiple slots // teaches: A task that occupies multiple slots calls withPermits(k); permits are returned even on interruption. // hint: predict the two log entries pushed by `heavy` and `light` — both run and both record describe("withPermits(k) — multi-permit tasks", () => { it("allows tasks to reserve multiple permits", async () => { const program = Effect.gen(function* () { const sem = yield* Semaphore.make(2) const log: Array<string> = []
const heavy = sem.withPermits(2)(Effect.sync(() => log.push("heavy"))) const light = sem.withPermits(1)(Effect.sync(() => log.push("light")))
yield* Effect.all([heavy, light], { concurrency: "unbounded" }) return log })
const log = await Effect.runPromise(program) // Predict the two strings the tasks pushed into log expect(log).toContain(/* @stub */ "heavy" /* @end */) expect(log).toContain(/* @stub */ "light" /* @end */) }) }) // @end-beat
describe("Connection pool cap", () => { it("never exceeds the pool size under high concurrency", async () => { const program = Effect.gen(function* () { const POOL_SIZE = 5 const pool = yield* Semaphore.make(POOL_SIZE) const active = yield* Ref.make(0) const peak = yield* Ref.make(0)
const query = (id: number) => pool.withPermits(1)( Effect.gen(function* () { yield* Ref.update(active, (n) => n + 1) const current = yield* Ref.get(active) yield* Ref.update(peak, (m) => Math.max(m, current)) yield* Effect.sleep("5 millis") yield* Ref.update(active, (n) => n - 1) return id * 2 }), )
yield* Effect.all( Array.from({ length: 20 }, (_, i) => query(i)), { concurrency: "unbounded" }, ) return yield* Ref.get(peak) })
expect(await Effect.runPromise(program)).toBeLessThanOrEqual(5) }) })
// @beat // id: limit-batch // shape: one-todo // title: Cap a parallel batch to N concurrent workers // teaches: Semaphore.make(N) + withPermits(1) is the standard pattern for bounding a pool — use it whenever you have more work than your downstream (a DB, an API) can handle concurrently. // hint: yield* Semaphore.make(2), then sem.withPermits(1)(task) wraps the work; assert peak never exceeds 2
describe("bounded batch with semaphore", () => { it("processes 8 tasks with at most 2 running at once", async () => { let inFlight = 0 let peak = 0
const program = /* @stub */ Effect.gen(function* () { const sem = yield* Semaphore.make(2)
const task = (id: number) => sem.withPermits(1)( Effect.gen(function* () { inFlight++ peak = Math.max(peak, inFlight) yield* Effect.sleep("5 millis") inFlight-- return id }), )
return yield* Effect.all( Array.from({ length: 8 }, (_, i) => task(i)), { concurrency: "unbounded" }, ) }) /* @end */
const results = await Effect.runPromise(program) expect(results).toHaveLength(8) expect(peak).toBeLessThanOrEqual(2) expect(peak).toBeGreaterThan(0) }) }) // @end-beat})
// @beat// id: scoped-resource-pool// shape: read-run// title: Reuse a bounded set of scoped resources// teaches: Pool bounds expensive resources and releases them with Scope; use Semaphore for permits and Pool when the permits represent reusable resources.// hint: Pool.make takes an acquireRelease effect and a maximum size; Pool.get borrows within the surrounding scope.describe("Pool", () => { it("reuses a bounded set of scoped resources", async () => { const program = Effect.scoped(Effect.gen(function*() { const allocations = yield* Ref.make(0) const pool = yield* Pool.make({ acquire: Effect.acquireRelease( Ref.updateAndGet(allocations, (n) => n + 1), () => Effect.void, ), size: 2, }) const resource = yield* Pool.get(pool) return { resource, allocations: yield* Ref.get(allocations) } })) const result = await Effect.runPromise(program) expect(result.resource).toBeGreaterThan(0) expect(result.allocations).toBeLessThanOrEqual(2) })})// @end-beat