Live data from Hacker News

Zig's new plan for asynchronous programs

lwn.net

101–110 of 274 posts

Re: Zig's new plan for asynchronous programs

#101
post #83

Earlier quoted context omitted.

Here's a problem with that: Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks. https://play.rust-lang.org/?version=stable&mode=debug&editio...

just pass around handles like you do in zig, alright? also: spawn_blocking for blocking code

But that's the thing, idiomatic Rust sync code almost never passes around handles, even when they need to do I/O.

You might be different, and you might start doing that in your code, but almost none of either std or 3rd party libraries will cooperate with you.

The difference with Zig is not in its capabilities, but rather in how the ecosystem around its stdlib is built.

The equivalent in Rust would be if almost all I/O functions in std would be async; granted that would be far too expensive and disruptive given how async works.

Re: Zig's new plan for asynchronous programs

#102

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 sol…

The function coloring problem actually comes up when you implement the async part using stackless coroutines (e.g. in Rust) or callbacks (e.g. in Javascript). Zig's new I/O does neither of those for now, so hence why it doesn't suffer from it, but at the same time it didn't "solve" the problem, it just sidestepped it by providing an implementation that has similar features but not exactly the same tradeoffs.

It's sans-io at the language level, I like the concept.

So I did a bit of research into how this works in Zig under the hood, in terms of compilation.

First things first, Zig does compile async fns to a state machine: https://github.com/ziglang/zig/issues/23446

The compiler decides at compile time which color to compile the function as (potentially both). That's a neat idea, but... https://github.com/ziglang/zig/issues/23367

> It would be checked illegal behavior to make an indirect call through a pointer to a restricted function type when the value of that pointer is not in the set of possible callees that were analyzed during compilation.

That's... a pretty nasty trade-off. Object safety in Rust is really annoying for async, and this smells a lot like it. The main difference is that it's vaguely late-bound in a magical way; you might get an unexpected runtime error and - even worse - potentially not have the tools to force the compiler to add a fn to the set of callees.

I still think sans-io at the language level might be the future, but this isn't a complete solution. Maybe we should be simply compiling all fns to state machines (with the Rust polling implementation detail, a sans-io interface could be used to make such functions trivially sync - just do the syscall and return a completed future).

Re: Zig's new plan for asynchronous programs

#103
post #73

Earlier quoted context omitted.

In Zig's case you pass the argument whether or not it's asynchronous, though. The caller controls the behavior, not the function being called.

The coloring is not the concrete argument (Io implementation) that is passed, but whether the function has an Io parameter in the first place. Whether the implementation of a function performs IO is in principle an implementation detail that can change in the future. A function that doesn't take an Io argument but wants to call another function that requires an Io argument can't. So you end up adding Io parameters ju…

> A function that doesn't take an Io argument but wants to call another function that requires an Io argument can't.

Why? Can’t you just create an instance of an Io of whatever flavor you prefer and use that? Or keep one around for use repeatedly?

The whole “hide a global event loop behind language syntax” is an example of a leaky abstraction which is also restrictive. The approach here is explicit and doesn’t bind functions to hidden global state.

Re: Zig's new plan for asynchronous programs

#104
post #92

Earlier quoted context omitted.

The function coloring problem actually comes up when you implement the async part using stackless coroutines (e.g. in Rust) or callbacks (e.g. in Javascript). Zig's new I/O does neither of those for now, so hence why it doesn't suffer from it, but at the same time it didn't "solve" the problem, it just sidestepped it by providing an implementation that has similar features but not exactly the same tradeoffs.

How are the tradeoffs meaningfully different? Imagine that, instead of passing an `Io` object around, you just had to add an `async` keyword to the function, and that was simply syntactic sugar for an implied `Io` argument, and you could use an `await` keyword as syntactic sugar to pass whatever `Io` object the caller has to the callee. I don't see how that's not the exact same situation.

Maybe I have this wrong, but I believe the difference is that you can create an Io instance in a function that has none

Re: Zig's new plan for asynchronous programs

#105
post #4

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.

> One thing I am curious about is whether there is any plan for channels in Zig.

The Zig std.Io equivalent of Golang channels is std.Io.Queue[0]. You can do the equivalent of:

    type T interface{}

    fooChan := make(chan T)
    barChan := make(chan T)

    select {
    case foo := 
in Zig like:

    const T = void;

    var foo_queue: std.Io.Queue(T) = undefined;
    var bar_queue: std.Io.Queue(T) = undefined;

    var get_foo = io.async(Io.Queue(T).getOne, .{ &foo_queue, io });
    defer get_foo.cancel(io) catch {};

    var get_bar = io.async(Io.Queue(T).getOne, .{ &bar_queue, io });
    defer get_bar.cancel(io) catch {};

    switch (try io.select(.{
        .foo = &get_foo,
        .bar = &get_bar,
    })) {
        .foo => |foo| {
            // handle foo
        },
        .bar => |bar| {
            // handle bar
        },
    }
Obviously not quite as ergonomic, but the trade off of being able to use any IO runtime, and to do this style of concurrency without a runtime garbage collector is really interesting.

[0] https://ziglang.org/documentation/master/std/#std.Io.Queue.

Re: Zig's new plan for asynchronous programs

#106
post #73

Earlier quoted context omitted.

In Zig's case you pass the argument whether or not it's asynchronous, though. The caller controls the behavior, not the function being called.

The coloring is not the concrete argument (Io implementation) that is passed, but whether the function has an Io parameter in the first place. Whether the implementation of a function performs IO is in principle an implementation detail that can change in the future. A function that doesn't take an Io argument but wants to call another function that requires an Io argument can't. So you end up adding Io parameters ju…

> Whether the implementation of a function performs IO is in principle an implementation detail that can change in the future.

I think that's where your perspective differs from Zig developers.

Performing IO, in my opinion, is categorically not an implementation detail. In the same way that heap allocation is not an implementation detail in idiomatic Zig.

I don't want to find out my math library is caching results on disk, or allocating megabytes to memoize. I want to know what functions I can use in a freestanding environment, or somewhere resource constrained.

Re: Zig's new plan for asynchronous programs

#107
post #4

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.

Have you tried Odin? Its a great language thats also a “better C” but takes more Go inspiration than Zig.

Second vote for Odin but with a small caveat.

Odin doesn't (and won't ever according to its creator) implement specific concurrency strategies. No async, coroutines, channels, fibers, etc... The creator sees concurrency strategy (as well as memory management) as something that's higher level than what he wants the language to be.

Which is fine by me, but I know lots of people are looking for "killer" features.

Re: Zig's new plan for asynchronous programs

#108

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 th…

Hey Andrew, question for you about something the article litely touches on but doesn't really discuss further:

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

What class of bug occurs if the wrong function is called? Is it "UB" depending on the IO model provided, a logic issue, or something else?

Re: Zig's new plan for asynchronous programs

#109
post #96
post #72

Earlier quoted context omitted.

> I don't know enough Zig to know whether you can write a bit of code that says "either pull from this queue or that queue when they are ready"; if so, then yes they are an adequate replacement, if not, no they are not. Thanks for giving me a reason to peek into how Zig does things now. Zig has a generic select function[1] that works with futures. As is common, Blub's language feature is Zig's comptime function. Then…

Getting a simple future from multiple queues and then waiting for the first one is not a match for Go channel semantics. If you do a select on three channels, you will receive a result from one of them, but you don't get any future claim on the other two channels. Other goroutines could pick them up. And if another goroutine does get something from those channels, that is a guaranteed one-time communication and the o…

> channels aren't futures and futures aren't channels.

In my mind a queue.getOne ~= a I really do appreciate you being strict about the semantics. Tbh the biggest thing I feel fuzzy on in all this is how go/zig actually go about finding the first completed future in a select, but other than that am I missing something?

https://ziglang.org/documentation/master/std/#std.Io.Queue.g...

Re: Zig's new plan for asynchronous programs

#110
post #18
post #6

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/param…

> Every struct or bare fn now needs (2) fields/parameters by default. Storing interfaces a field in structs is becoming a bit of an an anti-pattern in Zig. There are still use cases for it, but you should think twice about it being your go-to strategy. There's been a recent shift in the standard library toward "unmanaged" containers, which don't store a copy of the Allocator interface, and instead Allocators are pass…

I’m not sure I see how each example improves on the previous (though granted, I don’t really know Zig).

What happens if you call append() with two different allocators? Or if you deinit() with a different allocator than the one that actually handled the memory?

Post reply on HN