What Is Zig's “Colorblind” Async/Await?
kristoff.it
kristoff.it
I brought it up on HN once, and apparently this was discussed as an option for Rust, but for whatever reason, people didn't like it. To be fair, Rust has certain design requirements that might not be compatible with such a system.
Static function coloring based in asyncronicity is a feature not a bug.
Making it "dynamic" is like saying: "Yeah we've decided that if you just treat an Option as a value, we will let you do it, and we'll also treat every Option boxing inside your map as a noop."
> The function is no longer async, and in fact both keywords basically become no-ops
That sounds crazy to me, if I use a keyword I expect it to behave the same everywhere I use it. I hope I misunderstood, and that setting come kind of global const can't "disable" async for one library, but still have it enabled when used somewhere else. I would have to keep track of all these various consts to know my app is/isn't actually doing async tasks, even though I've explicitly used the keyword.
Similar questions might arise around cancellation and timeouts, which are different to perform in an async/evented environment than a synchronous one.
In most other worlds the problem is "solved" by declaring a subsystem of the application as async. And only that subsystem is running one or more eventloops. That obviously introduces a different set of issues, but it isolates parts of a program against others.
I'm really curious on how Zigs approach will work out in any bigger applications where libraries from various parties would get used.
In that case you would add in such library a line like this:
if (!std.io.is_async) @compileError("LIB doesn't support blocking mode");
> I'm really curious on how Zigs approach will work out in any bigger applications where libraries from various parties would get used.That's an open question. In general Zig is more like C than C++ also when it comes to expectations from libraries. We'll see where this will get us.
That's certainly an approach to make sure libraries run in the correct environment. But it still doesn't seem to enable use-cases where you run an eventloop on some threads and blocking IO (or just blocking on the eventloop) on other threads, which are often found in bigger programs that are made up of a lot of heterogenous libraries.
One could say "you might not need that - just run everything async". But since we are talking about a system programming language where libraries might be exported and used in different projects I'm not sure if that lack of flexbility won't hurt long term.
Zig doesn't take shortcuts when it comes to give people control over the machine so what you describe is expected to be possible. The async part of the language is still being worked on (Zig is at v0.6.0 as I write) and the top level API will be reworked if it turns out to be problematic (that's the superpower of being at a version < 1.0).
----
¹: https://ziglang.org/documentation/master/#Async-Functions
The top level constant interacts with the standard library to instantiate an event loop and add non-blocking flags to file descriptors etc.
Try the code out, use strace to see the syscalls being made and you will confirm that my article is accurate.
https://youtu.be/zeLToGnjIUM?t=546 (9:06 to 15:24)
So blocking still executes asynchronously (perhaps spawning a thread or something, so the program can continue on), but setting eventing will use an event-based mechanism?
No as far as I understand blocking just blocks. Which is the problematic part.
> The function is no longer async, and in fact both keywords basically become no-ops, but the point is that you can express concurrency even if you’re not able to take advantage of it.
(Emphasis done by me)
For many cases this is fine as "async" could just have completed one the first poll which we could just have done before the first yield.
But the problem is if you try to do something like calling two functions async which needs synchronization between them to complete. Something you generally should really avoid. But sometimes this is the only sane way to do certain things in complex systems. (But then it might make sense to simplify such a system or split it apart).
So I would say for many use-cases including all normal web-servers Zig's solutions is not just good but preferable. But to do some things it's bad.
Through it's just a question to add some escape hatches for the view cases where it matters or so.
Anyway spawning a thread on every `async` call if `.blocking` is used would be really bad.
Like, that variable doesn't seem to have any impact on whether or not Zig itself has a concept of an event loop or of asynchronous functions; both still continue to exist, even if the libraries happen to be configured to perform blocking operations. A function is still "async" (albeit pointlessly so) even if it doesn't yield.
No that's not correct: https://godbolt.org/z/5AMxy-
Given a modified version of that example¹, with everything being apples-to-apples (identical input code minus the presence of async/await keywords, and sticking to a debug build instead of --release-fast so as to avoid Zig and/or LLVM optimizing away the semantic differences), there are subtle differences in the interaction between the generated 'add' and 'add2' (calling convention differences maybe? I strongly suspect you'd know more about what's going on with rax and rcx there...). These differences continue to be visible with --release-safe (though --release-fast and --release-small both optimize away those differences, which does indeed seem like a reasonable thing to do).
That is: even in that example, the fact that add2() doesn't yield doesn't change the fact that Zig treats it differently (namely, I would guess, by assuming that it has the potential to yield and thus doing the necessary bookkeeping to handle that) depending on whether or not it's invoked as an async function. If async/await were indeed "no-ops" as the article asserts, then shouldn't the assembly outputs here be identical in debug and release-safe?
The LLVM IR output for --release-safe is telling, too, since it's apparent that Zig's emitting IR specifically around an async frame (at least that's my impression from seeing all those getelementptrs on a @Frame(add2) -- which, come to think of it, do correspond to the extra rax/rcx manipulations in the async version). --release-fast even shows a difference on the IR level, though it's admittedly pretty inconsequential-looking (just an extra @llvm.debug.value call in the non-async version).
EDIT: as another interesting note, these differences persist even with --single-threaded, which contradicts the documentation on Single Threaded Builds (which states that "The overhead of Async Functions becomes equivalent to function call overhead." -- I mean, I guess that's true with --release-fast or --release-small, but --single-threaded doesn't seem to have an effect there).
----
You have a misleading variable name there:
var frame = add2(a, b);
return frame;
It's not a "frame", it's just the i32 result of adding 2 numbers. This should be changed to: return add2(a, b);
Or if you wanted to be very explicit: const result: i32 = add2(a, b);
return result;
The only thing I wanted to point out in the grandparent is that a function is async if and only if it yields. You can see from your godbolt example that `add2` is the same machine code in both code examples, because it does not yield.Right, but ain't that the point: that Zig functions themselves are neither sync nor async (they're "colorblind"), but rather it's just how they're called? I mean, I guess one way to "color" the function would be to have it suspend or do something with @frame() (more on that below...), but that (from what I'm seeing) only forces functions to be async, and doesn't seem to imply a function without that to not (potentially) be async.
That is: the fact that the calling code is what's different is exactly my point. Nothing the function itself is doing is (I'd argue) what dictates whether or not it's "sync" or "async"; it's purely whether or not the caller is invoking it synchronously or asynchronously. Hence: a function can still be "async" even if it never yields.
I think we just have different ideas of what "async" means (i.e. whether it's a property of the caller or callee); I'm arguing the former, for the same reason that I'd argue a function (or more precisely its invocation) to be "comptime" if it's executed within a comptime {...} block, regardless of whether or not the function itself is aware of this.
Even excluding this particular angle, though (i.e. going with the convention of asyncness being callee-dependent), the docs have a couple¹ examples² of situations where an "async" function in Zig seemingly won't yield (due to a suspend that immediately resumes itself or due to simply invoking @frame without doing anything with it, respectively), and indeed both cases do appear to produce code¹ consistent with said cases not yielding (I certainly ain't resuming/awaiting anything in add(), and yet the return value is 3 as expected in either case, as opposed to 0⁴ if there's an actual yield before the arithmetic happens).
----
¹: https://ziglang.org/documentation/master/#Resuming-from-Susp...
²: https://ziglang.org/documentation/master/#frame
³: https://godbolt.org/z/kby4vB
⁴: Tangent: I'd expect returning or otherwise trying to read an undefined value would produce a panic, yeah? Yet, in both debug and release-safe returning "result" from main() while it's set to "undefined" results in a 0 return value. Ain't sure if this is a bug or expected behavior.
But will you get an error at compile time, or will the system just deadlock?
This might be something that just needs to be handled socially, by convention, much like you're not supposed to use too much unsafe code in Rust, and you should make sure that public API's are safe.
When a Goroutine would block, it's parked by the scheduler, thus allowing an other Goroutine to execute. And it's not only about I/O: This works for any syscall, or even for CPU-bound computations.
There is no clever trick from the compiler to allow this: There is just a hook to the scheduler in low-level I/O code as well as before and after syscalls.