Core concepts¶
Promises settle once¶
A PROMISE starts :PENDING and moves to :FULFILLED (via DELIVER) or
:FAILED (via DELIVER-ERROR) exactly once. A second DELIVER or
DELIVER-ERROR signals PROMISE-ALREADY-FULFILLED rather than silently
overwriting the first result -- a promise delivered twice is almost always a
bug in the caller, not a legitimate update.
AWAIT blocks until the promise settles, then either returns the delivered
value or re-signals the delivered condition, preserving its type so
HANDLER-CASE on the awaiting side can still discriminate.
FUTURE is sugar for "make a promise, spawn a thread that settles it with
its body's outcome, return the promise immediately".
PROMISE-THEN composes promises by continuation-passing rather than by
blocking: (PROMISE-THEN PROMISE ON-FULFILLED ON-REJECTED) registers both
callbacks and returns a new promise for whichever one runs, settled from
whatever thread settles PROMISE -- immediately, inline, if PROMISE is
already settled. Chaining several calls builds a pipeline no thread ever
blocks to construct; only the final AWAIT blocks, if anything does.
PROMISE-CATCH and PROMISE-FINALLY are PROMISE-THEN specialized to only
the rejection or only the settlement path. PROMISE-ALL, PROMISE-ANY, and
PROMISE-RACE combine several promises into one CPS-composed the same way:
none of them blocks the calling thread, and each is settled the moment its
own condition is met by whichever input promise's settling thread satisfies
it. CANCEL-PROMISE settles a still-:PENDING promise from the outside with
PROMISE-CANCELLED, racing ordinarily against whatever would otherwise
deliver it -- whichever settles first wins, and the loser's attempt signals
PROMISE-ALREADY-FULFILLED.
Channels: buffered vs. unbuffered¶
(MAKE-CHANNEL :BUFFER-SIZE 0) (the default) is a true CSP rendezvous: SEND
does not return until a RECV has taken the value back out. This is a
stronger guarantee than "enqueued somewhere" -- it is a synchronization
point between the sending and receiving threads, exactly like Go's
unbuffered channel.
(MAKE-CHANNEL :BUFFER-SIZE N) for N > 0 is a bounded queue: SEND only
blocks once N values are already waiting.
Closing a channel (CLOSE-CHANNEL) stops future SENDs (they signal
CHANNEL-CLOSED) but lets RECV keep draining whatever was already queued;
only once the channel is both closed and empty does RECV return (VALUES
NIL NIL).
TRY-SEND and TRY-RECV never block. TRY-SEND on an unbuffered channel in
particular trades away the "a receiver actually took it" guarantee that
blocking SEND provides, in exchange for never waiting.
Select doesn't poll¶
SELECT waits on several channel operations and runs whichever becomes
ready first. It is not a busy-poll loop: see Architecture
for how it sleeps between attempts.
Each recv channel form and each send channel/value form is evaluated
exactly once, in clause order, before SELECT probes for a ready operation.
This makes side-effecting setup expressions predictable even when a timeout
causes multiple readiness checks.
For static clauses, SELECT expands each TRY-RECV or TRY-SEND probe
directly. The ready path does not construct a runtime operation table or
dispatch through a selected index; the once-only bindings retain the same
evaluation and cleanup semantics.
SELECT gives clauses deterministic declaration-order priority when more
than one is ready. A receive from a closed and drained channel is ready too,
binding its value variable to NIL; a send to a closed channel signals
CHANNEL-CLOSED just as SEND does. :DEFAULT and :TIMEOUT are mutually
exclusive, and a form must contain at least one channel clause.
Executors vs. futures¶
FUTURE spawns one thread per task. MAKE-EXECUTOR starts a fixed pool of
worker threads up front and SUBMIT hands them work through a shared queue,
returning a PROMISE just like FUTURE does. Reach for an executor when the
number of tasks is large or unbounded and one-thread-per-task would be
wasteful.
An executor's queue is unbounded by default; passing MAKE-EXECUTOR's
:QUEUE-CAPACITY makes SUBMIT reject work past that bound (with
EXECUTOR-QUEUE-FULL) instead of letting the queue grow without limit --
backpressure for a producer that can outrun its workers. EXECUTOR-QUEUE-DEPTH
and EXECUTOR-HIGH-WATER-MARK expose that queue's live and peak size for
observability. WITH-EXECUTOR scopes an executor's lifetime the way
WITH-OPEN-FILE scopes a stream's: the pool is shut down and its already-queued
work runs to completion on every exit from the body, normal or not.
Structured concurrency: scopes own their children¶
WITH-TASK-SCOPE establishes a scope; SPAWN starts a tracked child task on
a new thread and returns a PROMISE for it. The scope guarantees:
- No child outlives the scope.
WITH-TASK-SCOPEdoes not return until everySPAWNed thread has finished, whether it succeeded, failed, or is still running when the body itself throws -- or until an optional:TIMEOUT(seconds) elapses waiting for stragglers, at which pointOPERATION-TIMED-OUTis signaled instead and every remaining child is cancelled the same cooperative way a sibling failure would cancel them. - A child's failure is never silently dropped. If the body returns
normally but one or more children failed,
WITH-TASK-SCOPEsignalsSCOPE-ERRORwith every failure's condition inSCOPE-ERROR-CAUSES. - Failure cancels siblings, cooperatively. A failing child trips a flag
on the scope; other tasks must call
CHECK-CANCELLEDat a safe point to observe it and unwind via a signaledTASK-CANCELLED. That is a choice, not a missing mechanism --WITH-TIMEOUTdoes forcibly interrupt a running SBCL thread, through SBCL's timer andSB-THREAD:INTERRUPT-THREAD, so the capability exists and is deliberately not used here. An asynchronous interrupt lands between two arbitrary instructions, so it can unwind a task whoseUNWIND-PROTECThas not yet recorded the resource its cleanup would release; a scope exists precisely to guarantee that every child it started has finished and been accounted for, and that guarantee is worth more than reclaiming a task a few moments sooner. Bound work that is safe to abandon at an arbitrary point withWITH-TIMEOUT; for work that owns a resource, use a scope andCHECK-CANCELLED. See Architecture. - The body's own error wins. If the body itself signals (rather than a
SPAWNed child), that condition propagates as-is after every child has been cancelled and awaited -- it is not wrapped inSCOPE-ERROR.
Every scope keeps a set of wakers -- callbacks registered by whatever is
currently blocked on behalf of one of its children (AWAIT-LATCH,
AWAIT-BARRIER, and the reactive stream stages below all register one).
Cancelling the scope fires every waker exactly once, the same way it fires
every child's cancel callback, so a blocked wait unblocks promptly on
cancellation rather than only noticing on its own next timeout.
Countdown latches and barriers¶
A COUNTDOWN-LATCH is one-shot: COUNT-DOWN decrements it, and once it
reaches zero it stays open forever, releasing every AWAIT-LATCH (present
and future) immediately. It composes with WITH-TASK-SCOPE the same way
AWAIT does: pass the scope explicitly via AWAIT-LATCH's :SCOPE, and a
cancelled scope unblocks the wait with TASK-CANCELLED instead of leaving it
hanging until its own :TIMEOUT.
A BARRIER is the cyclic sibling: PARTIES callers must all call
AWAIT-BARRIER before any of them proceeds, and once released, the barrier
resets itself for a fresh generation rather than staying open. Any single
generation can break instead of releasing -- on a timeout, a cancelled
:SCOPE, or an explicit RESET-BARRIER -- in which case every party still
waiting in that generation signals BARRIER-BROKEN rather than proceeding
with fewer parties than promised.
Reactive streams: stages over channels¶
The CHANNEL-* stream operators (CHANNEL-MAP, CHANNEL-FILTER,
CHANNEL-MERGE, and the rest) are all built from the same small piece of
machinery: a stage is a task -- run via SPAWN on a :SCOPE, submitted to
an :EXECUTOR, or run on its own FUTURE thread if neither is given -- that
reads an input channel (or channels), does some work, and writes an output
channel it alone owns and closes. Every stage-producing function returns two
values: the output channel and a completion PROMISE for the stage's own
task, so a caller who cares whether the stage itself succeeded or failed
(as opposed to just reading values off the output) can AWAIT it.
Ownership is the load-bearing invariant: because exactly one task ever writes
to and closes a given output channel, a downstream consumer can always tell
"no more values" from "closed" without racing another writer, and cancelling
a stage's :SCOPE closes its output rather than leaving a reader blocked
forever -- the same generic waker mechanism AWAIT-LATCH and AWAIT-BARRIER
use, registered before the stage's task starts and fired if the scope is
already, or becomes, cancelled.
Stages that fan in from several channels at once (CHANNEL-MERGE,
CHANNEL-ZIP, CHANNEL-SWITCH-MAP, and similar) are built on a small
variable-arity sibling of SELECT internal to the stream layer, for the same
reason SELECT itself exists: waiting on N channels without busy-polling any
of them.