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.
TxSemaphore
Section titled “TxSemaphore”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.
TxReentrantLock
Section titled “TxReentrantLock”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.
TxDeferred
Section titled “TxDeferred”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.
Picking a primitive
Section titled “Picking a primitive”| 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.