IO and async
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.
The problem this solves
Section titled “The problem this solves”In languages with function coloring (JavaScript, Python, Rust/Tokio), async infects the call stack:
// Rust/JS: async leaks upward infinitelyasync fn read_file() -> String { ... }async fn process() -> Data { read_file().await } // forcedasync fn handle() -> Response { process().await } // forcedasync fn main() { handle().await } // forcedHere, no function is marked. The scheduler suspends the process when IO blocks and resumes it when the data arrives:
// Looks synchronous, is non-blocking underneathfn 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.
IO parallelism via processes
Section titled “IO parallelism via processes”When you want multiple IO operations in parallel, you use processes, not async/await nor Futures:
// Parallel IO: each process does its read independentlychan_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 necessaryresult_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}What the runtime guarantees
Section titled “What the runtime guarantees”- 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
FutureorTaskallocation, since the process is already the unit of suspension.