Semaphore, Latch, and PartitionedSemaphore
Once you have many fibers, you need ways to coordinate them. Three small, composable primitives cover most needs:
- A
Semaphorelimits how many fibers may hold a resource at once — perfect for capping concurrency against a connection pool, an API quota, or any shared resource that can’t take unlimited load. - A
Latchis a gate: fibers wait on it until something opens it, then all waiters proceed. Use it to hold work back until initialization is done, or to pause and resume a pipeline. - A
PartitionedSemaphoreshares one bounded permit pool across keys (e.g. one tenant, customer, or host per key) so no single key can starve the others.
Semaphore
Section titled “Semaphore”A semaphore holds a fixed number of permits. withPermits(n) acquires n
permits before running an effect and releases them when the effect finishes —
success, failure, or interruption. If not enough permits are free, the fiber
suspends until they are. A semaphore with one permit is a mutex.
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { // Create a semaphore that allows at most 2 holders at a time. const semaphore = yield* Semaphore.make(2)
const task = (n: number) => // `withPermit` takes exactly one permit for the duration of the effect. semaphore.withPermit( Effect.gen(function*() { yield* Effect.log(`task ${n} running`) yield* Effect.sleep("500 millis") yield* Effect.log(`task ${n} done`) }) )
// Six tasks are forked, but the semaphore lets only 2 run concurrently. yield* Effect.forEach([1, 2, 3, 4, 5, 6], task, { concurrency: "unbounded" })})
Effect.runFork(program)Here Effect.forEach with concurrency: "unbounded" wants to run all six
tasks at once, but each task wraps itself in withPermit, so the semaphore caps
the effective concurrency at two. The permit is released automatically when each
task’s effect exits, so the next waiting fiber is admitted immediately.
Sharing a semaphore from a service
Section titled “Sharing a semaphore from a service”The real power of a semaphore is sharing one instance everywhere. Hold it in a service so every consumer goes through the same gate:
import { Context, Effect, Layer, Semaphore } from "effect"
// A client that allows at most 5 concurrent outbound requests, process-wide.export class RateLimitedApi extends Context.Service<RateLimitedApi, { request<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>}>()("app/RateLimitedApi") { static readonly layer = Layer.effect( RateLimitedApi, Effect.gen(function*() { // One shared semaphore for the lifetime of the layer. const semaphore = yield* Semaphore.make(5)
return RateLimitedApi.of({ // Every request, from anywhere, competes for the same 5 permits. request: (effect) => Semaphore.withPermit(semaphore, effect) }) }) )}Semaphore.make(n) and the standalone Semaphore.withPermit / withPermits
functions are the data-first equivalents of the methods on the semaphore object —
use whichever style reads better. You can also acquire several permits at once
with withPermits(n), try without blocking via withPermitsIfAvailable, or
adjust the limit at runtime with Semaphore.resize.
Resizing at runtime
Section titled “Resizing at runtime”resize changes the future capacity while leaving currently-held permits
untouched. Shrinking below the number of taken permits doesn’t revoke anything —
new acquisitions simply wait until releases bring availability back up.
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { const semaphore = yield* Semaphore.make(2)
// Bump the limit to 10 (e.g. after a config change). yield* Semaphore.resize(semaphore, 10)
// Later, throttle back down to 1. yield* Semaphore.resize(semaphore, 1)})A Latch is a one-bit gate that fibers can wait on. Its await effect suspends
the calling fiber while the latch is closed, and resumes once it is
opened. Opening is sticky: every fiber that waits after the latch opens
proceeds immediately.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() { // Start closed: waiters will suspend until we open it. const latch = yield* Latch.make(false)
// Fork a worker that must wait for the green light before doing anything. yield* Effect.forkChild( Effect.gen(function*() { yield* Effect.log("worker: waiting for the latch") yield* latch.await yield* Effect.log("worker: go!") }) )
// Do some setup, then open the latch to release the worker. yield* Effect.sleep("500 millis") yield* Effect.log("main: setup done, opening latch") yield* latch.open})
Effect.runFork(program)// worker: waiting for the latch// main: setup done, opening latch// worker: go!Open, release, and close
Section titled “Open, release, and close”A latch gives you three ways to control waiters, which matter when work arrives continuously:
open— release all current waiters and let future waiters pass immediately. The gate stays open.release— wake the fibers currently waiting, but leave the latch closed so later waiters suspend again. A one-shot pulse.close— re-close an open latch so futureawaits suspend once more.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() { const latch = yield* Latch.make(false)
// Pause a pipeline: close the latch and have stages await it. yield* Latch.close(latch)
// ...later, resume just the in-flight waiters without re-opening... yield* Latch.release(latch)
// ...or open it for good so everything flows. yield* Latch.open(latch)})This open/release/close trio makes a latch a natural pause/resume switch for a
streaming pipeline or a pool of workers: close to pause, open to
resume. For a single one-time hand-off of a value between fibers — a producer
computing a result that one consumer awaits — reach for a
Deferred instead, which carries the value (or failure)
along with the signal.
Gating an effect with whenOpen
Section titled “Gating an effect with whenOpen”whenOpen is await followed by an effect, in one step: the effect runs only
once the latch lets the fiber through.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() { const latch = yield* Latch.make(false)
// This effect is held back until the latch opens, then runs. yield* Effect.forkChild( latch.whenOpen(Effect.log("ready — running gated work")) )
yield* Effect.sleep("200 millis") yield* latch.open})PartitionedSemaphore
Section titled “PartitionedSemaphore”A plain Semaphore shares one global pool of permits. That’s a problem when a
single noisy key — a tenant flooding requests, one slow host — grabs every
released permit and starves everyone else. A PartitionedSemaphore<K> keeps the
same fixed shared capacity, but tracks waiters per key and hands released
permits back to waiting partitions in round-robin order, so each key keeps
making progress.
import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() { // A shared pool of 4 permits, partitioned by tenant id (a string key). const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 })
const handle = (tenant: string, job: number) => // Take one permit *for this tenant*. If tenant "a" floods the pool, // released permits still rotate to tenant "b" rather than being // re-grabbed entirely by "a". sem.withPermit(tenant)( Effect.gen(function*() { yield* Effect.log(`${tenant}: job ${job} start`) yield* Effect.sleep("300 millis") yield* Effect.log(`${tenant}: job ${job} done`) }) )
yield* Effect.forEach( [ handle("a", 1), handle("a", 2), handle("a", 3), handle("b", 1), handle("b", 2) ], (eff) => eff, { concurrency: "unbounded" } )})
Effect.runFork(program)The key (tenant here) only affects how waiting permits are distributed —
the total in-flight count never exceeds the shared permits capacity. Use
withPermits(key, n) to weight a partition’s request by more than one permit.
Semaphore reference
Section titled “Semaphore reference”Creates a Semaphore with permits total permits, inside Effect.
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { const semaphore = yield* Semaphore.make(3) // => Semaphore with 3 permits})makeUnsafe
Section titled “makeUnsafe”Creates a Semaphore synchronously, for when you need the value outside an
Effect workflow (e.g. a module-level shared limiter).
import { Semaphore } from "effect"
const semaphore = Semaphore.makeUnsafe(3)// => Semaphore (constructed synchronously)withPermits
Section titled “withPermits”Acquires n permits around an effect, waiting until they’re available, and
releases them when the effect exits (success, failure, or interruption).
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* Semaphore.make(5) // Reserve 3 permits for this heavier piece of work. yield* Semaphore.withPermits(sem, 3)(Effect.log("running with 3 permits")) // => "running with 3 permits", then 3 permits released})withPermit
Section titled “withPermit”The single-permit form of withPermits — a mutex when the semaphore has one
permit.
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* Semaphore.make(1) yield* Semaphore.withPermit(sem, Effect.log("exclusive section")) // => "exclusive section"})withPermitsIfAvailable
Section titled “withPermitsIfAvailable”Runs the effect only if n permits are free right now; never waits. Returns
Option.some(result) if it ran, Option.none() if permits weren’t available.
import { Effect, Option, Semaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* Semaphore.make(1)
// Manually take the only permit and never release it, draining the pool. yield* Semaphore.take(sem, 1)
const result = yield* Semaphore.withPermitsIfAvailable(sem, 1)( Effect.succeed("did the work") ) // => Option.none() (no permit was available)
return Option.isNone(result)})Lower-level: acquires n permits, waiting if needed, and returns the number
acquired. Pending takes are served first-in, first-out. Pair with release.
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* Semaphore.make(2) const got = yield* Semaphore.take(sem, 2) // => 2})release
Section titled “release”Returns n permits to the pool (waking waiters as needed) and yields the
resulting number of available permits. You must keep take/release balanced
yourself — prefer the scoped withPermit(s) helpers when you can.
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* Semaphore.make(2) yield* Semaphore.take(sem, 2) const available = yield* Semaphore.release(sem, 1) // => 1})releaseAll
Section titled “releaseAll”Returns every currently-taken permit to the semaphore at once, yielding the
resulting available count. Handy for cleanup of manual take/release
protocols.
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* Semaphore.make(3) yield* Semaphore.take(sem, 3) const available = yield* Semaphore.releaseAll(sem) // => 3})resize
Section titled “resize”Sets the total permit count of an existing semaphore. Existing acquisitions stay taken; if the new total is below the taken count, new acquisitions wait.
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* Semaphore.make(2) yield* Semaphore.resize(sem, 8) // => future acquisitions can now take up to 8 permits})interface Semaphore
Section titled “interface Semaphore”The semaphore value itself exposes the same operations as methods, so you can
call semaphore.withPermit(eff) instead of Semaphore.withPermit(semaphore, eff):
resize(permits), withPermits(permits)(effect), withPermit(effect),
withPermitsIfAvailable(permits)(effect), take(permits), release(permits),
and the releaseAll effect property.
import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* Semaphore.make(2) // Method form, equivalent to Semaphore.withPermit(sem, ...). yield* sem.withPermit(Effect.log("via method")) // => "via method"})Latch reference
Section titled “Latch reference”Creates a Latch inside Effect. Starts closed by default; pass true to
start open.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() { const latch = yield* Latch.make(false) // => closed Latch})makeUnsafe
Section titled “makeUnsafe”Creates a Latch synchronously, outside Effect. Starts closed unless true
is passed.
import { Latch } from "effect"
const latch = Latch.makeUnsafe(false)// => closed Latch (constructed synchronously)Opens the latch, releasing current and future waiters. Succeeds with true if
this call changed the state (closed → open), false if it was already open.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() { const latch = yield* Latch.make(false) const changed = yield* Latch.open(latch) // => true})openUnsafe
Section titled “openUnsafe”Synchronous open: opens the latch immediately and returns whether the state
changed.
import { Latch } from "effect"
const latch = Latch.makeUnsafe(false)const changed = Latch.openUnsafe(latch)// => truerelease
Section titled “release”Wakes the fibers currently waiting without opening the latch — future waiters
still suspend. Succeeds with true if it ran while closed, false if already
open.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() { const latch = yield* Latch.make(false) const released = yield* Latch.release(latch) // => true (current waiters released; latch stays closed)})Re-closes an open latch so future await/whenOpen calls suspend again.
Succeeds with true if it changed the state (open → closed), false otherwise.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() { const latch = yield* Latch.make(true) const changed = yield* Latch.close(latch) // => true})closeUnsafe
Section titled “closeUnsafe”Synchronous close: closes the latch immediately and returns whether the state
changed.
import { Latch } from "effect"
const latch = Latch.makeUnsafe(true)const changed = Latch.closeUnsafe(latch)// => truewhenOpen
Section titled “whenOpen”Waits on the latch, then runs the given effect, preserving its success, failure, and requirements. If the latch is open it runs immediately.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() { const latch = yield* Latch.make(true) const result = yield* Latch.whenOpen(latch, Effect.succeed(42)) // => 42})interface Latch
Section titled “interface Latch”The latch value exposes its operations directly: the open, release,
await, and close effect properties, the openUnsafe() / closeUnsafe()
synchronous methods, and whenOpen(effect). Every operation also has a
standalone function form (Latch.open, Latch.await, Latch.whenOpen, …) —
use whichever style reads better.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() { const latch = yield* Latch.make(true) // Method form; `Latch.await(latch)` is the equivalent standalone function. yield* latch.await // => proceeds immediately (latch is open)})PartitionedSemaphore reference
Section titled “PartitionedSemaphore reference”Creates a PartitionedSemaphore<K> inside Effect with a shared permits
capacity, tracking waiters by key K.
import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 }) // => PartitionedSemaphore keyed by string, capacity 4})makeUnsafe
Section titled “makeUnsafe”Synchronous construction. Negative permits are clamped to 0; a non-finite
value makes an unbounded (no-op) semaphore.
import { PartitionedSemaphore } from "effect"
const sem = PartitionedSemaphore.makeUnsafe<number>({ permits: 4 })// => PartitionedSemaphore keyed by number, capacity 4available
Section titled “available”Reads a snapshot of how many permits are currently free (does not reserve any).
import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 }) const free = yield* PartitionedSemaphore.available(sem) // => 4})capacity
Section titled “capacity”The fixed total capacity configured at construction. A plain number, not an effect — it never changes.
import { PartitionedSemaphore } from "effect"
const sem = PartitionedSemaphore.makeUnsafe<string>({ permits: 4 })const cap = PartitionedSemaphore.capacity(sem)// => 4Manually acquires permits for key, waiting until released permits are
assigned to this partition. The effect succeeds with void once the permits are
held. Requests above capacity never complete; zero or negative requests
complete without acquiring.
import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 }) yield* PartitionedSemaphore.take(sem, "tenant-a", 2) // => void (2 permits now held for "tenant-a")})release
Section titled “release”Returns permits to the shared pool and yields the resulting available count.
Released permits go first to waiting partitions (round-robin); leftovers raise
availability, capped at capacity.
import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 }) yield* PartitionedSemaphore.take(sem, "tenant-a", 2) const available = yield* PartitionedSemaphore.release(sem, 2) // => 4})withPermits
Section titled “withPermits”Acquires permits for key around an effect, releasing them when it exits
(success, failure, or interruption). The weighted, keyed equivalent of
Semaphore.withPermits.
import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 }) yield* PartitionedSemaphore.withPermits(sem, "tenant-a", 2)( Effect.log("heavy work for tenant-a") ) // => "heavy work for tenant-a", then 2 permits released})withPermit
Section titled “withPermit”Single-permit form of withPermits, keyed by partition.
import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 }) yield* PartitionedSemaphore.withPermit(sem, "tenant-b")( Effect.log("one unit of work for tenant-b") ) // => "one unit of work for tenant-b"})withPermitsIfAvailable
Section titled “withPermitsIfAvailable”Runs the effect only if permits can be taken from the shared pool immediately
— note this variant takes no key, since it never waits. Returns
Option.some if it ran, Option.none() otherwise.
import { Effect, Option, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* PartitionedSemaphore.make<string>({ permits: 1 }) yield* PartitionedSemaphore.take(sem, "tenant-a", 1) // drain the pool
const result = yield* PartitionedSemaphore.withPermitsIfAvailable(sem, 1)( Effect.succeed("ran") ) // => Option.none()
return Option.isNone(result)})interface PartitionedSemaphore and Partitioned
Section titled “interface PartitionedSemaphore and Partitioned”PartitionedSemaphore<K> is the model; its members mirror the functions above
(capacity, the available effect, take(key, permits), release(permits),
withPermits(key, permits), withPermit(key), withPermitsIfAvailable(permits)),
plus the PartitionedTypeId brand. Partitioned<K> is an alias interface with
no extra members — an alternate exported name for the same shape.
import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() { const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 }) // Method form, equivalent to PartitionedSemaphore.withPermit(sem, "k"). yield* sem.withPermit("tenant-a")(Effect.log("via method")) // => "via method"})PartitionedTypeId
Section titled “PartitionedTypeId”The runtime brand ("~effect/PartitionedSemaphore") stamped onto partitioned
semaphore values; PartitionedTypeId is also exported as a type for fields that
must hold that exact marker.
import { PartitionedSemaphore } from "effect"
const sem = PartitionedSemaphore.makeUnsafe<string>({ permits: 1 })const brand = sem[PartitionedSemaphore.PartitionedTypeId]// => "~effect/PartitionedSemaphore"