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 ContentWeekly EditionArchivesSearchKernelSecurityEvents calendarUnread commentsLWN FAQWrite for us
User: | Log in / Zig's new plan for asynchronous programs Welcome to LWN.net The following subscription-only content has been made available to you By Daroc AldenDecember 2, 2025 The designers of the Zig programming language have been working to find a initial design for announced (in a Zig SHOWTIME video) a new approach to asynchronous I/O that function coloring problem, and allows writing code that will execute In many languages (including Python, JavaScript, and Rust), asynchronous code Neither of those options was deemed suitable for Zig. Its designers wanted to Io. Any function that needs to perform an I/O operation will need to have access to Allocator Loris Cro, one of Zig's community organizers, const std = @import("std"); fn saveFile(io: Io, data: []const u8, name: []const u8) !void { If this function is given an instance of Io.Threaded, it will create io_uring, kqueue, or some other asynchronous backend suitable to the target operating On the other hand, suppose that a program wanted to save two files. These fn saveData(io: Io, data: []const u8) !void { const a_result = a_future.await(io); try a_result; const out: Io.File = .stdout(); When using an Io.Threaded instance, the async() function The real advantage of this approach is that it turns asynchronous code into a One problem, however, is with programs where two parts are actually required to const socket = try openServerSocket(io); try handleUserInput(io); If the programmer uses async() where they should have used The style of code that results from this design is a bit more verbose than To demonstrate this, getaddrinfo() Asynchronous I/O in Zig is far from done, however. Io.Evented is still experimental, and Still, the overall design of asynchronous code in Zig appears to be set. Zig has again. to post comments One and a half colors Posted Dec 2, 2025 17:00 UTC (Tue) 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) 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) > 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) 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) 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) That's much better solution that what Rust did. Of course Zig has the benefits of hindsight. One and a half colors Posted Dec 2, 2025 23:02 UTC (Tue) 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) 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) > 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) 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. |
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. |