std.async
std.async provides the canonical Future, structured Scope/Join, timeout, and the explicit bridge for coarse blocking work. Use it for direct await, structured waiting, deadlines, scheduler yields, and deliberately offloading a blocking closure. Resource readiness is an SDK/runtime implementation detail, not a public integer-handle future.
The language recognizes async semantics only on the canonical declarations in this module.
Quick Example
import std.async;
import std.io;
import std.time;
import std.mem.{ AllocError };
fn worker(value: i32) -> ^Future<i32> {
_ = await sleep(time.ns(0));
return value + 1;
}
fn main() throws(AllocError) {
let result = await worker(41);
io.println(try ^"{result}");
}Contract
- Futures are cold. Calling a future-returning function captures arguments and returns a suspended future; the async body starts only when awaited or passed to a structured helper.
- Plain
Future<T>is neitherSendnorSync. The sole portable identity isFuture<T> where Send, whose producer proves the complete possible in-flight frame portable. There is no Futurewhere Sync/Send + Syncand no runtime witness. FutureandJoinhandles cannot be constructed manually. Create futures with future-returning calls or helpers; scoped Joins come only fromScope.start. Handle fields are private.Futurepayloads cannot contain checked references. Directawaitmay pass references to future-returning calls while the reference is live.awaitacceptsFuture<T>or scopedJoin<T>, starts the work when needed, drives it to completion, moves out the result, and consumes the handle.Future<void>is ordinary typed composition: fallthrough,return;, andreturn void();complete withvoid(), and await yields that zero-sized value without a special future or completion path.- Bare future expressions must be consumed by
await,async.all,async.race,async.timeout,Scope.start, or explicitstd.drop. - Stored or structured futures that capture references are rejected. Direct
awaitmay pass references. - Direct
await, structured helpers, and local Scope start accept qualified or unqualified Futures. Parallel start requires a qualified frame and separateSendsuccess/error payloads. ScopedJoin<T>cannot escape its Scope. - The scheduler is cooperative. Suspension happens only at
await, runtime futures such asasync.sleep, resource I/O, structured helpers, and Scope joins. - The scheduler does not guarantee fairness, starvation freedom, exact poll counts, exact timer/readiness poll counts, cross-runtime ordering, or batching strategy.
async.blockingis the sole general bridge from cooperative execution to the bounded blocking pool. It consumes one concrete ownedwhere Sendclosure; it does not detach work or erase the closure behind a public dynamic callable.- Structured work is started with
Scope.start. The removedGroup.addname does not return as an alias: RFC 0064 reserves collection-likeaddfor one non-positional member, while starting cold work is an execution transition.
Runtime Futures
async.yield() and async.sleep(duration) are fully cold. Constructing or explicitly dropping these futures does not touch the scheduler or timer heap. The runtime action is registered only when the future starts. Their intrinsic runtime state is audited portable, so each returns ^Future<void> where Send.
There is no public readable(integer) or writable(integer) bridge. Typed I/O resources create their own cold futures. Internally those futures may register private readiness state tied to the nominal resource identity and its generation, so close/reuse cannot redirect a stale wait to a different host resource.
Blocking Work
async.blocking moves one concrete, single-use where Send closure to the runtime's dedicated bounded blocking pool and returns a cold region-bound Future for its result:
// Normative schema; implementation pending.
fn blocking<T: Send, E: Send, F, region L>(job: ^F in L)
-> ^Future<T throws(E)> in L
where F: (^self) -> T throws(E) where Send;// RFC 0063 target; implementation pending.
let result = try await async.blocking(<-job);Use this bridge for one intentionally coarse operation, such as performing a complete synchronous fs.Dir.walk() inside the closure and materializing only the summary that must cross back. Do not submit every directory entry or tiny read as a separate blocking job.
The closure is consumed exactly once and may start on a different OS thread, so its environment and eventual success/error payloads must satisfy the corresponding Send rules. The result Future retains any valid outer region bound; blocking is not a lifetime-erasure or detached-task escape hatch.
The pool is created lazily and has bounded workers and a bounded admission queue. A cold Future dropped before start does not enqueue the closure. A cancellation request before execution removes an admitted job when possible. After the closure starts, cancellation cannot interrupt arbitrary synchronous user code: the Future, its enclosing Scope, race, or timeout waits until the closure returns and cleanup finishes. Only then is cancellation complete.
The same true-completion rule applies to std.async.fs. Native cancellation may be requested, but memory, loans, handles, and blocking work remain owned until the backend proves quiescence. Cancellation is not a checked error and creates no detached job.
Structured Helpers
async.all, async.race, and async.timeout are cold until awaited.
async.all(f1, f2, ...)starts all children concurrently, returns one result position per child includingvoid, and cancels unfinished siblings on the first observed child failure or cancellation. For example,Future<void>plusFuture<i32>yields(void, i32); zero children yieldvoid().async.race(f1, f2, ...)starts all children concurrently, returns the first observed completion, and cancels losers. All success types must match.async.timeout(f, duration)returns the child result when the child wins and throwsTimeoutError.Expiredwhen the deadline wins.
Structured result selection is deterministic. Children are scanned left to right on each structured poll. If multiple outcomes are visible in the same scan, the lower child slot wins. For async.timeout, the child wins a same-scan tie with the deadline.
Cancellation is lifecycle state, not a checked error value added to every returned error set.
Scope And Join
Open cooperative children with [try] await with async.Scope.local() as tasks { ... }. This complete form is the compiler-known RFC 0009 construct, not an application of ordinary with. tasks.start(future) consumes a cold Future and returns an owned scoped Join. The Join cannot escape the Scope body, and Scope close cancel-and-joins every unfinished child before returning.
Local start accepts both ^Future<T> and ^Future<T> where Send. A borrowed Future retains its region and must still be consumed before that region ends.
import std.async;
import std.io;
import std.mem.{ AllocError };
fn child(value: i32) -> ^Future<i32> {
_ = await yield();
return value;
}
fn main() throws(AllocError) {
await with async.Scope.local() as tasks {
let first_join = tasks.start(child(10));
let second_join = tasks.start(child(32));
let first = await <-first_join;
let second = await <-second_join;
assert first == 10;
assert second == 32;
}
io.println("done");
}Await each Join directly with ownership transfer. A fallible child contributes its declared error to the Scope outcome, so the whole construct uses leading try; cancellation remains lifecycle state. tasks.cancel() requests orderly cancellation, and Scope close still joins every child.
Parallel Mode
async.Scope.parallel() has the same lexical lifecycle as Scope.local() but may run children on the worker pool. Use it for independent CPU work and keep Scope.local() for cooperative same-worker work. Every parallel start requires ^Future<T throws(E)> where Send; the producer has already verified captures, suspend-live locals, cleanup, nested awaits, and runtime frame state. The start separately requires T and every checked-error payload in E to be Send for Join transport.
import std.async;
import std.io;
import std.mem.{ AllocError };
fn square(value: i32) -> ^Future<i32> where Send {
_ = await yield();
return value * value;
}
fn main() throws(AllocError) {
var total = 0;
await with async.Scope.parallel() as tasks {
let two = tasks.start(square(2));
let three = tasks.start(square(3));
total = await <-two;
total = total + await <-three;
}
assert total == 13;
io.println(try ^"{total}");
}An unqualified Future cannot be strengthened at start. A region-bound qualified Future is accepted only when Scope close joins before its region ends. See Concurrency And Parallelism.
Ownership And Cleanup
Future frames own their captured arguments and locals. Owned values are dropped exactly once when the future is awaited and consumed, explicitly std.dropped, cancelled before it starts, or destroyed by the owning runtime after cancellation or completion.
Dropping an unawaited structured future does not enter child async bodies and does not start cold helper futures. Captured child frames are cancelled and cleaned up by the owning runtime.
Registered user defer bodies are part of async cleanup. Cancellation drives them in LIFO order, and await inside a defer body is allowed to finish before the cancelled task becomes complete.
Runtime allocation strategy, task queue layout, timer wheel/heap structure, and resource-readiness backend, blocking-pool worker count, and admission strategy are unspecified. Public behavior is the cold-future contract, bounded blocking offload, true-completion cancellation, structured cancellation contract, result/error propagation, and documented scheduling boundaries.
API Reference
Types
| Name | Kind | Purpose |
|---|---|---|
^Future<T> | struct | Cold async value returned by future-returning calls and future helpers. |
^Future<T> where Send | struct | Statically portable cold frame; same runtime representation, with no witness. |
^Join<T throws(E)> | compiler-owned scoped handle | Started child result; must be consumed inside its Scope region. |
Scope | compiler-known lexical construct | local and parallel modes; has no movable user representation. |
TimeoutError | error | Expired when an explicit timeout wins. |
Functions
| Function | Signature | Notes |
|---|---|---|
ready | ready<T>(value: ^T) -> ^Future<T> | Moves a value into an already-ready future. |
sleep | sleep(duration: time.Duration) -> ^Future<void> where Send | Completes after the timer fires; intrinsic frame state is audited portable. |
yield | yield() -> ^Future<void> where Send | Cooperatively yields to the scheduler; intrinsic frame state is audited portable. |
blocking | async.blocking(<-job) -> region-bound cold Future<T throws(E)> | Normative schema is given above. Consumes one concrete (^self) -> T throws(E) where Send closure; transported T and every E payload are Send, and cancellation waits for real completion after start. |
all | all<T...>(futures: ^Future<T>...) -> ^Future<(T...) throws(E)> | Structured wait preserving every child position, including void; an empty result pack normalizes to void. E is the union of child error sets. |
race | race<T...>(futures: ^Future<T>...) -> ^Future<T throws(E)> | Structured race with one success type and unioned child errors. |
timeout | timeout<T>(future: ^Future<T throws(E)>, duration: time.Duration) -> ^Future<T throws(E | TimeoutError)> | Deadline wrapper; expiry adds TimeoutError.Expired. |
Scope Operations
| Method | Signature | Notes |
|---|---|---|
Scope.local.start | start(future: ^Future<T throws(E)>) -> ^Join<T throws(E)> | Accepts qualified or unqualified identity and starts one local child. |
Scope.parallel.start | start<T: Send, E: Send>(future: ^Future<T throws(E)> where Send) -> ^Join<T throws(E)> | Requires portable frame plus separate Send success and every concrete error payload. |
cancel | cancel() | Requests orderly cancellation; Scope close still joins all children. |
Status
std.async is the current public async API. For language-level semantics, see Async. Use std.async.io for async byte streams, std.channel for async communication, and std.time for durations used by timers and deadlines. For the full exported surface, see Standard Library Inventory.