By darroch alden
2 December 2025
The designers of the Zig programming language have been working on finding 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 allow writing code that will execute correctly using 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, causing many headaches for library authors. Languages that do not make syntactic distinctions (such as Haskell) solve the problem by essentially making everything asynchronous, which typically requires the language runtime to know how the program is allowed to execute.
Neither of these options were deemed suitable for Zig. Its designers wanted to find an approach that didn’t add too much complexity to the language, that still allowed good control over asynchronous operations, and that actually made writing high-performance event-driven I/O relatively painless. The new approach solves this by hiding asynchronous operations behind a new generic interface,
io,
No AI slopes, everything is solid: Subscribe to LWN today
LWN has always emphasized quality over quantity; We need your help to continue publishing in-depth, reader-focused articles about Linux and the free-software community. Please subscribe today to support our work and keep LWN on the air; We are offering a one month free trial membership to get you started.
Any function will need access to an instance of the interface to perform an I/O operation. Typically, this is provided by passing the instance as a parameter to the function, similar to Zig.
divisor
Interface for memory allocation. The standard library will include two built-in implementations of the interface: Io.threaded And io.eventThe former uses synchronous operations, except where explicitly told to run things in parallel (with a particular 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, However, nothing in the design prevents Zig programmers from implementing their own version, so Zig users retain a good deal of control over how their programs execute,
Loris Crowe, one of Zig’s community organizers, wrote an explanation of the new behavior to justify the approach. Not much is changed to the synchronous code, other than using standard library functions.
iohe explained. Functions like the example below, which do not involve explicit asynchrony, will continue to work. This example creates a file, sets the file to be closed at the end of the function, and then writes a buffer of data to the file. it uses jigs Effort Keywords for handling errors, and
defer To make sure the file is closed. return type, !voidindicates that it may return an error, but does not 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 an instance of this function is given Io.threadedThis will create the file, write data to it, and then close it using normal system calls. If this example is given io.eventInstead it will use io_ure, kqueue, or some other asynchronous backend suitable for the target operating system. In doing so, it can pause the current execution and work on a different asynchronous function. Either way, the operation is guaranteed to be completed on time.
write all() Return. A library author who is writing a function that involves I/O does not need to care which of these tasks the end user of the library chooses to perform.
On the other hand, suppose a program wants to save two files. These operations can advantageously be performed in parallel. If a library author wants to enable this, they can use io of interface async()
Function to express that it does not matter in which order the two files are saved:
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 one Io.threaded Example, async() The function doesn’t actually do anything asynchronously – it simply runs the given function immediately. So, with that version of the interface, the function saves first file A and then file B. io.event For example, operations are truly asynchronous, and the program can save both files simultaneously.
The real benefit 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 asynchrony proves useful for performance, the writer can come back and write it using asynchronous operations. If the end user of the function has not enabled asynchronous execution, nothing changes. However, if they have, the function becomes transparently faster – nothing about how the function signature or how it interacts with the rest of the code base changes.
However, a problem exists with programs where correctness actually requires two parts to be executed simultaneously. For example, suppose a program wants to listen for connections on a port and simultaneously respond to user input. In that scenario, it would not be correct to wait for the connection and only then ask for user input. For that use case, io The interface provides a different function, asyncConcurrent() Which explicitly says to run the given function in parallel. Io.threaded To accomplish this, threads in the thread pool are used. io.event Treats it exactly the same as a normal call 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 been used
asyncConcurrent()He is a bug. Zig’s new model cannot (and does not) 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 resulting from this design is slightly more verbose than languages that give special syntax to asynchronous functions, but the language’s creator Andrew Kelly stated that “It reads like standard, idiomatic zig code.
“In particular, he notes that this approach lets the programmer use all of Zig’s specific control-flow primitives, such as Effort And deferIt does not introduce any new language features specific to asynchronous code.
To demonstrate this, Kelly gave an example of using the new interface to implement asynchronous DNS resolution. Standard
getaddrinfo()
The function for querying DNS information becomes trivial, because it makes requests to multiple servers (for IPv4 and IPv6) in parallel, waiting for all queries to complete before it responds. Kelly’s example zig code returns the first successful reply, canceling out other inflight requests.
However, asynchronous I/O in Zig is not yet complete. io.event Still experimental, and not yet implemented for all supported operating systems. a third kind ioOne that is compatible with WebAssembly is planned (although, as that issue details, implementing it depends on some other new language features). Original pull request for io It lists 24 planned follow-up items, most of which still need work.
Nevertheless, the overall design of asynchronous code in Zig appears to be set. Zig does not have a 1.0 release yet, as the community is still experimenting with the right way to implement many features. Asynchronous I/O was one of the big remaining priorities (along with native code generation, which was also enabled by default for debug builds on some architectures this year). It seems that Zig is constantly working towards a finished design – which should reduce the number of times Zig programmers are asked to rewrite their I/O as the interface changes again.
<a href