https://lwn.net/SubscriberLink/1046084/4c048ee008e1c70e/ LWN.net Logo LWN .net News from the source LWN * Content + Weekly Edition + Archives + Search + Kernel + Security + Events calendar + Unread comments + ------------------------------------------------------------- + LWN FAQ + Write for us [INS::INS] User: [ ] Password: [ ] [Log in] | [Subscribe] | [Register] 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 Alden December 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 do anything asynchronously -- it just runs 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() 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.asyncConcurrent(startAccepting, .{io, socket}); defer server.cancel(io) catch {}; try handleUserInput(io); If the programmer uses async() where they should have used asyncConcurrent(), 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. [Send a free link] ----------------------------------------- [Log in] to post comments One and a half colors Posted Dec 2, 2025 17:00 UTC (Tue) by smurf (subscriber, #17840) [ Link] (5 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. [Reply to this comment] 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. [Reply to this comment] 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). [Reply to this comment] 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. [Reply to this comment] One and a half colors Posted Dec 2, 2025 21:18 UTC (Tue) by excors (subscriber, #95769) [ Link] (1 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. [Reply to this comment] 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". [Reply to this comment] 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. [Reply to this comment] 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). [Reply to this comment] [INS::INS] Copyright (c) 2025, Eklektix, Inc. Comments and public postings are copyrighted by their creators. Linux is a registered trademark of Linus Torvalds