10 ms·
Zig's new plan for asynchronous programs
- ecshafer 10mo agoI 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 10mo agoAsync usually ends up being a coloring function that knows no bounds once it is used.
- amonroe805-2 10mo agoI’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 10mo agoIn 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 10mo ago> Why IO is more important than "may panic", "uses bounded stack", "may perform allocations", etc.? Rust could use these markers as well.
- newpavlov 10mo agoI 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 10mo ago`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 10mo ago>`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 10mo agoIs 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 10mo agoI'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 10mo agoIn JavaScript, I love the `async` keyword as it's a good indicator that something goes over the wire.
- Dwedit 10mo agoAsync always confused me as to when a function would actually create a new thread or not.
- metaltyphoon 10mo agoWhy? Asynchrony has nothing to do with multiple threads. In fact you can have async with only a single thread!
- darthwalsh 10mo agoZig doesn't make it simpler! Now in a single function, using async won't spawn threads, while using sync might. But I'm digging this abstraction.
- et1337 10mo agoI’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 10mo agoAt 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 10mo agoWhere 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 10mo agoSynchronous 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 10mo agoRust also allows writing async code that is agnostic to the async runtime used. Subsuming async under Io doesn't change much imo.
- metaltyphoon 10mo agoExcept 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.
- qudat 10mo agoI'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 10mo agoI 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 10mo agoThat'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 10mo ago> 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 }
- debugnik 10mo ago> 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 10mo agoHaskell has green threads. Plus nowadays Java also has virtual threads.
- debugnik 10mo agoAnd 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 10mo agoPure actions can be run in parallel with https://hackage-content.haskell.org/package/parallel/docs/Control-Parallel.html https://hackage-content.haskell.org/package/parallel/docs/Co... Impure actions use the IO monad like always in Haskell: https://hackage.haskell.org/package/base-4.21.0.0/docs/Control-Concurrent.html https://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 10mo agoIn 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 10mo agoLove it, async code is a major pita in most languages.
- giancarlostoro 10mo agoWhen 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 10mo agoIsn'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.
- themafia 10mo agoIt's one of the things JavaScript has an easier time of than other languages due to the event driven single threaded nature of the runtime itself. They're not as powerful but they are quite useful and exceedingly ergonomic.
- woodruffw 10mo agoI 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 10mo agoHaving 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 10mo ago> 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.
- dylanowen 10mo agoThis seems a lot like what the scala libraries Zio or Kyo are doing for concurrency, just without the functional effect part.
- cies 10mo agoI 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 10mo agoI 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 10mo agoThis is how JS works
- messe 10mo agoIt'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 10mo agoIt’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.html 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.
- Ericson2314 10mo agoThis 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 10mo agoOverall 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().
- deleted 10mo ago[deleted]
- landr0id 10mo agoHey 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 10mo agoA 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.
- setupminimal 10mo ago
- ethin 10mo agoOne 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 10mo agowhat's the point of implementing cooperative "multithreading" (coroutines) with preemptive one (async)?
- wchar_t 10mo agoThere was this beautiful little time period where async/await was basically just that: resumable functions. I abused it to implement generators.
- nine_k 10mo agoCan you create a thread pool consisting of one thread, and suspend / resume the thread?
- RossBencina 10mo agoDoesn't that negate the point of using coroutines? light-weight concurrency
- nine_k 10mo agoIt does, sadly.
- ethin 10mo agoI mean I guess I could... But threads are pretty heavyweight. Though there may be a more lightweight way of implementing them in kernel mode (not coroutines or fibers, but actual threads) and I just have never heard of it.
- LunicLynx 10mo agoPro 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 10mo agoIt 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 10mo agoThis 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 10mo ago> 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.
- davidee 10mo agohttps://www.scala-lang.org/api/current/scala/concurrent/ExecutionContext.html https://www.scala-lang.org/api/current/scala/concurrent/Exec... in case anyone is interested.
- badmonster 10mo ago[flagged]
- klabb3 10mo agoI don’t know the details but reading the article they got this right. It’s been my main gripe with Rust which imo totally botched it. Or rather, they botched the ergonomics. Rust still allows low level control just fine… (but so does a plain old explicit event loop). Go did much better, succeeding at ergonomics but failing at low level control (failed successfully that is, it was never a goal). The trick to retaining ergonomics and low level control is precisely to create a second layer, a ”runtime” layer, which is responsible for scheduling higher level tasks, IO and IPC. This isn’t easy, but it’s the only way. Otherwise you get an interoperability problem that the coloring and ecosystem fragmentation in Rust reflects.
- breatheoften 10mo agoIs there any way to implement structured concurrency on top of the std.Io primitive?
- AndyKelley 10mo agovar 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);
- idle_zealot 10mo agoPassing io into things over and over seems annoying. Like, you can use io to get a File instance, then you need to pass io into its methods to read/write it? When would you ever make a File with one io implementation and want to manipulate it with another?
- ivanjermakov 10mo agoNo one can stop you from defining a global Io value, similar to global allocators. Definitely a bad idea for library code though.
- hardwaresofton 10mo agoExplicit allocators and explicit io are sweet code smells for systems languages. Really think Zig is right about this, excited to use it and feel it out.
- spullara 10mo agoI think that Java virtual threads solve this problem in a much better way than most other languages. I'm not sure that it is possible in a language as low level as Zig however.
- mk12 10mo agoI think the new async IO is great in simple examples like the one shown in the article. But I’m much less sure how well it will work for more complex I/O like you need in servers. I filed an issue about it here: https://github.com/ziglang/zig/issues/26056 https://github.com/ziglang/zig/issues/26056
- notepad0x90 10mo agohttps://news.ycombinator.com/item?id=46065366 https://news.ycombinator.com/item?id=46065366
- danieltanfh95 10mo agohttps://danieltan.weblog.lol/2025/08/function-colors-represent-different-execution-contexts https://danieltan.weblog.lol/2025/08/function-colors-represe... the core problem is that language/library authors need to provide some way to bridge between different execution contexts, like containing these different contexts (sync / async) under FSMs and then providing some sort of communication channel between both.
- epolanski 10mo agojm2c, never had an issue with coloured functions, as long as they are tracked at the type level and you know what you're getting. Yes, eventually you're gonna lift sync to async code, and that works fine as it is generally also the runtime model (asynchronous, event-based).
- Someone 10mo agoIf I understand this correctly, in this example const std = @import("std"); const Io = std.Io; fn saveFile(io: Io, data: []const u8, name: []const u8) !void { const file = try Io.Dir.cwd().createFile(io, name, .{}); defer file.close(io); try file.writeAll(io, data); } the phrase “Either way, the operation is guaranteed to be complete by the time writeAll() returns” is too weak. Given that the function can, over time, be called with different implementations of IO and users can implement IO themselves, I think the only way this can work is that the operation is guaranteed to be complete when the defer starts (if not, what part of the code makes sure the createFile must have completed when writeAll starts? (The IO instance could know, but it would either have to allow for only one ‘in flight’ call or have to keep track of in-progress calls and know of dependency between creating a file and writing to it) But then, how is this really different from a blocking call? Also, if that’s the case, why is that interface called IO? It looks more like a “do this in a different context” thing than specific to I/O to me (https://ziglang.org/documentation/master/std/#std.Io https://ziglang.org/documentation/master/std/#std.Io seems to confirm that. It doesn’t mention I/O at all)
- ptx 10mo agoIsn't this (their async version of Io) essentially the same thing that Go is doing? I seem to recall reading about some downsides to that approach, e.g. that calling C libraries is relatively expensive (because a real stack has to be allocated) and that circumventing libc to do direct syscalls is fragile and unsupported on some platforms. Does the Zig implementation improve on Go's approach? Is it just that it makes it configurable, so that different tradeoffs can be made without changing the code?
- lukaslalinsky 10mo agoThe goal of of the interface is to support multiple modes of operations. You can have the same code, even the same compiled binary, and they can both with either threaded/blocking functions, or stackful coroutines and event loops.