[HN Gopher] Zig's new plan for asynchronous programs
___________________________________________________________________
Zig's new plan for asynchronous programs
Author : messe
Score : 180 points
Date : 2025-12-02 14:31 UTC (8 hours ago)
(HTM) web link (lwn.net)
(TXT) w3m dump (lwn.net)
| ecshafer wrote:
| 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.
| warmwaffles wrote:
| Async usually ends up being a coloring function that knows no
| bounds once it is used.
| amonroe805-2 wrote:
| I've never really understood the issue with this. I find it
| quite useful to know what functions may do something async vs
| which ones are guaranteed to run without stopping.
|
| In my current job, I mostly write (non-async) python, and I
| find it to be a performance footgun that you cannot trivially
| tell when a method call will trigger I/O, which makes it
| incredibly easy for our devs to end up with N+1-style queries
| without realizing it.
|
| With async/await, devs are always forced into awareness of
| where these operations do and don't occur, and are much more
| likely to manage them effectively.
|
| FWIW: The zig approach also seems great here, as the explicit
| Io function argument seems likely to force a similar
| acknowledgement from the developer. And without introducing
| new syntax at that! Am excited to see how well it works in
| practice.
| newpavlov wrote:
| In my (Rust-colored) opinion, the async keyword has two
| main problems:
|
| 1) It tracks code property which is usually omitted in sync
| code (i.e. most languages do not mark functions with "does
| IO"). Why IO is more important than "may panic", "uses
| bounded stack", "may perform allocations", etc.?
|
| 2) It implements an ad-hoc problem-specific effect system
| with various warts. And working around those warts requires
| re-implementation of half of the language.
| echelon wrote:
| > Why IO is more important than "may panic", "uses
| bounded stack", "may perform allocations", etc.?
|
| Rust could use these markers as well.
| newpavlov wrote:
| I agree. But it should be done with a proper effect
| system, not a pile of ad hoc hacks built on abuse of the
| type system.
| echelon wrote:
| `async` is in the type system. In your mind, how would
| you mark and bubble up panicky functions, etc.? What
| would that look like?
|
| I felt like a `panic` label for functions would be nice,
| but if we start stacking labels it becomes cumbersome:
| pub async panic alloc fn foo() {}
|
| That feels dense.
|
| I think ideally it would be something readers could spot
| at first glance, not something inferred.
| newpavlov wrote:
| >`async` is in the type system.
|
| No, it's not. `async` is just syntax sugar, the "effect"
| gets emulated in the type system using `Future`. This is
| one of the reasons why the `async` system feels so
| foreign and requires so many language changes to make it
| remotely usable. `const` is much closer to a "true"
| effect (well, to be precise it's an anti-effect, but it's
| not important right now).
|
| Also, I think it's useful to distinguish between effect
| and type systems, instead of lumping them into just "type
| system". The former applies to code and the latter to
| data.
|
| >That feels dense.
|
| Yes. But why `async` is more important than `alloc`? For
| some applications it's as important to know about
| potential allocations, as for other applications to know
| about whether code potentially yields or not.
|
| Explicitly listing all effects would the most
| straightforward approach, but I think a more practical
| approach would be to have a list of "default effects",
| which can be overwritten on the crate (or maybe even
| module) level. And on the function level you will be able
| to opt-in or opt-out from effects if needed.
|
| >I think ideally it would be something readers could spot
| at first glance
|
| Well, you either can have "dense" or explicit "at first
| glance" signatures.
| ecshafer wrote:
| Is this Django? I could maybe see that argument there. Some
| frameworks and ORMs can muddy that distinction. But most
| the code ive written its really clear if something will
| lead to io or not.
| warmwaffles wrote:
| I've watched many changes over time where the non async
| function uses an async call, then the function eventually
| becomes marked as async. Once majority of functions get
| marked as async, what was the point of that boilerplate?
| davidkunz wrote:
| In JavaScript, I love the `async` keyword as it's a good
| indicator that something goes over the wire.
| Dwedit wrote:
| Async always confused me as to when a function would actually
| create a new thread or not.
| et1337 wrote:
| 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.
| osigurdson wrote:
| At least Go didn't take the dark path of having async / await
| keywords. In C# that is a real nightmare and necessary to use
| sync over async anti-patterns unless willing to re-write
| everything. I'm glad Zig took this "colorless" approach.
| rowanG077 wrote:
| Where do you think the Io parameter comes from? If you change
| some function to do something async and now suddenly you
| require an Io instance. I don't see the difference between
| having to modify the call tree to be async vs modifying the
| call tree to pass in an Io token.
| messe wrote:
| Synchronous Io also uses the Io instance now. The coloring
| is no longer "is it async?" it's "does it perform Io"?
|
| This allows library authors to write their code in a manner
| that's agnostic to the Io runtime the user chooses,
| synchronous, threaded, evented with stackful coroutines,
| evented with stackless coroutines.
| rowanG077 wrote:
| Rust also allows writing async code that is agnostic to
| the async runtime used. Subsuming async under Io doesn't
| change much imo.
| metaltyphoon wrote:
| Except that now your library code lost context on how it
| runs. If you meant it to be sync and the caller gives you
| an multi threaded IO your code can fail in unexpected
| ways.
| kbd wrote:
| One of the harms Go has done is to make people think its
| concurrency model is at all special. "Goroutines" are green
| threads and a "channel" is just a thread-safe queue, which Zig
| has in its stdlib
| https://ziglang.org/documentation/master/std/#std.Io.Queue
| jerf wrote:
| A channel is not just a thread-safe queue. It's a thread-safe
| queue that can be used in a select call. Select is the
| distinguishing feature, not the queuing. 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.
|
| Of course even if that exact queue is not itself selectable,
| you can still implement a Go channel with select capabilities
| in Zig. I'm sure one exists somewhere already. Go doesn't get
| access to any magic CPU opcodes that nobody else does. And
| languages (or libraries in languages where that is possible)
| can implement more capable "select" variants than Go ships
| with that can select on more types of things (although not
| necessarily for "free", depending on exactly what is
| involved). But it is more than a queue, which is also why Go
| channel operations are a bit to the expensive side, they're
| implementing more functionality than a simple queue.
| jeffbee wrote:
| If we're just arguing about the true nature of Scotsmen,
| isn't "select a channel" merely a convenience around
| awaiting a condition?
| SkiFire13 wrote:
| It's more akin to awaiting *any* condition from a list.
| jerf wrote:
| This is not a "true Scotsman" argument. It's the
| distinctive characteristic of Go channels. Threaded
| queues where you can call ".get()" from another thread,
| but that operation is blocking and you can't try any
| other queues, then you can't write:
| select { case result := <-resultChan:
| // whatever case <-cxt.Done(): // our
| context either timed out or was cancelled }
|
| or any more elaborate structure.
|
| Or, to put it a different way, when someone says "I
| implement Go channels in X Language" I don't look for
| whether they have a threaded queue but whether they have
| a select equivalent. Odds are that there's already a
| dozen "threaded queues" in X Language anyhow, but select
| is less common.
|
| Again note the difference between the word "distinctive"
| and "unique". No individual feature of Go is unique, of
| course, because again, Go does not have special unique
| access to Go CPU opcodes that no one else can use. It's
| the more defining characteristic compared to the more
| mundane and normal threaded queue.
|
| Of course you can implement this a number of ways. It is
| not equivalent to a naive condition wait, but probably
| with enough work you could implement them more or less
| with a condition, possibly with some additional compiler
| assistance to make it easier to use, since you'd need to
| be combining several together in some manner.
| kbd wrote:
| > 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 the io implementation has a select
| function[2] that "Blocks until one of the futures from the
| list has a result ready, such that awaiting it will not
| block. Returns that index." and the generic select switches
| on that and returns the result. Details unclear tho.
|
| [1]
| https://ziglang.org/documentation/master/std/#std.Io.select
|
| [2]
| https://ziglang.org/documentation/master/std/#std.Io.VTable
| SkiFire13 wrote:
| Maybe I'm missing something, but how do you get a
| `Future` for receiving from a channel?
|
| Even better, how would I write my own `Future` in a way
| that supports this `select` and is compatible with any
| reasonable `Io` implementation?
| jerf wrote:
| 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 original goroutine now can not
| get access to that value; the future does not "resolve".
|
| Channel semantics don't match futures semantics. As the
| name implies, channels are streams, futures are a single
| future value that may or may not have resolved yet.
|
| Again, I'm sure nothing stops Zig from implementing Go
| channels in half-a-dozen different ways, but it's
| definitely not as easy as "oh just wrap a future around
| the .get of a threaded queue".
|
| By a similar argument it should be observed that channels
| don't naively implement futures either. It's fairly easy
| to make a future out of a channel and a couple of simple
| methods; I think I see about 1 library a month going by
| that "implements futures" in Go. But it's something that
| has to be done because channels aren't futures and
| futures aren't channels.
|
| (Note that I'm not making any arguments about whether one
| or the other is _better_. I think such arguments are
| actually quite difficult because while both are quite
| different in practice, they also both fairly fully cover
| the solution space and it isn 't clear to me there's
| globally an advantage to one or the other. But they are
| certainly _different_.)
| kbd wrote:
| > channels aren't futures and futures aren't channels.
|
| In my mind a queue.getOne ~= a <- on a Go channel. Idk
| how you wrap the getOne call in a Future to hand it to
| Zig's select but that seems like it would be a
| straightforward pattern once this is all done.
|
| 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.Queu
| e.g...
| 0x696C6961 wrote:
| What other mainstream languages have pre-emptive green
| threads without function coloring? I can only think of
| Erlang.
| smw wrote:
| I'm told modern Java (loom?) does. But I think that might
| be an exhaustive list, sadly.
| femiagbabiaka wrote:
| Maybe not mainstream, but Racket.
| dlisboa wrote:
| It was special. CSP wasn't anywhere near the common
| vocabulary back in 2009. Channels provide a different way of
| handling synchronization.
|
| Everything is "just another thing" if you ignore the
| advantage of abstraction.
| jerf wrote:
| Wrapping every IO operation into a channel operation is fairly
| expensive. You can get an idea of how fast it would work now by
| just doing it, using a goroutine to feed a series of IO
| operations to some other goroutine.
|
| It wouldn't be quite as bad as the perennial "I thought Go is
| fast why is it slow when I spawn a full goroutine and multiple
| channel operations to add two integers together a hundred
| million times" question, but it would still be a fairly
| expensive operation. See also the fact that Go had fairly
| sensible iteration semantics before the recent iteration
| support was added by doing a range across a channel... as long
| as you don't mind running a full channel operation and internal
| context switch for every single thing being iterated, which in
| fact quite a lot of us do mind.
|
| (To optimize pure Python, one of the tricks is to ensure that
| you get the maximum value out of all of the relatively
| expensive individual operations Python does. For example, it's
| already handling exceptions on every opcode, so you could win
| in some cases by using exceptions cleverly to skip running some
| code selectively. Go channels are similar; they're _relatively_
| expensive, on the order of dozens of cycles, so you want to
| make sure you 're getting sufficient value for that. You don't
| have to go super crazy, they're not like a millisecond per
| operation or something, but you do want to get value for the
| cost, by either moving non-trivial amount of work through them
| or by taking strong advantage of their many-to-many
| coordination capability. IO often involves moving around small
| byte slices, even perhaps one byte, and that's not good value
| for the cost. Moving kilobytes at a time through them is
| generally pretty decent value but not all IO looks like that
| and you don't want to write that into the IO spec directly.)
| ecshafer wrote:
| Have you tried Odin? Its a great language thats also a "better
| C" but takes more Go inspiration than Zig.
| dismalaf wrote:
| 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.
| Zambyte wrote:
| > 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
| := <- fooChan: // handle foo case bar := <-
| barChan: // handle bar }
|
| 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.
| qudat wrote:
| 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.
| SvenL wrote:
| I think and hope that they don't do that. As far as I remember
| their mantra was ,,no magic, you can see everything which is
| happening". They wanted to be a simple and obvious language.
| qudat wrote:
| That's fair, but the same argument can be made for Go's
| verbose error handling. In that case we could argue that
| `try` is magical, although I don't think anyone would want to
| take that away.
| messe wrote:
| > 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 passed to any
| member function that allocates.
|
| Previously, one would write: var list:
| std.ArrayList(u32) = .init(allocator); defer
| list.deinit(); for (0..count) |i| { try
| list.append(i); }
|
| Now, it's: var list: std.ArrayList(u32) =
| .empty; defer list.deinit(allocator); for
| (0..count) |i| { try list.append(allocator, i);
| }
|
| Or better yet: var list: std.ArrayList(u32) =
| .empty; defer list.deinit(allocator); try
| list.ensureUnusedCapacity(allocator, count); // Allocate up
| front for (0..count) |i| {
| list.appendAssumeCapacity(i); // No try or allocator necessary
| here }
| turtletontine wrote:
| 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?
| messe wrote:
| Storing an Allocator alongside the container is an
| additional 16-bytes. This isn't much, but starts adding up
| when you start storing other objects that keep allocators
| inside of those containers. This can improve cache
| locality.
|
| It also helps devirtualization, as the most common case is
| threading a single allocator through your application (with
| the occasion Arena allocator wrapping it for grouped
| allocations). When the Allocator interface is stored in the
| container, it's harder for the optimizer to prove it hasn't
| changed.
|
| > 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?
|
| It's undefined behaviour, but I've never seen it be an
| issue in practice. Expanding on what I mentioned above,
| it's typical for only a single allocator to be used for
| long live objects throughout the entire program. Arena
| allocators are used for grouped allocations, and tend to
| have a well defined scope, so it's obvious where
| deallocation occurs. FixedBufferAllocator also tends to be
| used in the same limited scope.
| Mond_ wrote:
| Yes, and it's good that way.
|
| Please, anything but a dependency injection framework. All
| parameters and dependencies should be explicit.
| scuff3d wrote:
| I think a good compromise between a DI framework and having to
| pass everything individually would be some kind of Context
| object. It could be created to hold an Allocator, IO
| implementation, and maybe a Diagnostics struct since Zig
| doesn't like attaching additional information to errors. Then
| the whole Context struct or parts of it could be passed around
| as needed.
| debugnik wrote:
| > 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?
| gf000 wrote:
| Haskell has green threads. Plus nowadays Java also has virtual
| threads.
| debugnik wrote:
| And I bet those green threads still need an IO type of some
| sort to encode anything non-pure, plus usually do-syntax.
| Comparing merely concurrent computations to I/O-async is just
| weird. In fact, I suspect that even those green threads
| already have a "colourful" type, although I can't check right
| now.
| iviv wrote:
| Pure actions can be run in parallel with https://hackage-
| content.haskell.org/package/parallel/docs/Co...
|
| Impure actions use the IO monad like always in Haskell: htt
| ps://hackage.haskell.org/package/base-4.21.0.0/docs/Contr..
| . (or the higher-level async library)
|
| I suppose an extreme version of the function coloring
| argument could be that all types are colors.
| debugnik wrote:
| In a sense, kinda? The function colour problem is that
| you can't call an async API from a non-async caller
| without modifying the entire call chain (or blocking the
| full thread). In async/await languages the conflict comes
| from changing the return types; the syntax is orthogonal.
|
| Maybe in practice some properties of code are better off
| being whole-program than represented as types, even if
| they're less modular or harder to check.
|
| Also thanks for the reference, I haven't touched Haskell
| in ages; I'm more of an F# and OCaml guy.
| codr7 wrote:
| Love it, async code is a major pita in most languages.
| giancarlostoro wrote:
| When Microsoft added Tasks / Async Await, that was when I
| finally stopped writing single threaded code as often as I did,
| since the mental overhead drastically went away. Python 3 as
| well.
| codr7 wrote:
| Isn't this exactly the mess Zig is trying to get out of here?
|
| Every other example I've seen encodes the execution model in
| the source code.
| woodruffw wrote:
| 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.)
| rowanG077 wrote:
| Having used zig a bit as a hobby. Why is it more ergonomic?
| Using await vs passing a token have similar ergonomics to me.
| The one thing you could say is that using some kind of token
| makes it dead simple to have different tokens. But that's
| really not something I run into often at all when using async.
| messe wrote:
| > The one thing you could say is that using some kind of
| token makes it dead simple to have different tokens. But
| that's really not something I run into often at all when
| using async.
|
| It's valuable to library authors who can now write code
| that's agnostic of the users' choice of runtime, while still
| being able to express that asynchronicity is possible for
| certain code paths.
| rowanG077 wrote:
| But that can already be done using async await. If you
| write an async function in Rust for example you are free to
| call it with any async runtime you want.
| messe wrote:
| But you can't call it from synchronous rust. Zig is
| moving toward all sync code also using the Io interface.
| tcfhgj wrote:
| yes, you can: runtime.block_on(async {
| })
|
| https://play.rust-
| lang.org/?version=stable&mode=debug&editio...
| whytevuhuni wrote:
| 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...
| tcfhgj wrote:
| just pass around handles like you do in zig, alright?
|
| also: spawn_blocking for blocking code
| whytevuhuni wrote:
| 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.
| tcfhgj wrote:
| > But that's the thing, idiomatic Rust sync code almost
| never passes around handles, even when they need to do
| I/O.
|
| Because they don't use async inside.
|
| Zig code is passing around handles in code without io?
| whytevuhuni wrote:
| > Because they don't use async inside.
|
| But they use I/O inside, and we arrive at this issue:
|
| I'm writing async, and I need to call std::fs::read. I
| can't, because it blocks the thread; I could use
| spawn_blocking but that defeats the purpose of async. So
| instead I have to go look for a similar function but of
| the other color, probably from tokio.
|
| In Zig, if you're writing sync, you call the standard
| library function for reading files. If you're writing
| async, you call the _same_ library function for reading
| files. Then, the creator of the `io` object decides
| whether the whole thing will be sync or async.
| messe wrote:
| Let me rephrase, you can't call it like any other
| function.
|
| In Zig, a function that does IO can be called the same
| way whether or not it performs async operations or not.
| And if those async operations don't _need_ concurrency
| (which Zig expresses separately to asynchronicity), then
| they 'll run equally well on a sync Io runtime.
| tcfhgj wrote:
| > In Zig, a function that does IO can be called the same
| way whether or not it performs async operations or not.
|
| no, you can't, you need to pass a IO parameter
| messe wrote:
| You will need to pass that for synchronous IO as well.
| _All IO_ in the standard library is moving to the Io
| interface. Sync and async.
|
| If I want to call a function that does asynchronous IO,
| I'll use: foo(io, ...);
|
| If I want to call one that does synchronous IO, I'll
| write: foo(io, ...);
|
| If I want to express that either one of the above can be
| run asynchronously if possible, I'll write:
| io.async(foo, .{ io, ... });
|
| If I want to express that it must be run concurrently,
| then I'll write: try io.concurrent(foo,
| .{ io, ... });
|
| Nowhere in the above do I distinguish whether or not foo
| does synchronous or asynchronous IO. I only mark that it
| does IO, by passing in a parameter of type std.Io.
| tcfhgj wrote:
| what about non-io code?
| messe wrote:
| What about it? It gets called without an Io parameter.
| Same way that a function that doesn't allocate doesn't
| get an allocator.
|
| I feel like you're trying to set me up for a gotcha "see,
| zig does color functions because it distinguishes
| functions that do io and those that don't!".
|
| And yes, that's true. Zig, at least Zig code using std,
| will mark functions that do Io with an Io parameter. But
| surely you can see how that will lead to less of a split
| in the ecosystem compared to sync and async rust?
| tcfhgj wrote:
| > But surely you can see how that will lead to less of a
| split in the ecosystem compared to sync and async rust?
|
| not yet
| torginus wrote:
| This creates the drill-down issue we see with React props
| where we have to pass objects around in the call chain
| just so that somewhere down the line we can use it.
|
| React gets around this with the context hook and which
| you can access implicitly if it has been injected at a
| higher level.
|
| Do you know if Zig supports something of the sort?
| messe wrote:
| It doesn't and likely never will.
|
| This has been a non-issue for years with Allocator. I
| fail to see why it will be a problem with IO.
| flohofwoe wrote:
| If calling the same function with a different argument would be
| considered 'function coloring', every function in a program is
| 'colored' and the word loses its meaning ;)
|
| Zig actually also had solved the coloring problem in the old
| and abandondend async-await solution because the compiler
| simply stamped out a sync- or async-version of the same
| function based on the calling context (this works because
| everything is a single compilation unit).
| woodruffw wrote:
| > If calling the same function with a different argument
| would be considered 'function coloring', than every function
| in a program is 'colored' and the word loses its meaning ;)
|
| Well, yes, but in this case the colors (= effects) are
| actually important. The implications of passing an effect
| through a system are nontrivial, which is why some languages
| choose to promote that effect to syntax (Rust) and others
| choose to make it a latent invariant (Java, with runtime
| exceptions). Zig chooses another path not unlike Haskell's
| IO.
| rowanG077 wrote:
| If your functions suddenly requires
| (currently)unconstructable instance "Magic" which you now
| have to pass in from somewhere top level, that indeed suffers
| from the same issue as async/await. Aka function coloring.
|
| But most functions don't. They require some POD or float,
| string or whatever that can be easily and cheaply constructed
| in place.
| jcranmer wrote:
| > If calling the same function with a different argument
| would be considered 'function coloring', than every function
| in a program is 'colored' and the word loses its meaning ;)
|
| I mean, the concept of "function coloring" in the first place
| is itself an artificial distinction invented to complain
| about the incongruent methods of dealing with "do I/O
| immediately" versus "tell me when the I/O is done"--two
| methods of I/O that are so very different that it really
| requires very different designs of your application on top of
| those I/O methods: in a sync I/O case, I'm going to design my
| parser to output a DOM because there's little benefit to not
| doing so; in an async I/O case, I'm instead going to have a
| streaming API.
|
| I'm still somewhat surprised that "function coloring" has
| become the default lens to understand the semantics of async,
| because it's a rather big misdirection from the fundamental
| tradeoffs of different implementation designs.
| omnicognate wrote:
| 100% agree, but fortunately I don't think it is the
| "default lens". If it were nobody would be adding new async
| mechanisms to languages, because "what color is your
| function" was a self-described rant against async, in
| favour of lightweight threads. It does seem to have
| established itself as an unusually persistent meme, though.
| adamwk wrote:
| The subject of the function coloring article was callback
| APIs in Node, so an argument you need to pass to your IO
| functions is very much in the spirit of colored functions and
| has the same limitations.
| jakelazaroff wrote:
| 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.
| layer8 wrote:
| 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 just in case, and in
| turn require all callers to do the same. This is very
| much like function coloring.
|
| In a language with objects or closures (which Zig doesn't
| have first-class support for), one flexibility benefit of
| the Io object approach is that you can move it to
| object/closure creation and keep the function/method
| signature free from it. Still, you have to pass it
| _somewhere_.
| quantummagic wrote:
| Is that a problem in practice though? Zig already has
| this same situation with its memory allocators; you can't
| allocate memory unless you take a parameter. Now you'll
| just have to take a memory allocator AND an additional io
| object. Doesn't sound very ergonomic to me, but if all
| Zig code conforms to this scheme, in practice there will
| only-one-way-to-do-it. So one of the colors will never be
| needed, or used.
| derriz wrote:
| > 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.
| layer8 wrote:
| You can, but then you're denying your callers control
| over the Io. It's not really different with async
| function coloring:
| https://news.ycombinator.com/item?id=46126310
|
| Scheduling of IO operations isn't hidden global state. Or
| if it is, then so is thread scheduling by the OS.
| messe wrote:
| > 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.
| simonask wrote:
| This is also why function coloring is not a problem, and
| is in fact desirable a lot of the time.
| SkiFire13 wrote:
| > Zig actually also had solved the coloring problem in the
| old and abandondend async-await solution because the compiler
| simply stamped out a sync- or async-version of the same
| function based on the calling context (this works because
| everything is a single compilation unit).
|
| AFAIK this still leaked through function pointers, which were
| still sync or async (and this was not visible in their type)
| throwawaymaths wrote:
| Pretty sure the Zig team is aware of this and has plans to
| fix it before they re-release async.
| dundarious wrote:
| There is a token you must pass around, sure, but because you
| use the same token for both async and sync code, I think
| analogizing with the typical async function color problem is
| incorrect.
| jayd16 wrote:
| Actually it seems like they just colored everything async and
| you pick whether you have worker threads or not.
|
| I do wonder if there's more magic to it than that because it's
| not like that isn't trivially possible in other languages. The
| issue is it's actually a huge foot gun when you mix things like
| this.
|
| For example your code can run fine synchronously but will
| deadlock asynchronously because you don't account for methods
| running in parallel.
|
| Or said another way, some code is thread safe and some code
| isn't. Coloring actually helps with that.
| flohofwoe wrote:
| > Actually it seems like they just colored everything async
| and you pick whether you have worker threads or not.
|
| There is no 'async' anywhere yet in the new Zig IO system (in
| the sense of the compiler doing the 'state machine code
| transform' on async functions).
|
| AFAIK the current IO runtimes simply use traditional threads
| or coroutines with stack switching. Bringing code-transform-
| async-await back is still on the todo-list.
|
| The basic idea is that the code which calls into IO interface
| doesn't need to know how the IO runtime implements
| concurrency. I guess though that the function that's _called_
| through the `.async()` wrapper is expected to work properly
| both in multi- and single-threaded contexts.
| jayd16 wrote:
| > There is no 'async'
|
| I meant this more as simply an analogy to the devX of other
| languages.
|
| >Bringing code-transform-async-await back is still on the
| todo-list.
|
| The article makes it seem like "the plan is set" so I do
| wonder what that Todo looks like. Is this simply the plan
| for async IO?
|
| > is expected to work properly both in multi- and single-
| threaded contexts.
|
| Yeah... about that....
|
| I'm also interested in how that will be solved. RTFM? I
| suppose a convention could be that your public API must be
| thread safe and if you have a thread-unsafe pattern it must
| be private? Maybe something else is planned?
| messe wrote:
| > The article makes it seem like "the plan is set" so I
| do wonder what that Todo looks like. Is this simply the
| plan for async IO?
|
| There's currently a proposal for stackless coroutines as
| a language primitive:
| https://github.com/ziglang/zig/issues/23446
| doyougnu wrote:
| Agreed. the Haskeller in me screams "You've just implemented
| the IO monad without language support".
| AndyKelley wrote:
| It's not a monad because it doesn't return a description of
| how to carry out I/O that is performed by a separate system;
| it does the I/O inside the function before returning. That's
| a regular old interface, not a monad.
| endgame wrote:
| So it's the reader monad, then? ;-)
| tylerhou wrote:
| Yes.
| SkiFire13 wrote:
| 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.
| bloppe wrote:
| 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.
| VMG wrote:
| Maybe I have this wrong, but I believe the difference is
| that you can create an Io instance in a function that has
| none
| bloppe wrote:
| In Rust, you can always create a new tokio runtime and
| use that to call an async function from a sync function.
| Ditto with Python: just create a new asyncio event loop
| and call `run`. That's actually exactly what an Io object
| in Zig is, but with a new name.
|
| Looking back at the original function coloring post [1],
| it says:
|
| > It is better. I will take async-await over bare
| callbacks or futures any day of the week. But we're lying
| to ourselves if we think all of our troubles are gone. As
| soon as you start trying to write higher-order functions,
| or reuse code, you're right back to realizing color is
| still there, bleeding all over your codebase.
|
| So if this is isomorphic to async/await, it does not
| "solve" the coloring problem as originally stated, but
| I'm starting to think it's not much of a problem at all.
| Some functions just have different signatures from other
| functions. It was only a huge problem for JavaScript
| because the ecosystem at large decided to change the type
| signatures of some giant portion of all functions at
| once, migrating from callbacks to async.
|
| [1]: https://journal.stuffwithstuff.com/2015/02/01/what-
| color-is-...
| bevr1337 wrote:
| In the JS example, a synchronous function cannot poll the
| result of a Promise. This is meaningfully different when
| implementing loops and streams. Ex, game loop, an animation
| frame, polling a stream.
|
| A great example is React Suspense. To suspend a component,
| the render function throws a Promise. To trigger a parent
| Error Boundary, the render function throws an error. To
| resume a component, the render function returns a result.
| React never made the suspense API public because it's a
| footgun.
|
| If a JS Promise were inspectable, a synchronous render
| function could poll its result, and suspended components
| would not need to use throw to try and extend the language.
| bloppe wrote:
| I see. I guess JS is the only language with the coloring
| problem, then, which is strange because it's one of the
| few with a built-in event loop.
|
| This Io business is isomorphic to async/await in Rust or
| Python [1]. Go also has a built-in "event loop"-type
| thing, but decidedly does _not_ have a coloring problem.
| I can 't think of any languages besides JS that do.
|
| [1]: https://news.ycombinator.com/item?id=46126310
| unbrice wrote:
| > Go also has a built-in "event loop"-type thing, but
| decidedly does not have a coloring problem.
|
| context is kind of a function color in go, and it's also
| a function argument.
| int_19h wrote:
| .NET has promises that you can poll synchronously. The
| problem with them is that if you have a single thread,
| then by definition while your synchronous code is
| running, none of the async callbacks can be running. So
| if you poll a Task and it's not complete yet, there's
| nothing you can do to wait for its completion.
|
| Well, technically you can run a nested event loop, I
| guess. But that's such a heavy sync-wrapping-async
| solution that it's rarely used other than as a temporary
| hack in legacy code.
| zamalek wrote:
| 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).
| algesten wrote:
| I wouldn't define it as Sans-IO if you take an IO argument
| and block/wait on reading/writing, whether that be via
| threads or an event loop.
|
| Sans-IO the IO is _outside_ completely. No read/write at
| all.
| matu3ba wrote:
| > 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).
|
| Can you be more specific what is missing in sans-io with
| explicit state machine for static and dynamic analysis
| would not be a complete solution? Serializing the state
| machine sounds excellent for static and dynamic analysis.
| I'd guess the debugging infrastructure for optimization
| passes and run-time debugging are missing or is there more?
| eikenberry wrote:
| Function coloring is specifically about requiring syntax for a
| function, eg. the async keyword. So if you want an async and
| non-async function you need to write both in code. If you pass
| the "coloring" as an argument you avoid the need for extra
| syntax and multiple function definitions and therefor the
| function has no color. You can solve this in various ways with
| various tradeoffs but as long as there is a single function
| (syntactically) is all that matters for coloring.
| IshKebab wrote:
| > Function coloring is specifically about requiring syntax
| for a function, eg. the async keyword.
|
| It isn't really. It's about having two classes of functions
| (async and sync), _and not being able to await async
| functions from sync ones_.
|
| It was originally about Javascript, where it is the case due
| to how the runtime works. In a sync function you can
| technically call an async one, but it returns a promise.
| There's no way to get the actual result before you return
| from your sync function.
|
| That isn't the case for all languages though. E.g. in Rust: h
| ttps://docs.rs/futures/latest/futures/executor/fn.block_on...
| .
|
| I think maybe Python can do something similar but don't quote
| me on that.
|
| There's a closely related problem about making functions
| generic over synchronicity, which people try and solve with
| effects, monads, etc. Maybe people call that "function
| colouring" now, but that wasn't exactly the original meaning.
| woodruffw wrote:
| > Function coloring is specifically about requiring syntax
| for a function, eg. the async keyword.
|
| Someone should tell the inventor of the phrase, because they
| don't mention the async keyword at all[1]. As-written,
| function coloring is about callbacks (since that's semantic
| mechanism that JavaScript happens to pick for their
| asynchronous model).
|
| Function coloring is just an informal way to describe
| encoding a function's _effect_. You can encode that in syntax
| if you want (an `async` keyword), or in the type system
| (returning `() - > T` instead of `T`), or in the runtime
| itself (by controlling all I/O and treating it the same). But
| you can't avoid it.
|
| [1]: https://journal.stuffwithstuff.com/2015/02/01/what-
| color-is-...
| throwawaymaths wrote:
| 1) zig's io is not a viral effect type, you can in principle
| declare a global io variable and use it everywhere that any
| library calls for it. Not best practice for a library writer,
| but if you're building an app, do what you want.
|
| 2) There are two things here, there is function coloring and
| the function coloring problem. The function coloring problem is
| five things:
|
| https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
|
| 1. Every function has a color.
|
| 2. The way you call a function depends on its color.
|
| 3. You can only call a red function from within another red
| function.
|
| 4. Red functions are more painful to call.
|
| 5. Some core library functions are red.
|
| You'll have some convincing to do that zig's plan satisfies 4.
| It's almost certain that it won't satisfy 5.
|
| It's open to debate if zig's plan _will work at all_ , of
| course.
| woodruffw wrote:
| > 1) zig's io is not an effect type, you can in principle
| declare a global io variable and use it everywhere that any
| library calls for it.
|
| That's an effect, akin to globally intermediated I/O in a
| managed runtime.
|
| To make it intuitive: if you have a global token for I/O,
| does your concurrent program need to synchronize on it in
| order to operate soundly? Do programs that fail to obtain the
| token behave correctly?
| throwawaymaths wrote:
| how do you "fail to obtain the token"?
| woodruffw wrote:
| The token guards a fallible resource (I/O). You can
| (temporarily or permanently) fail to obtain it for any
| reason that would affect the underlying I/O.
| dylanowen wrote:
| This seems a lot like what the scala libraries Zio or Kyo are
| doing for concurrency, just without the functional effect part.
| cies wrote:
| 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?
| amluto wrote:
| 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.
| nmilo wrote:
| This is how JS works
| messe wrote:
| It's not guaranteed in Zig either.
|
| Neither task future is guaranteed to do anything until
| .await(io) is called on it. Whether it starts immediately
| (possibly on the same thread), or queued on a thread pool, or
| yields to an event loop, is entirely dependent on the Io
| runtime the user chooses.
| amluto wrote:
| It's not guaranteed, but, according to the article, that's
| how it works in the Evented model:
|
| > When using an Io.Threaded instance, the async() function
| doesn't actually do anything asynchronously -- it just runs
| the provided function right away. So, with that version of
| the interface, the function first saves file A and then file
| B. With an Io.Evented instance, the operations are actually
| asynchronous, and the program can save both files at once.
|
| Andrew Kelley's blog (https://andrewkelley.me/post/zig-new-
| async-io-text-version.h...) discusses io.concurrent, which
| forces actual concurrency, and it's distinctly non-
| structured. It even seems to require the caller to make sure
| that they don't mess up and keep a task alive longer than
| whatever objects the task might reference:
| var producer_task = try io.concurrent(producer, .{
| io, &queue, "never gonna give you up", });
| defer producer_task.cancel(io) catch {};
|
| Having personally contemplated this design space a little
| bit, I think I like Zig's approach a bit more than I like the
| corresponding ideas in C and C++, as Zig at least has defer
| and tries to be somewhat helpful in avoiding the really
| obvious screwups. But I think I prefer Rust's approach or an
| actual GC/ref-counting system (Python, Go, JS, etc) even
| more: outside of toy examples, it's fairly common for
| asynchronous operations to conceptually outlast single
| function calls, and it's really really easy to fail to
| accurately analyze the lifetime of some object, and having
| the language prevent code from accessing something beyond its
| lifetime is very, very nice. Both the Rust approach of
| statically verifying the lifetime and the GC approach of
| automatically extending the lifetime mostly solve the
| problem.
|
| But this stuff is brand new in Zig, and I've never written
| Zig code at all, and maybe it will actually work very well.
| messe wrote:
| Ah, I think we might have been talking over each other. I'm
| referring to the interface not guaranteeing anything, not
| the particular implementation. The Io interface itself
| doesn't guarantee that anything will have started until the
| call to await returns.
| jayd16 wrote:
| C# works like this as well, no? In fact C# can (will?) run the
| async function on the calling thread until a yield is hit.
| throwup238 wrote:
| So do Python and Javascript. I think most languages with
| async/await also support noop-ing the yield if the future is
| already resolved. It's only when you create a new
| task/promise that stuff is guaranteed to get scheduled
| instead of possibly running immediately.
| amluto wrote:
| I can't quite parse what you're saying.
|
| Python works like this: import asyncio
| async def sleepy() -> None: print('Sleepy
| started') await asyncio.sleep(0.25)
| print('Sleepy resumed once') await
| asyncio.sleep(0.25) print('Sleepy resumed and
| is done!') async def main():
| sleepy_future = sleepy() print('Started a
| sleepy') await asyncio.sleep(2)
| print('Main woke back up. Time to await the sleepy.')
| await sleepy_future if __name__ == "__main__":
| asyncio.run(main())
|
| Running it does this: $ python3
| ./silly_async.py Started a sleepy Main woke
| back up. Time to await the sleepy. Sleepy started
| Sleepy resumed once Sleepy resumed and is done!
|
| So there mere act of creating a coroutine does not cause
| the runtime to run it. But if you explicitly create a task,
| it does get run: import asyncio
| async def sleepy() -> None: print('Sleepy
| started') await asyncio.sleep(0.25)
| print('Sleepy resumed once') await
| asyncio.sleep(0.25) print('Sleepy resumed and
| is done!') async def main():
| sleepy_future = sleepy() print('Started a
| sleepy') sleepy_task =
| asyncio.create_task(sleepy_future) print('The
| sleepy future is now in a task') await
| asyncio.sleep(2) print('Main woke back up.
| Time to await the task.') await
| sleepy_task if __name__ == "__main__":
| asyncio.run(main()) $ python3 ./silly_async.py
| Started a sleepy The sleepy future is now in a task
| Sleepy started Sleepy resumed once Sleepy
| resumed and is done! Main woke back up. Time to
| await the task.
|
| I personally like the behavior of coroutines not running
| unless you tell them to run -- it makes it easier to reason
| about what code runs when. But I do not particularly like
| the way that Python obscures the difference between a
| future-like thing that is a coroutine and a future-like
| thing that is a task.
| throwup238 wrote:
| That's exactly the behavior I'm describing.
|
| `sleepy_future = sleepy()` creates the state machine
| without running anything, `create_task` actually
| schedules it to run via a queue, `asyncio.sleep` suspends
| the main task so that the newly scheduled task can run,
| and `await sleepy_task` either yields the main task until
| sleepy_task can finish, or no-ops immediately if it has
| already finished without yielding the main task.
|
| My original point is that last bit is a very common
| optimization in languages with async/await since if the
| future has already resolved, there's no reason to suspend
| the current task and pay the switching overhead if the
| task isn't blocked waiting for anything.
| int_19h wrote:
| > I personally like the behavior of coroutines not
| running unless you tell them to run -- it makes it easier
| to reason about what code runs when.
|
| In .NET the difference was known as "hot" vs "cold"
| tasks.
|
| "Hot" tasks - which is what .NET does with C# async/await
| - have one advantage in that they get to run any code
| that validates the arguments right away and fail right
| there at the point of the call, which is easier to debug.
|
| But one can argue that such validation should properly be
| separate from function body in the first place - in DbC
| terms it's the contract of the function.
| metaltyphoon wrote:
| In C# that Task is ALWAYS hot, aka scheduled to run.
| throwawaymaths wrote:
| is it not the case that in zig, the execution happens in
| a_future.await?
|
| I presume that:
|
| io.async 1 stores in io "hey please work on this"
|
| io.async 2 stores in io "hey also please work on this"
|
| in the case where io is evented with some "provided event
| loop":
|
| await #1 runs through both 1 and 2 interleavedly, and if 2
| finishes before 1, it puts a pin on it, and then returns
| a_result when 1 is completed.
|
| await #2 "no-executions" if 1 finished after 2, but if there is
| still work to be done for 2, then it keeps going until the
| results for 2 are all in.
|
| There's no "task that's running somewere mysteriously" _unless_
| you pick threaded io, in which case, yeah, io.async actually
| kicks shit off, and if the cpu takes a big fat nap on the
| calling thread between the asyncs and the awaits, progress
| might have been made (which wouldn 't be the case if you were
| evented).
| Ericson2314 wrote:
| 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.)
| AndyKelley wrote:
| 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().
| landr0id wrote:
| 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?
| AndyKelley wrote:
| A deadlock.
|
| For example, the function is called immediately, rather than
| being run in a separate thread, causing it to block forever
| on accept(), because the connect() is after the call to
| async().
|
| If concurrent() is used instead, the I/O implementation will
| spawn a new thread for the function, so that the accept() is
| handled by the new thread, or it will return
| error.ConcurrencyUnavailable.
|
| async() is infallible. concurrent() is fallible.
| ethin wrote:
| 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.
| NooneAtAll3 wrote:
| what's the point of implementing cooperative "multithreading"
| (coroutines) with preemptive one (async)?
| nine_k wrote:
| Can you create a thread pool consisting of one thread, and
| suspend / resume the thread?
| RossBencina wrote:
| Doesn't that negate the point of using coroutines? light-
| weight concurrency
| LunicLynx wrote:
| 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.
| mono442 wrote:
| 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.
| thefaux wrote:
| 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.
| pron wrote:
| > I did not find this api to be significantly better for many
| use cases than writing threads and communicating over a
| concurrent queue.
|
| The problem with using OS threads, you run into scaling
| problems due to Little's law. On the JVM we can use virtual
| threads, which don't run into that limitation, but the JVM can
| implement user-mode threads more efficiently than low-level
| languages can for several reasons (the JIT can see through all
| virtual calls, the JVM has helpful restrictions on pointers
| into the stack, and good GCs make memory management very cheap
| in exchange for a higher RAM footprint). So if you want
| scalability, low-level languages need other solutions.
| badmonster wrote:
| Interesting to see Zig tackle async. The io_uring-first approach
| makes sense for modern systems, but the challenge is always
| making async ergonomic without sacrificing Zig's explicit control
| philosophy. Curious how colored functions will play out in
| practice.
| breatheoften wrote:
| Is there any way to implement structured concurrency on top of
| the std.Io primitive?
| AndyKelley wrote:
| var group: Io.Group = .init; defer group.cancel(io);
|
| If you see this pattern, you are doing structured concurrency.
|
| Same thing with: var future = io.async(foo,
| .{}); defer future.cancel(io);
___________________________________________________________________
(page generated 2025-12-02 23:00 UTC)