LmCast :: Stay tuned in

Zig's new plan for asynchronous programs

Recorded: Dec. 3, 2025, 3:04 a.m.

Original Summarized

Zig's new plan for asynchronous programs [LWN.net]

LWN.net
News from the source

ContentWeekly EditionArchivesSearchKernelSecurityEvents calendarUnread commentsLWN FAQWrite for us

User:
Password: |

|

Log in /
Subscribe /
Register

Zig's new plan for asynchronous programs
[LWN subscriber-only content]

Welcome to LWN.net

The following subscription-only content has been made available to you
by an LWN subscriber. Thousands of subscribers depend on LWN for the
best news from the Linux and free software communities. If you enjoy this
article, please consider subscribing to LWN. Thank you
for visiting LWN.net!

By Daroc AldenDecember 2, 2025

The designers of the

Zig programming language have been working to find a
suitable design for asynchronous code for some time.
Zig is a carefully minimalist language, and its

initial design for
asynchronous I/O did not fit well with its other
features. Now, the project has

announced (in a Zig SHOWTIME video) a new approach to asynchronous I/O that
promises to solve the

function coloring problem, and allows writing code that will execute
correctly using either synchronous or asynchronous I/O.

In many languages (including Python, JavaScript, and Rust), asynchronous code
uses special syntax. This can make it difficult to reuse code between
synchronous and asynchronous parts of a program, introducing a number of headaches for
library authors. Languages that don't make a syntactical distinction (such as
Haskell) essentially solve the problem by making everything asynchronous, which
typically requires the language's runtime to bake in ideas about how programs
are allowed to execute.

Neither of those options was deemed suitable for Zig. Its designers wanted to
find an approach that did not add too much complexity to the language, that
still permitted fine control over asynchronous operations, and that still made
it relatively painless to actually write high-performance event-driven I/O. The
new approach solves this by hiding asynchronous operations behind a new generic
interface,

Io.

Any function that needs to perform an I/O operation will need to have access to
an instance of the interface. Typically, that is provided by passing the
instance to the function as a parameter, similar to Zig's

Allocator
interface for memory allocation. The standard library will include two built-in
implementations of the interface: Io.Threaded and Io.Evented.
The former uses synchronous
operations except where explicitly asked to run things in parallel (with a
special function; see below), in which
case it uses threads. The latter (which is still a work-in-progress) uses an
event loop and asynchronous I/O. Nothing in the design prevents a Zig programmer
from implementing their own version, however, so Zig's users retain their fine
control over how their programs execute.

Loris Cro, one of Zig's community organizers,
wrote
an explanation of the new behavior to justify the approach.
Synchronous code is not much changed,
other than using the standard library functions that have moved under
Io, he explained. Functions like the example below, which don't involve explicit
asynchronicity, will continue to work. This example creates a file, sets the
file to close at the end of the function, and then writes a buffer of data to
the file. It uses Zig's try keyword to handle errors, and
defer to ensure the file is closed. The return type, !void,
indicates that it could return an error, but doesn't return any data:

const std = @import("std");
const Io = std.Io;

fn saveFile(io: Io, data: []const u8, name: []const u8) !void {
const file = try Io.Dir.cwd().createFile(io, name, .{});
defer file.close(io);
try file.writeAll(io, data);
}

If this function is given an instance of Io.Threaded, it will create
the file, write data to it, and then close it using ordinary system calls. If it
is given an instance of Io.Evented, it will instead use

io_uring,

kqueue, or some other asynchronous backend suitable to the target operating
system. In doing so, it might pause the current execution and go work on a
different asynchronous function.
Either way, the operation is guaranteed to be complete by the time
writeAll() returns.
A library author writing a function that involves I/O doesn't need to
care about which of these things the ultimate user of the library chooses to do.

On the other hand, suppose that a program wanted to save two files. These
operations could profitably be done in parallel. If a library author wanted to
enable that, they could use the Io interface's async()
function to express that it does not matter which order the two files are saved in:

fn saveData(io: Io, data: []const u8) !void {
// Calls saveFile(io, data, "saveA.txt")
var a_future = io.async(saveFile, .{io, data, "saveA.txt"});
var b_future = io.async(saveFile, .{io, data, "saveB.txt"});

const a_result = a_future.await(io);
const b_result = b_future.await(io);

try a_result;
try b_result;

const out: Io.File = .stdout();
try out.writeAll(io, "save complete");
}

When using an Io.Threaded instance, the async() function
doesn't actually isn't actually required to do anything asynchronously [although the actual implementation may dispatch the function to a separate thread, depending on how it was configured] — it can just run the provided function
right away. So, with that version of the interface, the function first saves
file A and then file B. With an Io.Evented instance, the operations are
actually asynchronous, and the program can save both files at once.

The real advantage of this approach is that it turns asynchronous code into a
performance optimization. The first version of a program or library can write
normal straight-line code. Later, if asynchronicity proves to be useful for
performance, the author can come back and write it using asynchronous
operations. If the ultimate user of the function has not enabled asynchronous
execution, nothing changes. If they have, though, the function becomes faster
transparently — nothing about the function signature or how it interacts with
the rest of the code base changes.

One problem, however, is with programs where two parts are actually required to
execute simultaneously for correctness. For example, suppose that a program
wants to listen for connections on a port and simultaneously respond to user
input. In that scenario, it wouldn't be correct to wait for a connection and
only then ask for user input. For that use case, the Io interface
provides a separate function, asyncConcurrent()concurrent() [this function was renamed during development; concurrent() is the most recent name] that explicitly asks for
the provided function to be run in parallel. Io.Threaded uses a thread
in a thread pool to accomplish this. Io.Evented treats it exactly the
same as a normal call to async().

const socket = try openServerSocket(io);
var server = try io.concurrent(startAccepting, .{io, socket});
defer server.cancel(io) catch {};

try handleUserInput(io);

If the programmer uses async() where they should have used
concurrent(), that is a bug. Zig's new model does not (and cannot)
prevent programmers from writing incorrect code, so there are still some
subtleties to keep in mind when adapting existing Zig code to use the new
interface.

The style of code that results from this design is a bit more verbose than
languages that give asynchronous functions special syntax, but Andrew Kelley,
creator of the language, said that "it reads
like standard, idiomatic Zig code." In particular, he noted that this
approach lets the programmer use all of Zig's typical control-flow primitives,
such as try and defer; it doesn't introduce any new language
features specific to asynchronous code.

To demonstrate this,
Kelley gave an example of using the new interface to implement asynchronous DNS
resolution. The standard

getaddrinfo()
function for querying DNS information falls short because, although it makes
requests to multiple servers (for IPv4 and IPv6) in parallel, it waits for all of the queries to
complete before returning an answer. Kelley's example Zig code returns the first
successful answer, canceling the other inflight requests.

Asynchronous I/O in Zig is far from done, however. Io.Evented is still experimental, and
doesn't have implementations for all supported operating systems yet. A third
kind of Io, one that is compatible with WebAssembly, is
planned (although, as
that issue details, implementing it depends on some other new language
features). The original
pull request for Io lists 24
planned follow-up items, most of which still need work.

Still, the overall design of asynchronous code in Zig appears to be set. Zig has
not yet had its 1.0 release, because the community is still experimenting with
the correct way to implement many features. Asynchronous I/O was one of the
larger remaining priorities (along with native code generation, which was also
enabled by default for debug builds on some architectures this year). Zig seems
to be steadily working its way toward a finished design — which should decrease
the number of times Zig programmers are asked to rewrite their I/O because the
interface has changed

again.

to post comments

One and a half colors

Posted Dec 2, 2025 17:00 UTC (Tue)
by smurf (subscriber, #17840)
[Link] (7 responses)

So instead of function-marked-async-or-not you shlep an additional "io" parameter all around your call chain whenever something might or might not need to do anything async?

Doesn't seem much different from tagging everything you want to async-ize with "async" and "await" …

Also I'm interested in how zig plans to manage an event loop this way. I mean you need to save and restore the call stack somehow, and stacks may not exactly be small in a production setup.

One and a half colors

Posted Dec 2, 2025 18:53 UTC (Tue)
by daroc (editor, #160859)
[Link] (1 responses)

It's a bit different because the syntax of the functions involved doesn't change. And you can store an Io in a data structure or global variable if you want to. But I agree it's separately onerous to have another piece of context to port around everywhere.

As for managing the event loop: I believe the plan is for there to be built-in functions that can start executing a function on a user-provided stack, so the event loop can allocate separate stacks and then give them to the running functions. But Zig has also had a long-term goal to eventually be able to statically determine the needed stack size of any given function, at which point it should be possible to write comptime code that does better than that.

One and a half colors

Posted Dec 2, 2025 21:20 UTC (Tue)
by softball (subscriber, #160655)
[Link]

> piece of context to port around everywhere

Similar to Go, which also has "one and a half colors". Functions taking a context.Context as their first argument are most likely performing I/O. It's not required though: a number-crunching function performing no I/O might still check the context for cancellation every so often. Likewise, I/O without taking a context.Context is possible.

Similarly, in async languages (Rust, ...), a function marked async might end up performing no I/O at all (no await points). A function marked sync might spawn its own runtime (or grab the global one) and start spawning futures (= called async functions) on it.

Lots of grey areas.

One thing I wonder about is mixing threading for CPU-bound work and eventing for I/O-bound work. In Rust, one solution is having the application be fundamentally async (tokio, ...) and hand around a dedicated threadpool (rayon, ...). If there's enough available parallelism, both can coexist without interference and communicate via channels. Rust makes this explicit and relatively ergonomic at compile time (Send + Sync, ...). I wonder how equivalent Zig code would look like (I suppose Io would be the evented variant, and for the CPU-bound work just spawn OS threads normally).

One and a half colors

Posted Dec 2, 2025 20:09 UTC (Tue)
by quotemstr (subscriber, #45331)
[Link]

I'd actually people carry an IO object around. The ambient nature of IO makes doing things like shimming the outside world for testing annoying. Programs are better overall when you give a component an explicit pass from above capability for anything you'd like it to do.

One and a half colors

Posted Dec 2, 2025 21:18 UTC (Tue)
by excors (subscriber, #95769)
[Link] (3 responses)

Sounds like there's still two colours, but instead of the colours being "uses async IO" and "doesn't use async IO", they're now "uses IO" and "doesn't use IO". You still have the problem of propagating a colour (i.e. the presence of an `Io` parameter, either direct or carried in some other struct) all the way up the call graph, until you reach a high enough level that can decide the application's policy on concurrency.

Arguably that's a good case of colouring, because the presence of IO _should_ be part of your API: users ought to be aware of the security and performance and portability and testability implications of an API that accesses the filesystem/network, and should have some control over it. But users shouldn't have to care about serial IO vs concurrent IO - that's just an implementation detail and a performance optimisation - and in this model they don't have to care, because the API is IO-coloured either way, unlike the async/await model where migrating to concurrent IO changes your API colouring.

That's similar to how the use of dynamic memory allocation should be part of your API (and in Zig it is); it's too important to hide. And error handling (in most languages that don't use exceptions). And the general concept of dependency injection.

I suppose the main downside is that once you start making everything an explicit part of every API and avoiding implicit global state, it gets annoyingly verbose, and it's hard to read code when the important logic is obscured by boilerplate. But I think it's interesting to see Zig try a different tradeoff here.

One and a half colors

Posted Dec 2, 2025 22:23 UTC (Tue)
by khim (subscriber, #9252)
[Link]

That's much better solution that what Rust did. Of course Zig has the benefits of hindsight.
In practice there are more than two colors — except in Rust it's not obvious from the source and almost unmanageable in practice.
That's because you couldn't simply pass any random async function into any random executor… the functions that do actual work have to match the executor or else the whole things falls apart — and these functions are invisible in Rust's async fn signature.
In Zig one may simply have more than two implementations of the interface.
People are talking about “two colors” because in practice that's something that actually works, but try to mix two executors in one program in Rust… and the whole thing falls apart, you couldn't do that. It's not “2 colors” problem, but “2+ colors problem”.

One and a half colors

Posted Dec 2, 2025 23:02 UTC (Tue)
by sionescu (subscriber, #59410)
[Link] (1 responses)

Correct. It still has two colours, and still suffers from a lack of preemption, so a CPU-bound code block can still starve an async worker thread. Sucks as much as the other approaches (Rust, Go), just in a slightly different flavour.

One and a half colors

Posted Dec 3, 2025 2:55 UTC (Wed)
by Cyberax (✭ supporter ✭, #52523)
[Link]

Go can actually pre-empt computation-heavy goroutines. It's implemented using signals: https://github.com/golang/proposal/blob/master/design/245...

I see what you mean but...

Posted Dec 2, 2025 21:40 UTC (Tue)
by dcoutts (subscriber, #5387)
[Link] (1 responses)

> Languages that don't make a syntactical distinction (such as Haskell) essentially solve the problem by making everything asynchronous, which typically requires the language's runtime to bake in ideas about how programs are allowed to execute.

Yes there's an analogy in there somewhere but no. Asynchronous code and threaded code have some similarities but are different. Async code is about trying to do cooperative execution in a single thread (and often with little to no runtime support). Threaded code (with language support) typically means a runtime system with a thread scheduler, and some compiler support to implement proper thread pre-emption.

In Haskell in particular (which is what I'm familiar with) the compiler doesn't need to make everything async. It compiles to very traditional-looking low level sequential code. The only magic is the compiler inserts yield points (where it anyway has to do stack or heap checks), and yes there is a thread scheduler in the runtime system (and thread synchronisation primitives interact with the scheduler too of course).

Turning everything async is a rather different compiler transformation.

I see what you mean but...

Posted Dec 2, 2025 21:58 UTC (Tue)
by daroc (editor, #160859)
[Link]

I agree that Haskell code compiles down to code that looks more like threads than like Rust's async, for example. But that's at least partly because Haskell's design, as a language, has pervasive support for suspending computations as part of being a non-strict language. The unit is just the thunk, not the asynchronous function.

Compare a Rust async function that does some work, and then goes to work on another async function due to an .await, and then finishes its work. That is quite conceptually similar to a Haskell function that does some work, demands another thunk, and then finishes its work. They're really quite similar in that they don't usually involve the runtime, unless there's some multithreading going on or its time for a context switch. In both languages, the operation (.await or forcing a thunk) are theoretically implemented with a call instruction, but can in practice have the compiler inline parts or do them ahead of time if it can prove that they're used later. In both languages, the in-progress state of these things is partly stored in registers and mostly stored in a specific object in memory.

I accept that it's not a perfect analogy. There are serious differences between the language, and in particular the GHC runtime's "everything is a function, even data" approach is pretty different from Rust's "everything is data, even async functions" approach. But I also think that it's not a misleading comparison when the language mechanisms are solving similar problems (letting computation occur in an order that doesn't strictly match the order that a traditional strict, imperative language would demand) in a similar way (by using specialized objects in memory that a runtime helps to manage, but that can do basic interactions between objects just by calling through the appropriate function pointer).

Copyright © 2025, Eklektix, Inc.

Comments and public postings are copyrighted by their creators.
Linux is a registered trademark of Linus Torvalds

Zig’s New Plan for Asynchronous Programs

The design of asynchronous code has been a persistent challenge for many programming languages, and Zig is tackling this issue with a novel approach. As detailed in LWN.net’s subscriber-only article, Zig’s designers are introducing a new interface, “Io,” to manage asynchronous I/O operations. This design aims to solve the “function coloring problem”—the difficulty of writing code that can seamlessly utilize either synchronous or asynchronous I/O—without imposing excessive complexity on the language.

Zig’s approach centers around a generic interface, Io, which any function needing to perform an I/O operation must access. The standard library provides two built-in implementations, Io.Threaded and Io.Evented. Io.Threaded employs traditional synchronous operations, except when explicitly requested to run functions in parallel using a special function. Conversely, Io.Evented leverages asynchronous I/O mechanisms like `io_uring`, `kqueue`, or similar operating system-specific APIs, potentially pausing execution and migrating to a different asynchronous function. Critically, the core design allows a programmer to utilize either approach, without needing to modify the code itself.

One of the key aspects of this design is its flexibility. Loris Cro, a Zig community organizer, explains that existing synchronous code largely remains unchanged, with functions now utilizing the standard library through the Io interface. The example provided—writing a file, setting its close-on-end behavior, and writing data—demonstrates this. The use of Zig’s `try` and `defer` keywords remains unaffected, while the `writeAll` function demonstrates the potential for asynchronous execution through the `io` parameter. This parameter enables the calling function to control whether the operation is performed synchronously or asynchronously, depending on the chosen Io implementation.

A prime example of this flexibility is demonstrated when dealing with multiple file operations. By constructing a function using `io.async`, the programmer can express that the order of operations doesn't matter. The code effectively delegates the scheduling to the underlying implementation – allowing, for example, the execution of the two save operations in parallel. Using an instance of `Io.Threaded` utilizes a thread pool from the thread pool itself, while `Io.Evented` enables the same operations via asynchronous I/O.

The true advantage of this architecture lies in its potential to transform asynchronous I/O into a performance optimization. The initial code can be written using standard, straightforward I/O functions. Later, should asynchronous execution prove beneficial, the code can be adapted without requiring substantial changes. The flexibility afforded by the `io` parameter means that the existing code remains largely untouched.

However, there are inherent challenges. Zig's developers acknowledge that problems can arise when two operations absolutely require simultaneous execution for correctness. As illustrated by the example of listening for network connections while simultaneously prompting for user input, simply awaiting the connection's establishment before requesting user input would be incorrect. To handle such scenarios, the Io interface provides a separate function, `asyncConcurrent()`, which explicitly asks for the provided function to be run in parallel. The `Io.Threaded` implementation utilizes a thread pool to accomplish this.

Despite the design’s elegance, Zig’s model doesn't completely eliminate the possibility of writing incorrect code, particularly in cases where asynchronous execution isn’t properly managed. The potential for subtle errors remains, highlighting the importance of careful design and diligent testing. Andrew Kelley, the language’s creator, notes that the code reads like standard, idiomatic Zig code, but the design itself remains a shift. While the `io` parameter adds an extra layer of context, it’s manageable – it can be stored in a data structure or global variable if desired.

Furthermore, while the design simplifies asynchronous I/O, it’s still a work in progress. The `Io.Evented` implementation is experimental and lacks support for all operating systems. A third “WebAssembly-compatible” Io implementation is planned, dependent on additional language features. Zig’s development is ongoing, aiming to refine the asynchronous I/O interface and address remaining challenges. The ultimate goal is to decrease the frequency with which Zig programmers have to rewrite I/O code due to interface changes, creating a more stable and predictable development environment.