A well-known problem of async in many languages, including Rust, is function colouring. The crux is that async and blocking code are not compatible with one another, leading to an ecosystem split between sync and async libraries. The Zig language's async system "solves" function colouring by parametrizing all code across blocking and async modes of execution. This post details Zig's solution. I want to explore how Zig's solution can apply to Rust.
Zig uses special keywords for async functions. The async
keyword is used to call async functions and runs the function until it reaches its first await/suspension point. Unlike Rust, async function calls in Zig use different syntax from sync calls, which is an important point we'll touch on later. Zig also has await
, which, like Rust, suspends the current async function to wait for an expression to complete. An async call in Zig looks like this:
fn foo() {
var frame = async read_socket(); // The frame is the "future" of the read_socket call
var msg = await frame; // Awaits the frame (Rust equivalent is frame.await)
}
This code makes sense if read_socket
is async, but it also works if read_socket
is sync. In that case, the async
line blocks on read_socket
as if it was called normally, and the await
line returns the message from the socket without needing to suspend foo
. foo
also changes from an async to a sync function. The same thing applies to "synchronous" function calls. In Zig, a sync function call is simply written as read_socket()
, which behaves like await async read_socket()
if read_socket
is async. This also makes foo
async. Since the behaviour of all Zig code is defined for both sync and async environments, Zig provides a compile-time switch called io_mode
that sets all I/O primitives as async or blocking. This leads to the whole library to be compiled as either async or blocking, allowing the same codebase to support both "colours".
Zig's secret sauce for eliminating colouring is the fact that its code is generic across async and sync APIs. In Rust, the usual answer for this is effect systems, a well-explored topic on this forum. A simpler way to abstract across sync and async functions is to model both as special cases of generators. Sync functions can be thought of as generators that never yield; async functions are generators that keeps yielding until completion. More specifically, sync and async functions can be modeled as functions that return generators. For example, the following functions:
fn foo(n: u32) -> u32 {
n
}
async foo_async(n: u32) -> u32 {
tokio::time::sleep(1).await;
n
}
would desugar into:
fn foo(n: u32) -> impl Generator<Return = u32, Yield = ()> {
|| { n } // This is a generator, not a closure
}
fn foo_async(n: u32) -> impl Generator<Return = u32, Yield = ()> {
|| {
let fut = tokio::time::sleep(1);
// This loop is the desugared await
loop {
if let Completed(val) = fut.resume(...) {
break val;
}
yield;
}
n
}
}
After desugaring, both foo
and foo_async
return generators with identical signatures, so it's possible to interact with both functions with the same surrounding code. However, there is one caveat regarding function calls. The desugared return type works with foo_async()
, which returns a future that can be represented as a generator. However, the sync call foo()
returns u32
, which is the output of the generator, not the generator itself. The problem is that Rust's async and sync calls are semantically different operations sharing the same syntax. Async calls return the "frame" of the function without executing the body, while sync calls execute the function to completion. The solution is to separate "async calls" and "sync calls" into distinct language constructs, like Zig does. In Zig, async foo()
always returns a frame that will be awaited later, even in sync contexts; foo()
always drives the function to completion, even in async contexts. For this post, I will use async foo()
for async calls and completion foo()
for sync calls.
With the separation of async and sync calls into different operations, we have 3 async-related language constructs: fut.await
, async foo()
and completion foo()
. async foo()
desugars to foo()
, which returns the function's frame/generator. fut.await
desugars into something like:
loop {
if let Completed(val) = fut.resume(...) {
break val;
}
yield;
}
Note that if fut
is a "sync generator" that never yields, fut.await
also won't yield. Lastly, completion foo()
desugars to (async foo()).await
. Like with await
, the expression is only async if foo
is async.
Since we already model both sync and async functions as generators, and the async language constructs all desugar into code that works with generators, our language constructs work on both sync and async functions. Furthermore, for constructs like await
and completion
, the code's asynchrony depends on the asynchrony of the functions it's calling. This allows the asynchrony of an entire application to be determined by the asynchrony of its I/O primitives, since asynchrony propagates all the way up the callstack until main
, which cannot be async. As such, main
is responsible for driving the entry point of the application, which may or may not be async, to completion. Also note that the compiler can determine the asynchrony of a function statically by checking whether it's possible for the function to yield. If a function contains an await
or completion
for any async function calls, then it is marked as async even if it never actually awaits. This allows sync functions to be optimized into regular calls instead of generator state machines. Additionally, main
can determine whether the application's entry point is sync or async at compile time and drive the function differently using that information (eg throw the function on Tokio if it's async, otherwise just do blocking execution).
I'm still not sure how to set the asynchrony of I/O primitives. Zig uses a global switch, but that requires their standard library to provide sync and async versions of I/O functions. Doing this in Rust means merging async-std
into std
, which is a big change. Also, I don't know how executor-dependent APIs like tasks will work under this system (what's a synchronous version of tasks? Threads?). On a related note, some code patterns just don't work in both sync and async contexts, even if they do compile. For example, something like join!(foo(), bar())
will likely call foo
and bar
sequentially if both functions are blocking, which may lead to deadlocks if the code was written with async in mind. Zig treats this as a fact of life, which may not be OK for us. Lastly, my proposed distinction between sync and async function calls will break almost all existing async code, so it can only be introduced in a new edition.