Skip to content

Coordination

Sometimes what fibers contend over isn’t a value but access: a limited number of permits, exclusive use of a resource, or a signal that fires exactly once. The Tx* coordination primitives — TxSemaphore, TxReentrantLock, and TxDeferred — solve these, and because they are built on TxRef their state changes commit atomically alongside the rest of your transaction.

A TxSemaphore holds a fixed number of permits. Acquiring blocks (via Effect.txRetry) until enough permits are free; releasing returns them, capped at the original capacity. The most ergonomic API is withPermit, which acquires one permit, runs an effect, and releases the permit even on failure or interruption.

import { Context, Effect, Layer, TxSemaphore } from "effect"
// A connection pool that lets at most 3 callers run a query concurrently.
class Db extends Context.Service<Db, {
readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>>
}>()("app/Db") {
static layer = Layer.effect(Db)(
Effect.gen(function*() {
// Fixed capacity of 3 permits == at most 3 concurrent queries.
const semaphore = yield* TxSemaphore.make(3)
const query = Effect.fn("Db.query")(function*(sql: string) {
// withPermit brackets the work: acquire 1 permit, run, release.
// A 4th concurrent caller waits here until a permit frees up.
return yield* TxSemaphore.withPermit(
semaphore,
Effect.succeed([] as ReadonlyArray<unknown>)
)
})
return Db.of({ query })
})
)
}

withPermits(semaphore, n) and acquireN / releaseN work with several permits at once. tryAcquire succeeds only if permits are already available (returning a boolean instead of waiting), and available / capacity report the current and maximum permit counts.

A TxReentrantLock is a read/write lock with per-fiber ownership tracking. Many fibers may hold a read lock at once, but a write lock grants one fiber exclusive access. It is reentrant: a fiber may re-acquire a lock it already owns (as long as each acquisition is matched by a release), so nested critical sections are safe.

import { Effect, Ref, TxReentrantLock } from "effect"
const program = Effect.gen(function*() {
const lock = yield* TxReentrantLock.make()
const state = yield* Ref.make(0)
// withWriteLock grants exclusive access for the duration of the effect,
// then releases — even on failure or interruption. Concurrent readers and
// writers wait until the write lock is dropped.
yield* TxReentrantLock.withWriteLock(lock, Ref.update(state, (n) => n + 1))
// withReadLock allows multiple readers to proceed in parallel, but waits
// while any fiber holds the write lock.
return yield* TxReentrantLock.withReadLock(lock, Ref.get(state)) // 1
})

Prefer the bracketing helpers withReadLock, withWriteLock, and withLock over manual acquireRead / releaseRead (and their write counterparts) — they guarantee the lock is released no matter how the effect ends. If you need lock ownership tied to a Scope rather than a single effect, readLock and writeLock acquire a lock that is released when the scope closes.

A TxDeferred<A, E> is a transactional, write-once cell — the STM counterpart of a regular deferred. Readers await it from inside a transaction: while it is empty the transaction retries, and once another transaction completes it, every waiter sees the same committed result. Completion is single-assignment, so only the first succeed / fail / done wins; later attempts return false.

import { Effect, Fiber, TxDeferred } from "effect"
const program = Effect.gen(function*() {
// A one-shot handoff: the worker computes a value, the waiter receives it.
const result = yield* TxDeferred.make<number, string>()
const waiter = yield* Effect.forkChild(
// await parks the fiber (transaction retry) until the deferred is
// completed, then resumes with the success value or typed failure.
TxDeferred.await(result)
)
// Complete it exactly once. The waiter wakes with 42.
const first = yield* TxDeferred.succeed(result, 42)
const second = yield* TxDeferred.succeed(result, 99) // ignored
return {
value: yield* Fiber.join(waiter), // 42
first, // true
second // false
}
})

Use TxDeferred.poll to inspect the current state without waiting — it returns an Option<Result<A, E>> that is None until the cell is completed.

| You need to coordinate… | Use | | --- | --- | | A limited number of concurrent users | TxSemaphore | | Shared reads with exclusive writes | TxReentrantLock | | A one-time signal or handoff | TxDeferred |

For coordinating data rather than access, see the transactional data structures; for the underlying atomic-state mechanics, see transactional state.