Skip to content

Specification §12

IO and async

language-design.md §12 · 76 lines · 2 min read

Inspired by Zig 0.17: async is decoupled from the type system. The developer writes linear code, without worrying about whether the operation is blocking or non-blocking. The IO interface resolves this internally.

No “function coloring”: a function that does IO does not force its caller to be marked as async. The scheduler takes care of suspending and resuming processes transparently.

In languages with function coloring (JavaScript, Python, Rust/Tokio), async infects the call stack:

// Rust/JS: async leaks upward infinitely
async fn read_file() -> String { ... }
async fn process() -> Data { read_file().await } // forced
async fn handle() -> Response { process().await } // forced
async fn main() { handle().await } // forced

Here, no function is marked. The scheduler suspends the process when IO blocks and resumes it when the data arrives:

// Looks synchronous, is non-blocking underneath
fn read_file(path: string) -> Result[string, error{NotFound}] {
return io.read(path) // scheduler suspends this process here if necessary
}
fn process(path: string) -> Result[Data, error{...}] {
content := read_file(path) catch |e| { return e }
return parse(content)
}
fn handle(req: Request) -> Response {
data := process(req.path) catch |e| {
return Response.error(e)
}
return Response.ok(data)
}

io.read is a normal function call. Internally, it is implemented via the runtime’s IO interface, which can use epoll, io_uring or kqueue, according to the OS. The current process is suspended by the scheduler while it waits, freeing the core for other processes. When the data arrives, the scheduler resumes from where it stopped.

When you want multiple IO operations in parallel, you use processes, not async/await nor Futures:

// Parallel IO: each process does its read independently
chan_a: Channel[->string] = ...
chan_b: Channel[->string] = ...
spawn fetch_url("https://api-a.com", chan_a)
spawn fetch_url("https://api-b.com", chan_b)
// Wait for both, block only when necessary
result_a := -> chan_a catch |e| { return }
result_b := -> chan_b catch |e| { return }

Or, if you want the first one to arrive, with match without a subject (the “select” form; section 4):

match {
data := -> chan_a => handle(data)
data := -> chan_b => handle(data)
} timeout(5s) {
return error.Timeout
}
  • A io.* call never blocks the scheduler, only the process that made it (epoll or io_uring underneath). A blocking FFI call (section 17) is another story: work-stealing absorbs it in a pool, and the deterministic one requires you to isolate it. See “Blocking FFI and the scheduler” (section 3).
  • The process is suspended cooperatively at the exact point of the io.*; between instructions, the scheduler can still preempt it (section 3).
  • Zero overhead of Future or Task allocation, since the process is already the unit of suspension.