Background
Blog post What Color is Your Function? (2015) by Bob Nystrom highlighted problems of async computation handling in programming language design. It has started heated discussions on Hacker News and Reddit.
Although many solutions to this problem were suggested, none of them seemed to be a silver bullet.
Zig’s new I/O
In the Zig Roadmap 2026 stream Andrew Kelley announced a new way of doing I/O, and Loris Cro wrote a Zig’s New Async I/O blog post describing it in more details.
This is the example Zig code from that post, that writes data
to two files asynchronously:
const std = @import("std");
const Io = std.Io;
fn saveData(io: Io, data: []const u8) !void {
var a_future = io.async(saveFile, .{io, data, "saveA.txt"});
defer a_future.cancel(io) catch {};
var b_future = io.async(saveFile, .{io, data, "saveB.txt"});
defer b_future.cancel(io) catch {};
// We could decide to cancel the current task
// and everything would be released correctly.
// if (true) return error.Canceled;
try a_future.await(io);
try b_future.await(io);
const out: Io.File = .stdout();
try out.writeAll(io, "save complete");
}
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);
}
Loris later claims that this approach to I/O solves function coloring… and I don’t agree.
To see why, let’s compare function signatures of red (blocking) and
blue (non-blocking) versions of saveData
with a few modifications
that make my argument a bit more clear:
// red
fn saveData(data: []const u8) !void
fn saveFile(file: std.File, data: []const u8) !void
// blue
fn saveData(io: Io, data: []const u8) !void
fn saveFile(io: Io, file: std.File, data: []const u8) !void
And compare it to semantically similar functions in Node.js:
// red
function saveData(data: Uint8Array): void
function saveFile(file: string, data: Uint8Array): void
// blue
async function saveData(data: Uint8Array): Promise<void>
async function saveFile(file: string, data: Uint8Array): Promise<void>
Difference is easy to spot:
- in Node.js, blue functions are marked with
async
and return a promise (or take callback() => void
as a parameter) - in Zig, blue functions take
std.Io
parameter!
But there is a catch: with this new I/O approach it is impossible to write to a file without std.Io
!
// impossible without io
fn saveData(data: []const u8) !void
Semantically, passing std.Io
to every function is no different from making every Node.js function async and
returning a promise.
Zig shifts function coloring from blocking/non-blocking choice to io/non-io.
Inevitable?
So does Zig solve a function coloring problem? Depends on who you ask:
Yes, because function type signature is the same
Yes, because for the caller any function is blocking
Yes, because function can be called in both blocking and non-blocking contexts
No, because functions doing I/O require parameter
std.Io
No, because I/O functions can only be called from other I/O functions
No, because function may return the result of
std.Io.async()
, which only makes sense for functions doing async:fn saveData(io: Io, data: []const u8) !std.Future { return io.async(saveFile, .{io, data, "saveA.txt"}); }
No, because
std.Io
interface has runtime-sensitive functions such asasyncConcurrent
and I/O implementation might not support it
In my opinion, function coloring is not about the syntax, not about the function’s type signature - it is about semantics and behavior of the function. And I don’t think this problem is solvable:
- There will always be functions that are sensitive to I/O runtime they are within.
- Some functions are inherently blocking, some inherently non-blocking.
- Some APIs have a way to make blocking functions behaving in non-blocking manner.
Does this mean that the programming language needs to treat them in the same way?
Ergonomics
But most complains related to function coloring are not that blocking and non-blocking functions need to be handled differently. Complains are about how inconvenient it is to work with these differences. Understanding this moves the problem from computer science to the programming language design - how to make it more convenient?
And this is where I think Zig’s new I/O design does a great job.
It unifies the way of working with both execution models elegantly.
Although passing std.Io
everywhere seems to be annoying, it seems to work well for passing std.mem.Allocator
to any
function that allocates.
It keeps intent clear and gives great flexibility to the caller.
And as with allocation, not every bit of behavior needs to be expressed in the type signature: function does not tell who owns allocated memory, developer either needs to read the docs or go through the code to find out for themselves.
Further reading
- What Color is Your Function?
- Zig overview
- What is Zig’s “Colorblind” Async/Await?
- sans-IO: The secret to effective Rust for network services
- Why Algebraic Effects?
- Tokio crate documentation
- Async IO in Python: A Complete Walkthrough
- Notes on structured concurrency
- Async-Await ≈ Generators + Promises