30 comments

[ 5.0 ms ] story [ 53.2 ms ] thread
I like the look of this direction. I am not a fan of the `async` keyword that has become so popular in some languages that then pollutes the codebase.
I’m excited to see how this turns out. I work with Go every day and I think Io corrects a lot of its mistakes. One thing I am curious about is whether there is any plan for channels in Zig. In Go I often wish IO had been implemented via channels. It’s weird that there’s a select keyword in the language, but you can’t use it on sockets.
I'm excited to see where this goes. I recently did some io_uring work in zig and it was a pain to get right.

Although, it does seem like dependency injection is becoming a popular trend in zig, first with Allocator and now with Io. I wonder if a dependency injection framework within the std could reduce the amount of boilerplate all of our functions will now require. Every struct or bare fn now needs (2) fields/parameters by default.

> Languages that don't make a syntactical distinction (such as Haskell) essentially solve the problem by making everything asynchronous

What the heck did I just read. I can only guess they confused Haskell for OCaml or something; the former is notorious for requiring that all I/O is represented as values of some type encoding the full I/O computation. There's still coloring since you can't hide it, only promote it to a more general colour.

Plus, isn't Go the go-to example of this model nowadays?

Love it, async code is a major pita in most languages.
I think this design is very reasonable. However, I find Zig's explanation of it pretty confusing: they've taken pains to emphasize that it solves the function coloring problem, which it doesn't: it pushes I/O into an effect type, which essentially behaves as a token that callers need to retain. This is a form of coloring, albeit one that's much more ergonomic.

(To my understanding this is pretty similar to how Go solves asynchronicity, expect that in Go's case the "token" is managed by the runtime.)

AFACT, the only practically critical issue of colored function is the duplication of code b/w sync and async code paths. Zig avoids this with dependency injection, and that’s enough for practical usages (which basically means “ergonomic”). Other points raised by the original article (like calling async function is more difficult) are pretty much unavoidable for the sake of precise control.
This seems a lot like what the scala libraries Zio or Kyo are doing for concurrency, just without the functional effect part.
I like Zig and I like their approach in this case.

From the article:

    std.Io.Threaded - based on a thread pool.

      -fno-single-threaded - supports concurrency and cancellation.
      -fsingle-threaded - does not support concurrency or cancellation.

    std.Io.Evented - work-in-progress [...]
Should `std.Io.Threaded` not be split into `std.Io.Threaded` and `std.Io.Sequential` instead? Single threaded is another word for "not threaded", or am I wrong here?
I find this example quite interesting:

       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);
In Rust or Python, if you make a coroutine (by calling an async function, for example), then that coroutine will not generally be guaranteed to make progress unless someone is waiting for it (i.e. polling it as needed). In contrast, if you stick the coroutine in a task, the task gets scheduled by the runtime and makes progress when the runtime is able to schedule it. But creating a task is an explicit operation and can, if the programmer wants, be done in a structured way (often called “structured concurrency”) where tasks are never created outside of some scope that contains them.

From this example, if the example allows the thing that is “io.async”ed to progress all by self, then I guess it’s creating a task that lives until it finishes or is cancelled by getting destroyed.

This is certainly a valid design, but it’s not the direction that other languages seem to be choosing.

This is a bad explanation because it doesn't explain how the concurrency actually works. Is it based on stacks? Is there a heavy runtime? Is it stackless and everything is compiled twice?

IMO every low level language's async thing is terrible and half-baked, and I hate that this sort of rushed job is now considered de rigueur.

(IMO We need a language that makes the call stack just another explicit data structure, like assembly and has linearity, "existential lifetimes", locations that change type over the control flow, to approach the question. No language is very close.)

Overall this article is accurate and well-researched. Thanks to Daroc Alden for due diligence. Here are a couple of minor corrections:

> When using an Io.Threaded instance, the async() function doesn't actually do anything asynchronously — it just runs the provided function right away.

While this is a legal implementation strategy, this is not what std.Io.Threaded does. By default, it will use a configurably sized thread pool to dispatch async tasks. It can, however, be statically initialized with init_single_threaded in which case it does have the behavior described in the article.

The only other issue I spotted is:

> For that use case, the Io interface provides a separate function, asyncConcurrent() that explicitly asks for the provided function to be run in parallel.

There was a brief moment where we had asyncConcurrent() but it has since been renamed more simply to concurrent().

> > When using an Io.Threaded instance, the async() function doesn't actually do anything asynchronously — it just runs the provided function right away.

> [...]

Well, yeah, but even if you spin up a thread to run "the provided function right away" it still will only be for some value of "right away" that is not instantaneous. Creating a thread and getting it up and running is often an asynchronous operation -- it doesn't have to be, in that the OS can always simply transfer the caller's time quantum, on-CPU state, and priority to the new thread, taking the caller off the CPU if need be. APIs like POSIX just do not make that part of their semantics. Even if they did then the caller would be waiting to get back on CPU, so thread creation is fundamentally an async operation.

One thing the old Zig async/await system theoretically allowed me to do, which I'm not certain how to accomplish with this new io system without manually implementing it myself, is suspend/resume. Where you could suspend the frame of a function and resume it later. I've held off on taking a stab at OS dev in Zig because I was really, really hoping I could take advantage of that neat feature: configure a device or submit a command to a queue, suspend the function that submitted the command, and resume it when an interrupt from the device is received. That was my idea, anyway. Idk if that would play out well in practice, but it was an interesting idea I wanted to try.
> suspend/resume

special @asyncSuspend and @asyncResume builtins, they will be the low level detail you can build an evented io with.

new Io is an abstraction over the higher level details that are common between sync, threaded, and evented, so you shouldn't expect the suspension mechanism to be in it.

Pro tip: use postfix keyword notation.

Eg.

doSomethingAsync().defer

This removes stupid parentheses because of precedence rules.

Biggest issue with async/await in other languages.

It look like promising idea, though I'm a bit spectical that they can actually make it work with other executors like for example stackless coroutines transparently and it probably won't work with code that uses ffi anyway.
This design seems very similar to async in scala except that in scala the execution context is an implicit parameter rather than an explicit parameter. I did not find this api to be significantly better for many use cases than writing threads and communicating over a concurrent queue. There were significant downsides as well because the program behavior was highly dependent on the execution context. It led to spooky action at a distance problems where unrelated tasks could interfere with each and management of the execution context was a pain. My sense though is that the zig team has little experience with scala and thus do not realize the extent to which this is not a novel approach, nor is it a panacea.
Is there any way to implement structured concurrency on top of the std.Io primitive?
Passing io into things over and over seems annoying. Like, you can use io to get a File instance, then you need to pass io into its methods to read/write it? When would you ever make a File with one io implementation and want to manipulate it with another?
Explicit allocators and explicit io are sweet code smells for systems languages.

Really think Zig is right about this, excited to use it and feel it out.

I think that Java virtual threads solve this problem in a much better way than most other languages. I'm not sure that it is possible in a language as low level as Zig however.
I think the new async IO is great in simple examples like the one shown in the article. But I’m much less sure how well it will work for more complex I/O like you need in servers. I filed an issue about it here: https://github.com/ziglang/zig/issues/26056
jm2c, never had an issue with coloured functions, as long as they are tracked at the type level and you know what you're getting.

Yes, eventually you're gonna lift sync to async code, and that works fine as it is generally also the runtime model (asynchronous, event-based).

If I understand this correctly, in this example

    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);
    }
the phrase “Either way, the operation is guaranteed to be complete by the time writeAll() returns” is too weak. Given that the function can, over time, be called with different implementations of IO and users can implement IO themselves, I think the only way this can work is that the operation is guaranteed to be complete when the defer starts (if not, what part of the code makes sure the createFile must have completed when writeAll starts? (The IO instance could know, but it would either have to allow for only one ‘in flight’ call or have to keep track of in-progress calls and know of dependency between creating a file and writing to it)

But then, how is this really different from a blocking call?

Also, if that’s the case, why is that interface called IO? It looks more like a “do this in a different context” thing than specific to I/O to me (https://ziglang.org/documentation/master/std/#std.Io seems to confirm that. It doesn’t mention I/O at all)