Skip to content

std.async

The std.async modules carry the asynchronous line that landed across the early 0.4.x releases: futures, the event loop, and timers in 0.4.0, then the epoll reactor with its non-blocking byte surface in 0.4.1, then TCP over that reactor in 0.4.3. There are five modules, each imported separately:

@import std.async.future
@import std.async.loop
@import std.async.time
@import std.async.io
@import std.async.net

The async func, await, and async_run keywords landed in 0.4.2. They are language surface, not a module, but they ride exactly this machinery, so you often import std.async.future and std.async.loop beside them. The keyword layer is covered in the async reference; this page covers the library surface the keywords sit on. For a task-oriented walkthrough, see the async guide.

A shared shape runs through these modules. A Future<T> is a one shot completion slot: minted pending, completed exactly once from any thread, and consumed exactly once by the thread that owns the event loop. The handle is a plain pair of words that copies freely, and every copy names the same future, which is how a pool lambda captures it. Unlike the concurrency handles, a future is single consume: reading it retires its record in the generational heap, so a second read faults by name. The loop itself is a process singleton like the thread pool, and in fact it schedules onto that same thread pool; the pool is the substrate an offloaded completer runs on.

Added in 0.4.0. A Future<T> is a one shot completion slot: minted pending, completed exactly once from any thread, and consumed exactly once by the thread that owns the event loop.

struct Future<T>
func future_new<T>() -> Future<T>
func future_wrap<T>(h: *void) -> Future<T>
func complete<T>(f: Future<T>, v: T, e: error) -> error
func await<T>(f: Future<T>) -> (T, error)
func await_timeout<T>(f: Future<T>, ms: int64) -> (T, error)
func try_poll<T>(f: Future<T>) -> (T, error)
func future_free<T>(f: Future<T>) -> void
  • future_new() mints a pending future, the element type pinned by the binding annotation the same way chan_new and alloc pin theirs.
  • future_wrap(h) rebuilds a typed handle around a raw future word the runtime already minted, the element type pinned by the annotation again.
  • complete(f, v, e) stores the value and the error together from any thread and wakes the loop, so an offloaded body hands its own failure through unchanged and the awaiter reads exactly the pair the completer supplied. The second completion is refused with "future already completed" and its value is dropped, whether the loser lands before or after the awaiter consumes the future.
  • await(f) parks until completion and hands back the value and error pair.
  • await_timeout(f, ms) parks at most ms milliseconds against the monotonic clock, then comes back with "await timed out", the zero value, and the future still live: the recoverable escape hatch.
  • try_poll(f) never parks, reporting "future is pending" while unresolved and consuming the future once it is ready.
  • future_free(f) releases a future that will never be consumed. Do not free one a completer may still touch, the same discipline chan_free follows.

Consuming reads the pair and retires the record in the generational heap, so a future is awaited once the way a thread is joined once, and the second consume faults with "use of a dead future".

A future element must be safe to hand to the awaiter, the same rule channel elements and spawn captures follow: an element type containing a slice, a closure, or an interface value, wherever it sits, including buried in a struct or enum field, is a compile error at the minting site. A view of the completing thread’s frame would dangle in the awaiter, so put the backing on the heap and hand the awaiter a managed pointer instead.

A completer that cannot carry the typed handle, a spawned or submitted lambda inside an async func’s own offload, completes through complete_raw, the off-thread completer surface. It takes the future’s raw words and generation rather than the typed Future<T>, since the typed handle would view a frame the task outlives. The offload above, which captures the typed handle in a plain submit lambda, is the direct hand-written form:

@import std.concurrent.pool
@import std.async.future
@import std.async.loop
le := loop_init()
le.ignore()
pe := pool_start(2)
pe.ignore()
f: Future<int64> = future_new()
se := submit(lambda () -> void {
n, ne := compute()
ne.ignore()
ce := complete(f, n, ne)
ce.ignore()
})
se.ignore()
v, e := await(f)
e.ignore()
println(v)
pool_shutdown()
loop_free()

future_wrap runs the other direction across that same raw layer. Where complete_raw completes a future addressed by its words, future_wrap takes a raw handle word and rebuilds a typed Future<T> around it, reading the record’s current generation as it goes. The binding annotation pins the element type, f: Future<int64> = future_wrap(h), exactly as it does for future_new, and the element ban lands here too, since a wrap is a minting site as much as a mint is. What it does not do is create anything or complete anything: the record already exists, the wrapped handle names it, and the future’s state stays whatever that record already carried, pending or completed. Wrapping is how you make a copy of a handle when the bare word is all you were given.

The standard library is its heaviest user, since a module that mints its future down in C holds a raw handle and nothing else. sleep_async is future_wrap(cool_timer_new(ms)), and readable, writable, and chan_recv_async each wrap the handle their own runtime call hands back. That is why it landed in 0.4.0 beside future_new rather than later: the timer module could not return a Future<int64> without it. In your own code it is the awaiting mirror of complete_raw, for when the typed handle cannot cross a boundary but a bare word can, as when an async func takes h: *void and wraps it on the far side. The word must name a live future record; a stale or invented one is on the raw layer’s honor system, the same footing every *raw pointer stands on.

@import std.async.future
@import std.async.loop
le := loop_init()
le.ignore()
f: Future<int64> = future_new()
h := f.h // the bare word, no future captured
ce := complete(f, 7, error {})
ce.ignore()
g: Future<int64> = future_wrap(h)
v, e := await(g) // consumed through the wrapped handle
e.ignore()
println(v) // 7
loop_free()

Added in 0.4.0. The event loop is a process singleton like the pool, started on the thread that will consume futures, which then becomes the owner.

func loop_init() -> error
func loop_free() -> void
  • loop_init() starts the loop on the calling thread and makes it the owner. Its error exists on a failed start.
  • loop_free() frees the loop after the last completer has finished.

Completion is legal from any thread, but every other touch, minting a future, awaiting, polling, or freeing one, asserts the owner and faults by name off the owner thread, so the single threaded discipline is mechanical rather than documentary. Unlike the pool, a freed loop may be initialized again, on any thread, which then becomes the new owner; futures from the earlier loop stay consumable, but their pending timers are gone.

An await that provably cannot finish is a deadlock, not a hang. When no timer is pending, no spawned thread is alive, no pool task is in flight, and no watch is armed, nothing in the process can complete the future, and the wait aborts with "the event loop is idle but work is still pending". Every gauge drops only after its body finishes and every drop wakes the loop, so the gate never fires against a completion still in flight.

Added in 0.4.0. Timers are futures the loop completes.

func sleep_async(ms: int64) -> Future<int64>
  • sleep_async(ms) mints a Future<int64> the loop’s timer heap completes with 0 at its deadline.

Timers fire while any await or poll runs, deadlines measure on the monotonic clock, and two timers sharing a deadline complete in creation order, so awaiting a long timer lets shorter ones fire in passing.

Added in 0.4.1. The reactor is one C thread that turns file descriptor readiness into one shot readiness futures on the event loop. It runs no user code and touches no user memory; pipes are the deterministic rig to exercise it.

struct Pipe
func reactor_start() -> error
func reactor_stop() -> void
func readable(fd: int64) -> Future<int64>
func writable(fd: int64) -> Future<int64>
func pipe_new() -> (Pipe, error)
func fd_nonblock(fd: int64) -> error
func fd_close(fd: int64) -> error
func read_nb(fd: int64, buf: *void, cap: int64) -> (int64, error)
func write_nb(fd: int64, buf: *void, n: int64) -> (int64, error)
  • reactor_start() starts the reactor thread. Its error fires on a double start, an operating system refusal setting up the epoll and event descriptors, or a start landing while a concurrent stop is still in flight, each "the reactor could not start".
  • reactor_stop() flips the reactor stopped, finishes delivering everything already ready, then joins. A stopped reactor restarts clean, with a fresh epoll descriptor on each start.
  • readable(fd) and writable(fd) arm a one shot watch on a file descriptor and return a future completed with the readiness mask.
  • pipe_new() makes a close on exec, blocking by default pipe with r and w fields, refusing with "the pipe could not be created".
  • fd_nonblock(fd) sets a descriptor non-blocking; call it on an end before you move bytes through it, refusing with "the file descriptor could not be set non-blocking".
  • fd_close(fd) closes a descriptor, refusing with "the file descriptor could not be closed".
  • read_nb(fd, buf, cap) and write_nb(fd, buf, n) move bytes through a caller staged buffer, the channel element idiom, and never block.

The readiness mask is 1 for readable, 2 for writable, 4 for hangup, and 8 for error, ORed together into one int64. Only one armed watch is allowed per file descriptor at a time; arming a second watch on an fd that already carries one is a fault, not an error, since the signatures carry no error channel. future_free on a readiness future does not disarm its watch: the watch stays armed until it later fires against a dead record and loses like any other refused completer.

read_nb and write_nb each refuse with "would block" when the operating system has nothing to give or take, the one canonical recoverable string in both directions, or with "the read failed" and "the write failed" on a harder refusal. A read_nb returning a count of zero with no error is end of stream, every writer closed.

The sanctioned order is loop_init, then reactor_start, every watch armed and fired, reactor_stop, then loop_free. Writing to a pipe whose read end is closed delivers SIGPIPE and kills the process; that hardening has not landed yet, so no sanctioned program writes to a pipe with no reader.

@import std.async.io
@import std.async.future
@import std.async.loop
le := loop_init()
le.ignore()
se := reactor_start()
se.ignore()
p, pe := pipe_new()
pe.ignore()
ne := fd_nonblock(p.r)
ne.ignore()
w := readable(p.r)
buf: *raw int64 = alloc_bytes(sizeof(int64))
buf[0] = 7
n, we := write_nb(p.w, buf, sizeof(int64))
we.ignore()
println(n) // 8, bytes written
m, me := await(w)
me.ignore()
println(m) // 1, readable
v, ve := read_nb(p.r, buf, sizeof(int64))
ve.ignore()
println(v) // 8, bytes read
println(buf[0]) // 7
free(buf)
ce := fd_close(p.r)
ce.ignore()
cwe := fd_close(p.w)
cwe.ignore()
reactor_stop()
loop_free()

Added in 0.4.3. TCP over the reactor’s readiness futures. A socket is an ordinary file descriptor the reactor already watches, so this is a thin layer over the non-blocking socket calls with no new event machinery. Addresses are literal IPv4 dotted quads; there is no name resolution yet.

func tcp_listen(port: int64, backlog: int64) -> (int64, error)
func tcp_local_port(fd: int64) -> (int64, error)
async func tcp_accept(fd: int64) -> (int64, error)
async func tcp_connect(host: string, port: int64) -> (int64, error)
async func tcp_read(fd: int64, buf: *void, cap: int64) -> (int64, error)
async func tcp_write(fd: int64, buf: *void, n: int64) -> (int64, error)
func tcp_close(fd: int64) -> error
  • tcp_listen(port, backlog) binds and listens on loopback and returns the listening descriptor; a port of 0 lets the operating system assign an ephemeral one.
  • tcp_local_port(fd) reads back the port a listener was assigned, the way you recover an ephemeral port bound with port 0.
  • tcp_accept(fd) awaits a connection and returns the client descriptor. Async.
  • tcp_connect(host, port) connects to a literal IPv4 address, completing the non-blocking handshake and surfacing a refusal as a clean error. Async.
  • tcp_read(fd, buf, cap) awaits readability and reads once into a caller-staged buffer; a count of zero with no error is end of stream. Async.
  • tcp_write(fd, buf, n) writes every byte, awaiting writability as needed, so a short write never drops the tail. Async.
  • tcp_close(fd) closes a descriptor.

The four async funcs await from inside an async func; awaiting one from synchronous code is rejected, "'await' is only legal inside an async func". Run them under a live reactor: the sanctioned order is loop_init, reactor_start, the networking tasks under async_run, reactor_stop, then loop_free. A write to a peer that has closed its end returns "broken pipe" rather than killing the process, since SIGPIPE is ignored process-wide, and a peer reset falls to the generic "the write failed". A descriptor mint that runs the process out of file descriptors returns "too many open files" without leaking the half-open descriptor. For a full server and client on one loop, see the async guide.