07·04Semaphore

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.

38 - Semaphore: Bounded Concurrency

Previous · Next

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 resumes

A 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: the concurrency option on Effect.all.

Run the tests: pnpm test 38-semaphore


The full test suite

The index.test.ts for this lesson, verbatim from steps/38-semaphore/.

index.test.ts
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
← PreviousInterruptionNext →Ref