Futures and Structured Concurrency
Canonical source:
docs/guide/08-futures-and-structured-concurrency.mdat locked revisione99b712ecf658a527b1fe82c8beba2c1286d8dc9. This rendered page is non-normative; the applicable Protos specification remains authoritative.
Status: Non-normative programming guide
Normative owner:
spec/concurrency/FUTURES_AND_TASKS.mdRelated normative owners:
spec/semantics/CALLABLES.md,spec/semantics/ERRORS.md,spec/semantics/EXECUTION_AND_CONTROL.md, andspec/concurrency/ACTORS.md
Protos separates an eventual result from the lifetime of the work that produces it.
The central model is:
A Future is an ordinary eventual-result object. A task-backed Future may also represent asynchronous child work whose lifetime is owned by the current asynchronous task scope.
There is no async function category and no await keyword. Ordinary Closures
can execute synchronously or asynchronously, and ordinary Future messages provide
observation, composition, and cancellation.
A Closure chooses synchronous or asynchronous execution at the call site
Section titled “A Closure chooses synchronous or asynchronous execution at the call site”The same Closure can run synchronously:
work: () => { 42}
result: work()or asynchronously:
future: work.future()future() requests asynchronous execution of that Closure and immediately
returns a Future representing the result.
Asynchrony belongs to that execution, not permanently to the Closure definition.
There is therefore no separate async Closure type.
future() is ordinary protocol behavior
Section titled “future() is ordinary protocol behavior”The standard future selector participates in ordinary lookup on Object, with
a semantic Closure receiver requirement.
This means normal lookup rules still matter:
lookupreflectionextractionshadowingoverride behaviorMerely possessing or inheriting a slot named future does not turn an arbitrary
object into a semantic Closure.
The runtime/scheduler boundary is implementation machinery behind the ordinary language-visible protocol.
Each Future-producing invocation normally gets a fresh Future identity
Section titled “Each Future-producing invocation normally gets a fresh Future identity”Unless an operation explicitly promises to return an already-existing Future, two separate Future-producing invocations produce distinct Future objects.
For example:
work: () => 42
a: work.future()b: work.future()
a === bis false.
That remains true even if both Futures eventually resolve to the same value.
Future identity and result identity are separate questions.
A Future has four states
Section titled “A Future has four states”The semantic states are exactly:
pendingresolved(value)failed(error)cancelledOnly pending is non-terminal.
The first terminal transition wins and remains stable.
A Future does not later switch from resolved to failed, from cancelled to resolved, or otherwise rewrite its terminal outcome because some later producer, cancellation, or adoption bookkeeping arrives.
Resolution preserves the result object
Section titled “Resolution preserves the result object”When asynchronous work completes normally with an ordinary non-Future value, its Future resolves with that exact value.
Conceptually:
object: {}
f: (() => object).future()result: f.value()
result === objectThe Future does not clone or canonicalize the result merely because it crossed an asynchronous completion boundary inside the same value/isolation domain.
Boundary-specific value transfer rules, such as Actor or isolated-parallel value transfer, remain owned by those boundaries.
Returning a Future causes adoption, not a nested Future result
Section titled “Returning a Future causes adoption, not a nested Future result”If Future resolution is given another Future, the destination adopts the source Future’s eventual terminal outcome.
For example:
source: (() => 20).future()
result: source.then((value) => { (() => value * 2 + 2).future()})
result.value()produces 42.
Conceptually:
transform returns sourceFuture | vdestination remains pending | vsourceFuture resolves/fails/cancels | vdestination mirrors that outcomeThe destination does not resolve to a Future whose value is another Future.
Adoption transfers outcome, not identity or ownership
Section titled “Adoption transfers outcome, not identity or ownership”Adoption does not make the two Futures identical.
It also does not transfer:
task identitystructured ownershipupstream cancellation authorityCancelling an adopting destination does not automatically cancel the adopted source.
Adoption is an outcome relationship, not ownership re-parenting.
Future-adoption cycles fail
Section titled “Future-adoption cycles fail”A Future cannot validly adopt itself, directly or transitively.
An adoption cycle fails the destination with a fresh standard
FutureResolutionCycle Error occurrence rather than leaving it pending forever.
This is a semantic failure rule, not a timeout or implementation deadlock heuristic.
value() observes the stable Future outcome
Section titled “value() observes the stable Future outcome”The ordinary observation operation is:
future.value()Its behavior depends on the stable state.
For:
resolved(value)it returns the exact resolved value.
For:
failed(error)it signals the recorded Error in the consumer’s current dynamic handler context.
For:
cancelledit signals a fresh standard Cancelled occurrence for that observation.
For:
pendingit explicitly suspends the current execution until the Future becomes terminal or cancellation of the waiting task is observed.
Waiting does not require blocking an OS thread
Section titled “Waiting does not require blocking an OS thread”A pending value() is a language-level suspension point.
The implementation may resume the task later without dedicating a blocked host thread to the wait.
The semantic guarantee is about task suspension/resumption and observable ordering, not the scheduler representation.
Waiting cannot lose a terminal notification
Section titled “Waiting cannot lose a terminal notification”The registration of a pending value() waiter and the Future’s first terminal
transition are coordinated so that no lost-wakeup gap is observable.
Semantically, one of these happens:
Future terminalizes first -> consumer does not remain suspended
waiter registration happens first -> terminalization makes that waiter eligible to resumeMultiple waiters can observe one Future independently.
One waiter must not be skipped merely because others are also waiting.
Failed-Future observation re-signals the recorded Error
Section titled “Failed-Future observation re-signals the recorded Error”Suppose asynchronous work fails with an existing Error problem.
The failed Future records that exact domain-local Error.
Later:
Error.handle( () => { future.value() }, (error) => { error === problem })can observe that same Error identity in the same value domain.
The new value() observation is nevertheless a new signaling event in the
consumer.
It does not resume the producer, restore the producer’s handler stack, or recreate its continuation.
Repeated observations re-signal the same recorded Error object.
Cancelled-Future observation is deliberately different
Section titled “Cancelled-Future observation is deliberately different”A cancelled Future stores the terminal state:
cancelledIt does not store one permanent Cancelled Error instance.
Each call to:
cancelledFuture.value()creates and signals a fresh standard Error occurrence delegating to
Cancelled.
Therefore repeated observations of one cancelled Future receive distinct Error identities.
This keeps “stored failed Error” distinct from “observation of cancelled state”.
then() creates asynchronous continuation work
Section titled “then() creates asynchronous continuation work”The composition operation is:
destination: source.then(transform)After eager validation, then() creates a distinct continuation task plus a
fresh destination Future.
When a resolved source makes that continuation runnable, the transform is invoked asynchronously with the source value:
source.then((value) => { value + 1})Completion of the source does not run transform inline inside the source’s terminalization path.
A then transform is ordinarily invokable, not Closure-only
Section titled “A then transform is ordinarily invokable, not Closure-only”Unlike some Closure-specific Core protocols, then(transform) accepts a value
that satisfies the ordinary invocation/callability protocol.
It is not restricted to semantic Closure identity.
Validation is eager and does not invoke the transform.
The later continuation performs ordinary invocation when it actually runs, so normal call lookup/mutation rules remain observable.
Source failure or cancellation skips the transform
Section titled “Source failure or cancellation skips the transform”For a then continuation:
source resolved(value) -> invoke transform(value)
source failed(error) -> destination fails with that Error
source cancelled -> destination becomes cancelledThe transform is not invoked for failed or cancelled source outcomes.
If the transform returns a Future, ordinary Future adoption flattens its eventual outcome into the destination.
then cancellation is downstream-only
Section titled “then cancellation is downstream-only”Cancelling the destination Future requests cancellation of the continuation task.
It does not cancel the source Future.
This is a useful general rule:
Dependency does not imply upstream cancellation ownership.
Future.all(...) coordinates multiple Futures deterministically
Section titled “Future.all(...) coordinates multiple Futures deterministically”The standard aggregate operation is:
Future.all(a, b, c)It returns a fresh non-task-backed aggregate Future.
When all sources resolve successfully, the aggregate resolves to a fresh Array:
[aValue, bValue, cValue]in argument order, not completion order.
For zero arguments:
Future.all()returns an already-resolved Future containing a fresh empty Array.
Future.all does not own its source Futures
Section titled “Future.all does not own its source Futures”The aggregate is an observation Future.
It does not:
re-parent sourcescancel sourcestake ownership of their tasksCancelling the aggregate abandons only the aggregate observation.
The source Futures continue according to their own producers and ownership contracts.
Future.all failure/cancellation selection uses argument order
Section titled “Future.all failure/cancellation selection uses argument order”Core deliberately avoids choosing failure based on scheduler timing.
The aggregate uses a deterministic ascending-index frontier.
Conceptually, for:
Future.all(a, b, c)a non-resolved outcome at b cannot become the aggregate outcome until a is
known to have resolved.
If a later fails, a wins because it has the lower argument index.
Physical completion order, callback timing, carrier choice, or polling order does not choose the result.
Core has no generic Future.race or Future.select
Section titled “Core has no generic Future.race or Future.select”Core v0.1 deliberately does not expose a generic “whichever Future happened to finish first” combinator.
Independent Future producers do not acquire a portable global total completion order just because a program wants to select one.
A generic race based on scheduler or host callback timing would expose implementation machinery as language semantics.
Future.all(...) is the deterministic Core multi-Future coordination primitive.
A future library/domain-specific selection facility can define its own explicit semantic priority or ordering contract.
Cancellation is cooperative
Section titled “Cancellation is cooperative”Future.cancel() requests cancellation.
It does not mean:
interrupt arbitrary code immediatelyroll back completed effectsguarantee cancelled terminal state before returningFor a pending task-backed Future, the request is recorded and observed at portable cancellation boundaries.
cancel() is idempotent and returns the same Future object.
Calling it on an already-terminal Future leaves that terminal state unchanged.
Cancellation has explicit observation boundaries
Section titled “Cancellation has explicit observation boundaries”Portable cancellation boundaries include:
- before a newly created task begins ordinary Protos code;
- explicit suspension points;
- resume from suspension before ordinary Protos code continues;
- operations whose normative contract explicitly adds a cancellation-aware pending boundary.
Ordinary non-suspending code does not gain hidden cancellation points merely from:
method callsClosure callsallocationsloop back-edgesJIT/interpreter pollsgarbage collectionA CPU-bound computation that never reaches a cancellation boundary may therefore finish normally after cancellation was requested.
Cancellation never reverses completed effects
Section titled “Cancellation never reverses completed effects”This principle is fundamental:
Cancellation never reverses effects that have already occurred.
If an operation already crossed its semantic commitment boundary, cancellation cannot pretend that commitment never happened.
Individual I/O and communication operations define their own commitment and uncertainty rules.
Future cancellation composes with those contracts rather than overriding them.
Cancelling a waiter does not cancel what it waits for
Section titled “Cancelling a waiter does not cancel what it waits for”Suppose two tasks call:
source.value()on the same pending Future.
Cancelling one waiting task only changes that consumer’s control flow.
It does not automatically:
cancel sourcefail sourcewake the other waiter as cancelledrewrite source outcomeWaiting and producer ownership are separate.
Structured concurrency is task-scoped
Section titled “Structured concurrency is task-scoped”The most important ownership rule is:
Structured execution scope belongs to asynchronous task execution, not to each ordinary synchronous invocation frame.
A task-backed asynchronous computation has one current structured scope.
Ordinary nested:
Closure callsmethodswhile callbacksensure body/cleanup callsexecute inside that same scope unless they start new asynchronous work.
Core does not expose a public Task/scope object merely to represent this relationship.
Child task-backed work is owned by default
Section titled “Child task-backed work is owned by default”When current task execution creates asynchronous task-backed work, that child is owned by the current structured scope by default.
For example:
parent: (() => { child: (() => { slowWork() }).future()
42}).future()The synchronous body of parent can reach its normal result while child is
still pending.
But the parent asynchronous computation cannot terminalize normally until its owned child becomes terminal.
Returning a Future from a synchronous call does not change ownership
Section titled “Returning a Future from a synchronous call does not change ownership”This is a subtle but essential rule.
Suppose a helper called inside an asynchronous computation creates a child:
makeChild: () => { (() => slowWork()).future()}and returns it:
child: makeChild()The helper’s synchronous return does not create or close a structured scope.
It does not:
transfer child ownershipre-parent childremove the ownership edgeThe child remains owned by the surrounding asynchronous task scope that was current when it was created.
No escape analysis or “returned value” rule changes that.
Structured ownership limits lifetime, not result propagation
Section titled “Structured ownership limits lifetime, not result propagation”If an owner reaches otherwise-normal completion, it waits for every owned child to become terminal.
However, a child’s terminal:
failed(error)cancelleddoes not automatically fail or cancel the owner merely because ownership exists.
The owner observes child outcome explicitly through normal Future operations such as:
child.value()This avoids hidden “unobserved child failure” state.
Ownership says how long work may remain outstanding, not which result the parent must choose.
Returning, storing, or wrapping a child Future does not transfer ownership
Section titled “Returning, storing, or wrapping a child Future does not transfer ownership”All of these are ordinary value operations:
return Futurestore Future in slotput Future in Arraywrap Future in another objectpass Future to synchronous helperNone of them changes structured ownership.
Task-backed ownership cannot be escaped
Section titled “Task-backed ownership cannot be escaped”After a task-backed child Future is created, its ownership edge stays attached to the surrounding asynchronous task scope until the child is terminal.
Core exposes no Future.detach() operation. While the child remains pending,
there is no public operation that:
removes the ownership edgetransfers the edge to another scopere-parents the child taskNot every Future has a task ownership edge. For example, some I/O operations return non-task-backed Futures representing backend work. Their lifetime and cancellation follow the producer’s own contract.
Ownership does not escape the Actor domain
Section titled “Ownership does not escape the Actor domain”An Actor-local child task may outlive the synchronous activation that created it.
It does not thereby become:
Process-global workRootActor worka new Actorruntime-global workIt still belongs to the same Actor execution domain.
If that Actor incarnation terminates, Actor-local tasks are still subject to Actor-lifecycle cancellation.
Structured ownership therefore limits child lifetime; it does not grant an independent Actor-like lifetime.
Cancellation unwind composes with ensure
Section titled “Cancellation unwind composes with ensure”When task cancellation is observed, unwind runs applicable ensure cleanup.
The Future cannot become terminally cancelled until required cleanup and structured-cancellation obligations have completed.
As described in the previous chapter, the same already-delivered cancellation request is not repeatedly delivered at suspension boundaries inside that cleanup.
If cleanup itself fails, that later Error can replace cancellation and the task’s Future becomes failed instead.
A new child task creates its own structured scope
Section titled “A new child task creates its own structured scope”The ownership tree is recursive.
Conceptually:
parent task scope | +-- child task | +-- grandchild taskWhen the child starts asynchronous execution, it has its own structured scope for work that it creates.
This keeps the tree tied to asynchronous computations rather than synchronous call-stack depth.
Actor creation is not ordinary Future-child ownership
Section titled “Actor creation is not ordinary Future-child ownership”Actor lifetime is a different semantic boundary.
Creating an Actor does not automatically mean that Actor is a structured child task whose lifetime is governed by the creator’s Future scope.
Actors have their own lifecycle and supervision/communication model, covered in the Actors chapter.
This distinction prevents structured task ownership and Actor identity from becoming the same concept.
Practical patterns
Section titled “Practical patterns”Start asynchronous work and wait explicitly
Section titled “Start asynchronous work and wait explicitly”work: (() => { calculate()}).future()
result: work.value()Transform a successful result
Section titled “Transform a successful result”answer: (() => 20).future().then((value) => { value + 22})
answer.value()Flatten asynchronous transformation automatically
Section titled “Flatten asynchronous transformation automatically”answer: (() => 20).future().then((value) => { (() => value + 22).future()})
answer.value()Coordinate deterministic results
Section titled “Coordinate deterministic results”left: (() => 20).future()right: (() => 22).future()
values: Future.all(left, right).value()values[0] + values[1]Explicitly observe child failure when it matters
Section titled “Explicitly observe child failure when it matters”child: (() => { riskyWork()}).future()
child.value()Structured ownership keeps the child within the task lifetime, while value()
makes its result or failure relevant to the caller’s control flow.
Current executable lessons
Section titled “Current executable lessons”The repository contains a focused Future tutorial:
../../protos/tutorials/09-futures/01-async-closure.protosshows the same Closure executed withfuture()and observed withvalue();../../protos/tutorials/09-futures/02-compose-with-then.protosdemonstratesthen()plus automatic adoption/flattening;../../protos/tutorials/09-futures/03-wait-for-many.protosdemonstrates deterministicFuture.all(...)result ordering.
Representative conformance evidence
Section titled “Representative conformance evidence”The retained Future conformance suite includes focused cases such as:
../../protos/tests/conformance/future/construction-and-identity.protos;../../protos/tests/conformance/future/cancellation-and-terminal-errors.protos;../../protos/tests/conformance/future/all.protos.
These are executable evidence for current implementation behavior. The specification remains authoritative.
Practical rules to remember
Section titled “Practical rules to remember”- There are no
asyncfunctions orawaitkeyword in Core v0.1. - Execute a Closure asynchronously with ordinary
closure.future(). - Distinct Future-producing invocations normally produce distinct Future identities.
- A Future is exactly pending, resolved, failed, or cancelled; first terminal transition wins.
value()returns a resolved value, re-signals a stored failed Error, creates a freshCancelledError per cancelled observation, or suspends while pending.- A failed Future stores the exact same-domain Error; observing it does not restore producer control state.
- Future resolution automatically adopts/ flattens a Future result.
- Adoption transfers eventual outcome, not Future identity, ownership, or upstream cancellation.
then()creates asynchronous continuation work; source failure/cancellation bypasses the transform.thencancellation is downstream-only.Future.all(...)preserves argument order and uses deterministic argument-index failure/cancellation selection.- Core intentionally has no generic scheduler-timing-based Future race/select.
- Cancellation is cooperative and never reverses completed effects.
- Cancelling a waiter does not cancel the Future it is observing.
- Structured ownership belongs to asynchronous task scopes, not synchronous call frames.
- Returning, storing, or wrapping a task-backed Future does not transfer its ownership edge.
- Normal owner completion waits for owned children to become terminal, but child failure/cancellation is not implicitly adopted as the owner’s result.
- A child keeps its structured ownership edge until terminal completion;
Core exposes no
Future.detach()operation. - Cancellation unwind runs applicable
ensurecleanup before terminal cancellation. - Treat ownership/lifetime and result/error observation as separate concerns.
Normative references
Section titled “Normative references”For exact behavior, consult:
../../spec/concurrency/FUTURES_AND_TASKS.mdfor Future identity/state,future,value, adoption,then,all, cancellation, structured ownership, and Actor-lifecycle integration;../../spec/semantics/CALLABLES.mdfor ordinaryObject.futureplacement and invocation/callability rules;../../spec/semantics/ERRORS.mdfor stored Error identity and consumer-side re-signaling;../../spec/semantics/EXECUTION_AND_CONTROL.mdforensurecleanup during cancellation unwind;../../spec/concurrency/ACTORS.mdfor Actor-domain execution and termination consequences;07-errors-handlers-ensure-and-resource-lifetime.mdfor the Error/cleanup model that Future observation and cancellation reuse.
Those documents define the language. This chapter supplies the programmer-facing model for eventual results, cooperative cancellation, and structured task lifetime.