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!
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.