38 comments

[ 5.2 ms ] story [ 50.4 ms ] thread
This is very well written, and very exciting! I especially love the implications for WebAssembly -- WASI in userspace? Bring your own IO? Why not both!
I feel that I have to point this out once again, because the article goes so far as to state that:

> With this last improvement Zig has completely defeated function coloring.

I disagree with this. Let's look at the 5 rules referenced in the famous "What color is your function?" article referenced here.

> 1. Every function has a color

Well, you don't have async/sync/red/blue anymore, but you now have IO and non-IO functions.

> 2. The way you call a function depends on its color.

Now, technically this seems to be solved, but you still need to provide IO as a parameter. Non-IO functions don't need/take it.

It looks like a regular function call, but there's no real difference.

> 3. You can only call a red function from within another red function

This still applies. You can only call IO functions from within other IO functions.

Technically you could pass in a new executor, but is that really what you want? Not to mention that you can also do this in languages that don't claim to solve the coloring problem.

> 4. Red functions are more painful to call

I think the spirit still applies here.

> 5. Some core library functions are red

This one is really about some things being only possible to implement in the language and/or stdlib. I don't think this applies to Zig, but it doesn't apply to Rust either for instance.

Now, I think these rules need some tweaking, but the general problem behind function coloring is that of context. Your function needs some context (an async executor, auth information, an allocator, ...). In order to call such a function you also need to provide the context. Zig hasn't really solved this.

That being said, I don't think Zig's implementation here is bad. If anything, it does a great job at abstracting the usage from the implementation. This is something Rust fails at spectacularly.

However, the coloring problem hasn't really been defeated.

Does Zig have closures? If yes, than at least in that case the IO pointer can be a bound parameter. For languages with the async keyword function coloring also applied to closures.
I have a much longer rant elsethread, but the tl;dr; is:

In some languages red can call blue, but blue cannot call red (JS). In some other languages blue can call red, but the resulting combined function is blue (traditional async with optional blocking). Finally some languages allow blue to call red and having the resulting combined function to be red (lua, scheme, go, and I believe Zig). As color is no longer a n unabstractable restriction in these languages, it no different than other kind of typing.

I'm generally a fan of Zig, but it's a little sad seeing them go all in on green threads (aka fibers, aka stackful coroutines). Rust got rid of their Runtime trait (the rough equivalent of Zig's Io) before 1.0 because it performed badly. Languages and OS's have had to learn this lesson the hard way over and over again:

https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p13...

> While fibers may have looked like an attractive approach to write scalable concurrent code in the 90s, the experience of using fibers, the advances in operating systems, hardware and compiler technology (stackless coroutines), made them no longer a recommended facility.

If they go through with this, Zig will probably top out at "only as fast as Go", instead of being a true performance competitor. I at least hope the old std.fs sticks around for cases where performance matters.

Oh man. I think the biggest mistake Rust has ever made is their async model. It’s been nothing short of a disaster. Zig supporting green threads and other options is spectacularly exciting. Thus far no one has “solved” async. So very exciting to see what Zig can come up with.
Seeing a systems language like Zig require runtime polymorphism for something as common as standard IO operations seems off to me -- why force that runtime overhead on everyone when the concrete IO implementation could be known statically in almost all practical cases?
I don't know Zig, but wouldn't such a change be a major breaking change where all prior Zig code doing Io wouldn't work anymore if upgraded?
> io.async expresses asynchronicity (the possibility for operations to happen out of order and still be correct) and it does not request concurrency, which in this case is necessary for the code to work correctly.

This is the key point for me. Regardless of whether you’re under an async event loop, you can specify that the order of your io calls does not imply sequencing. Brilliant. Separate what async means from what the io calls do.

As the author of a semi-famous post about how Zig has function colors [1], I decided to read up on this.

I see that blocking I/O is an option:

> The most basic implementation of `Io` is one that maps to blocking I/O operations.

So far, so good, but blocking I/O is not async.

There is a thread pool that uses blocking I/O. Still good so far, but blocking I/O is still not async.

Then there's green threads:

> This implementation uses `io_uring` on Linux and similar APIs on other OSs for performing I/O combined with a thread pool. The key difference is that in this implementation OS threads will juggle multiple async tasks in the form of green threads.

Okay, they went the Go route on this one. Still (sort of) not async, but there is an important limitation:

> This implementation requires having the ability to perform stack swapping on the target platform, meaning that it will not support WASM, for example.

But still no function colors, right?

Unfortunately not:

> This implementation [stackless coroutines] won’t be available immediately like the previous ones because it depends on reintroducing a special function calling convention and rewriting function bodies into state machines that don’t require an explicit stack to run.

(Emphasis added.)

And the function colors appear again.

Now, to be fair, since there are multiple implementation options, you can avoid function colors, especially since `Io` is a value. But those options are either:

* Use blocking I/O.

* Use threads with blocking I/O.

* Use green threads, which Rust removed [2] for good reasons [3]. It only works in Go because of the garbage collector.

In short, the real options are:

* Block (not async).

* Use green threads (with their problems).

* Function colors.

It doesn't appear that the function colors problem has been defeated. Also, it appears to me that the Zig team decided to have every concurrency technique in the hope that it would appear innovative.

[1]: https://gavinhoward.com/2022/04/i-believe-zig-has-function-c...

[2]: https://github.com/aturon/rfcs/blob/remove-runtime/active/00...

[3]: https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p13...

Although I'm not wild about the new `io` parameter popping up everywhere, I love the fact that it allows multiple implementations (thread based, fiber based, etc.) and avoids forcing the user to know and/or care about the implementation, much like the Allocator interface.

Overall, I think it's a win. Especially if there is a stdlib implementation that is a no overhead, bogstock, synchronous, blocking io implementation. It follows the "don't pay for things you don't use" attitude of the rest of zig.

So is that zig becoming a type AND effect system?
I wish Zig had not done async/await. CPS (like you have in Go) is way, way better, and is lower level, making it possible to do you own "async/await" if you really want to.
Ok, they are implementing an effect system. Is there any acknowledgement that they are going down an established path?
When I watched the release notes they didn't sound like it was some ground breaking new pattern it's just a new approach that fits best with zig.
Is this in effect introducing algebraic effects by concept? E.g. the io passed in is an effect handler, and it is the effect handler's choice whether to perform stack switching (or other means of non-blocking waiting) to enable asynchronicity?
Damn that’s some ugly async syntax.
Interesting. This is a bit reminiscent of how OCaml handles async these days.
Note that this same concept is "sans io" and was previously discussed for it's use in Rust:

https://www.firezone.dev/blog/sans-io

https://sans-io.readthedocs.io/

https://news.ycombinator.com/item?id=40872020

If the functions are still calling I/O methods directly rather than the I/O being externally driven, I don't think that qualifies as sans-io, based on my previous exposure / based on your second link:

> For byte-stream based protocols, the protocol implementation can use a single input buffer and a single output buffer. For input (that is, receiving data from the network), the calling code is responsible for delivering code to the implementation via a single input (often via a method called receive_bytes, or something similar). The implementation will then append these bytes to its internal byte buffer. At this point, it can choose to either eagerly process those bytes, or do so lazily at the behest of the calling code.

> When it comes to generating output, a byte-stream based protocol has two options. It can either write its bytes to an internal buffer and provide an API for extracting bytes from that buffer, as done by hyper-h2, or it can return bytes directly when the calling code triggers events (more on this later), as done by h11. The distinction between these two choices is not enormously important, as one can easily be transformed into the other, but using an internal byte buffer is recommended if it is possible that the act of receiving input bytes can cause output bytes to be produced: that is, if the protocol implementation sometimes automatically responds to the peer without user input.

Love the no function coloring solution! I am so looking forward to Zig 1.0. Finally, a system programming language that I can actually read and understand without putting in heavy labor. Hell, I could fully follow this blog post, without actually knowing anything much about Zig. Broke my head on async Rust several times before throwing in the towel.
I think this design is a regression from the previous design, in which you could use compile time introspection to check whether things are actually async (calling convention) or not.

Additionally, I don't necessarily want to delegate the management of the memory backing the futures to an Io, or pass around a blob of syscalls and an associated runtime, which accesses everything via a vtable. I would prefer to have these things be compile time generic only.

Does this mean that as a side effect, it'll now be possible to enforce functions are pure/deterministic in Zig by not passing in an Io?
I like the IO interface simply for the fact that it would allow me to create language level vfs
Speaking of colors… I like the color scheme of the code snippets, is it a standard scheme available in VSCode?
This is a good time to implement "context", a way to pass down the call stack parameters instead of having to add a io argument to every function
(comment deleted)
So you have to do:

    io.async(saveFile, .{io, data, "saveA.txt"}).await(io);
That is 3 references to `io` in a single call.

Considering there is very little use case for mix and matching different Ios, any chance of having some kind of default / context IO to avoid all these ?

(comment deleted)