Async Design Notes
This file records design notes for Cosmo async computations. It is not yet a normative specification. It is self-contained, but it uses the effect vocabulary from effect.typeffect.typ and the concrete yield protocol from generator.typgenerator.typ.
The central rule is:
`async` is an abstract suspension effect.
`async` is an abstract suspension effect.
It describes that a computation may suspend, but it does not commit the function to a generator return type, a stackless state machine, a boxed future, or a fiber-backed runtime handle.
Functions and lambdas keep the unified shape:
A => R with E
A => R with E
An async-capable function is an ordinary function whose effect row contains asyncasync:
def fetch(path: Path): String with async = { reschedule() Fs.read_to_string(path)}
def fetch(path: Path): String with async = { reschedule() Fs.read_to_string(path)}
Its function type is:
Path => String with async
Path => String with async
This means that the computation eventually produces a StringString, but it may suspend before doing so.
R with asyncR with async is not the same type as:
R with yield[Pending]
R with yield[Pending]
The async effect is abstract. A backend or runtime may implement it with:
- a stackless state machine;
- a fiber stack;
- a boxed runtime continuation;
- a VM frame;
- a platform async primitive;
- another representation that preserves the async semantics.
The source type should remain stable when the runtime representation changes.
It is useful to understand one possible async protocol as:
class Poll[R] { case Pending case Ready(R)}
class Poll[R] { case Pending case Ready(R)}
In that model an async activation may produce PendingPending zero or more times and then produce Ready(R)Ready(R) exactly once:
poll -> Pendingpoll -> Pendingpoll -> Ready(value)
poll -> Pendingpoll -> Pendingpoll -> Ready(value)
This model explains stackless async lowering, but it is not required by the plain asyncasync effect. It becomes source-visible only when async is paired with a concrete yield protocol such as:
R with [async, yield[Pending]]
R with [async, yield[Pending]]
reschedule()reschedule() is an async operation:
def reschedule(): Unit with async
def reschedule(): Unit with async
It requests that the current async computation suspend and be run again later. In a stackless async implementation that uses yield[Pending]yield[Pending], it behaves like:
schedule current activation for lateryield Pending
schedule current activation for lateryield Pending
The scheduling step is essential. Returning PendingPending without arranging a future resume or wakeup can permanently lose the activation.
In a fiber-backed implementation, reschedule()reschedule() may instead enqueue the current fiber and switch back to the scheduler. Both implementations preserve the same source-level asyncasync effect.
Returning from an async computation completes it with its result:
def answer(): i32 with async = { 42}
def answer(): i32 with async = { 42}
In a pending/ready protocol this is:
Ready(42)
Ready(42)
An async computation completes once. Polling or resuming a completed activation is an error unless a later runtime specification defines a different behavior.
awaitawait consumes an async computation and produces its completed value in the current async computation.
Conceptual rule:
await child: if child is ready with value: continue with value if child is pending: suspend the current computation
await child: if child is ready with value: continue with value if child is pending: suspend the current computation
In a stackless implementation, the current frame records the state after the awaitawait and returns PendingPending when the child is pending. In a fiber-backed implementation, the current fiber yields to the scheduler until the child is ready.
awaitawait does not catch residual effects such as throw[E]throw[E]; those effects remain part of the surrounding computation unless a handler deals with them.
An async handler may implement async suspension through a generator protocol:
R with [async, yield[Pending]]
R with [async, yield[Pending]]
Under this handler:
reschedule()reschedule()schedules the current activation and yieldsPendingPending.await childawait childyieldsPendingPendingwhenchildchildis pending and continues whenchildchildis ready.return valuereturn valuecompletes withReady(value)Ready(value).
Example:
def later(): i32 with [async, yield[Pending]] = { reschedule() 42}
def later(): i32 with [async, yield[Pending]] = { reschedule() 42}
Conceptual state machine:
state 0: schedule current activation for later state = 1 return Yield(Pending)state 1: state = done return Ready(42)
state 0: schedule current activation for later state = 1 return Yield(Pending)state 1: state = done return Ready(42)
This is the explicit way to request stackless async lowering. The presence of asyncasync alone does not force this representation.
The annotation:
R with [async, yield[Pending]]
R with [async, yield[Pending]]
requires that all async suspension in the body be expressible through the chosen yield[Pending]yield[Pending] protocol. If an async operation can only be implemented by a fiber or another runtime mechanism that cannot be translated to the yield protocol, the stackless lowering must fail with a diagnostic.
In other words, yield[Pending]yield[Pending] is a concrete lowering contract, not a magic conversion for arbitrary async runtimes.
For:
R with async
R with async
a runtime may choose a stackless or stackful representation.
A stackless implementation stores the current continuation as explicit state:
frame { state params locals live across suspension child async state}
frame { state params locals live across suspension child async state}
A stackful implementation stores the current continuation in a runtime-owned stack or stack segment:
fiber { stack entry function scheduler state}
fiber { stack entry function scheduler state}
If a fiber-backed async activation is first-class, it must be represented by an owned runtime handle, commonly a boxed object or arena handle. The public type should still be the async activation protocol, not Box[Fiber]Box[Fiber].
For:
R with [async, yield[Pending]]
R with [async, yield[Pending]]
the selected implementation must expose the yield protocol. The default native lowering should be stackless state-machine lowering when the body satisfies the lowering requirements.
Boxing is an implementation and storage concern:
- A concrete stackless async frame may stay unboxed when it does not escape.
- A stackless activation may be boxed for type erasure or long-lived storage.
- A first-class fiber activation needs stable runtime ownership, usually a box, reference-counted object, or runtime handle.
The source effect asyncasync should not force the user-facing return type to name the chosen storage strategy.
Any value live across an async suspension point is part of the continuation. Stackless implementations store it in a frame; stackful implementations keep it on a fiber stack. The safety rule is representation-independent:
Values that remain live across suspension must be suspend-safe.
Values that remain live across suspension must be suspend-safe.
The first implementation should conservatively reject borrowed locals that remain live across awaitawait or reschedule()reschedule() unless the type system proves the borrow cannot outlive its owner and cannot create an invalid self-reference.
Detached async work has stricter requirements: it must not capture stack-only handlers or borrowed locals unless the captured values are owned, lifetime-safe, and runtime-safe for the detached execution context.