27 ms·
Zig's New Async I/O
- logicchains 1y agoDoes this mean that as a side effect, it'll now be possible to enforce functions are pure/deterministic in Zig by not passing in an Io?
- mlugg 1y agoNot quite: * Global variables still exist and can be stored to / loaded from by any code * Only convention stops a function from constructing its own `Io` * Only convention stops a function from reaching directly into low-level primitives (e.g. syscalls or libc FFI) However, in practice, we've found that such conventions tend to be fairly well-respected in most Zig code. I anticipate `Io` being no different. So, if you see a function which doesn't take `Io`, you can be pretty confident (particularly if it's in a somewhat reputable codebase!) that it's not interacting with the system (e.g. doing filesystem accesses, opening sockets, sleeping the thread).
- logicchains 1y agoWhat about random number generation; is that something that will also fall under Io?
- AndyKelley 1y agoI think random numbers can safely be considered non blocking.
- n42 1y agoThis is very well written, and very exciting! I especially love the implications for WebAssembly -- WASI in userspace? Bring your own IO? Why not both!
- dminik 1y agoI feel that I have to point this out once again, because the article goes so far as to state that: > With this last improvement Zig has completely defeated function coloring. I disagree with this. Let's look at the 5 rules referenced in the famous "What color is your function?" article referenced here. > 1. Every function has a color Well, you don't have async/sync/red/blue anymore, but you now have IO and non-IO functions. > 2. The way you call a function depends on its color. Now, technically this seems to be solved, but you still need to provide IO as a parameter. Non-IO functions don't need/take it. It looks like a regular function call, but there's no real difference. > 3. You can only call a red function from within another red function This still applies. You can only call IO functions from within other IO functions. Technically you could pass in a new executor, but is that really what you want? Not to mention that you can also do this in languages that don't claim to solve the coloring problem. > 4. Red functions are more painful to call I think the spirit still applies here. > 5. Some core library functions are red This one is really about some things being only possible to implement in the language and/or stdlib. I don't think this applies to Zig, but it doesn't apply to Rust either for instance. Now, I think these rules need some tweaking, but the general problem behind function coloring is that of context. Your function needs some context (an async executor, auth information, an allocator, ...). In order to call such a function you also need to provide the context. Zig hasn't really solved this. That being said, I don't think Zig's implementation here is bad. If anything, it does a great job at abstracting the usage from the implementation. This is something Rust fails at spectacularly. However, the coloring problem hasn't really been defeated.
- andyferris 1y agoI think of it this way. Given an `io` you can, technically, build another one from it with the same interface. For example given an async IO runime, you could create an `io` object that is blocking (awaits every command eagerly). That's not too special - you can call sync functions from async functions. (But in JavaScript you'd have trouble calling a sync function that relies on `await`s inside, so that's still something). Another thing that is interesting is given a blocking posix I/O that also allows for creating processes or threads, you could build in userspace a truly asynchronous `io` object from that blocking one. It wouldn't be as efficient as one based directly on iouring, and it would be old school, but it would basically work. Going either way (changing `io` to sync or async) the caller doesn't actually care. Yes the caller needs a context, but most modern apps rely on some form of dependency injection. Most well-factored apps would probably benefit from a more refined and domain-specific "environment" (or set of platform effects, perhaps to use the Roc terminology), not Zig's posix-flavoured standard library `io` thing. Yes rust achieves this to some extent; you can swap an async runtime for another and your app might still compile and run fine. Overall I like this alot - I am wondering if Richard Feldmann managed to convince Andrew Kelley that "platforms" are cool and some ideas were borrowed from Roc?
- the__alchemist 1y agoEt tu, Zig?
- do_not_redeem 1y agoI'm generally a fan of Zig, but it's a little sad seeing them go all in on green threads (aka fibers, aka stackful coroutines). Rust got rid of their Runtime trait (the rough equivalent of Zig's Io) before 1.0 because it performed badly. Languages and OS's have had to learn this lesson the hard way over and over again: https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p13... > While fibers may have looked like an attractive approach to write scalable concurrent code in the 90s, the experience of using fibers, the advances in operating systems, hardware and compiler technology (stackless coroutines), made them no longer a recommended facility. If they go through with this, Zig will probably top out at "only as fast as Go", instead of being a true performance competitor. I at least hope the old std.fs sticks around for cases where performance matters.
- dundarious 1y agoIt's hardly "all-in" if it is merely one choice of many, and the choice is made in the executable not in the library code.
- do_not_redeem 1y agoI have definitely gotten the impression that green threads will be the favored implementation, from listening to core team members and hanging around the discord. Stackless coroutines don't even exist in the language currently.
- andyferris 1y agoIn the 2026 roadmap talk Andrew Kelley spoke of the fact that stackless coroutines with iouring is the end goal here (but the requires an orthogonal improvement in the compiler for inlining that data to the stack where possible).
- do_not_redeem 1y agoDo you have the timestamp? I watched that video when it came out and don't remember hearing it.
- henrikl 1y agoSeeing a systems language like Zig require runtime polymorphism for something as common as standard IO operations seems off to me -- why force that runtime overhead on everyone when the concrete IO implementation could be known statically in almost all practical cases?
- do_not_redeem 1y agoI think it's just the Zig philosophy to care more about binary size than speed. Allocators have the same tradeoff, ArrayListUnmanaged is not generic over the allocator, so every allocation uses dynamic dispatch. In practice the overhead of allocating or writing a file will dwarf the overhead of an indirect call. Can't argue with those binary sizes. (And before anyone mentions it, devirtualization is a myth, sorry)
- kristoff_it 1y ago> (And before anyone mentions it, devirtualization is a myth, sorry) In Zig it's going to be a language feature, thanks to its single unit compilation model. https://github.com/ziglang/zig/issues/23367 https://github.com/ziglang/zig/issues/23367
- do_not_redeem 1y agoWouldn't this only work if there's only one implementation throughout the entire compliation unit? If you use 2 allocators in your app, your restricted function type has 2 possible callees for each entry, and you're back to the same problem.
- Zambyte 1y ago> A side effect of proposal #23367, which is needed for determining upper bound stack size, is guaranteed de-virtualization when there is only one Io implementation being used (also in debug builds!). > In the less common case when a program instantiates more than one Io implementation, virtual calls done through the Io interface will not be de-virtualized, as that would imply doubling the amount of machine code generated, creating massive code bloat. From the article
- didibus 1y agoI don't know Zig, but wouldn't such a change be a major breaking change where all prior Zig code doing Io wouldn't work anymore if upgraded?
- open592 1y agoLarge breaking change: https://github.com/ziglang/zig/pull/24329 https://github.com/ziglang/zig/pull/24329
- xxpor 1y agoZig's not at 1.0 yet, so there's no stability guarantee at this point.
- TUSF 1y agoBreaking changes is just another Tuesday for Zig.
- flohofwoe 1y agoYeah, but why is that a problem? Zig doesn't promise any stability before 1.0, and it's not like we don't need to change code in other language ecosystem frequently for all sorts of reasons (e.g. bumping a dependency version, or a new minor C/C++ compiler implementing new warnings).
- sevensor 1y ago> io.async expresses asynchronicity (the possibility for operations to happen out of order and still be correct) and it does not request concurrency, which in this case is necessary for the code to work correctly. This is the key point for me. Regardless of whether you’re under an async event loop, you can specify that the order of your io calls does not imply sequencing. Brilliant. Separate what async means from what the io calls do.
- gavinhoward 1y agoAs the author of a semi-famous post about how Zig has function colors [1], I decided to read up on this. I see that blocking I/O is an option: > The most basic implementation of `Io` is one that maps to blocking I/O operations. So far, so good, but blocking I/O is not async. There is a thread pool that uses blocking I/O. Still good so far, but blocking I/O is still not async. Then there's green threads: > This implementation uses `io_uring` on Linux and similar APIs on other OSs for performing I/O combined with a thread pool. The key difference is that in this implementation OS threads will juggle multiple async tasks in the form of green threads. Okay, they went the Go route on this one. Still (sort of) not async, but there is an important limitation: > This implementation requires having the ability to perform stack swapping on the target platform, meaning that it will not support WASM, for example. But still no function colors, right? Unfortunately not: > This implementation [stackless coroutines] won’t be available immediately like the previous ones because it depends on reintroducing a special function calling convention and rewriting function bodies into state machines that don’t require an explicit stack to run. (Emphasis added.) And the function colors appear again. Now, to be fair, since there are multiple implementation options, you can avoid function colors, especially since `Io` is a value. But those options are either: * Use blocking I/O. * Use threads with blocking I/O. * Use green threads, which Rust removed [2] for good reasons [3]. It only works in Go because of the garbage collector. In short, the real options are: * Block (not async). * Use green threads (with their problems). * Function colors. It doesn't appear that the function colors problem has been defeated. Also, it appears to me that the Zig team decided to have every concurrency technique in the hope that it would appear innovative. [1]: https://gavinhoward.com/2022/04/i-believe-zig-has-function-colors/ https://gavinhoward.com/2022/04/i-believe-zig-has-function-c... [2]: https://github.com/aturon/rfcs/blob/remove-runtime/active/0000-remove-runtime.md https://github.com/aturon/rfcs/blob/remove-runtime/active/00... [3]: https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p13...
- ozgrakkurt 1y agoTheir bet seems to be that they can transparently implement real async inside an IO implementation using compiler magic. But then it means if you use that IO instance with the magic then your function gets transformed into a state machine? Then this whole thing is useless for implementing cooperative scheduling async like in rust?
- eestrada 1y agoAlthough I'm not wild about the new `io` parameter popping up everywhere, I love the fact that it allows multiple implementations (thread based, fiber based, etc.) and avoids forcing the user to know and/or care about the implementation, much like the Allocator interface. Overall, I think it's a win. Especially if there is a stdlib implementation that is a no overhead, bogstock, synchronous, blocking io implementation. It follows the "don't pay for things you don't use" attitude of the rest of zig.
- ozgrakkurt 1y agoIsn’t “don’t pay for what you don’t use” a myth? Some other person will using unless you are a very small team with discipline, and you will pay for it. Or just passing around an “io” is more work than just calling io functions where you want them.
- aatd86 1y agoSo is that zig becoming a type AND effect system?
- phplovesong 1y agoI wish Zig had not done async/await. CPS (like you have in Go) is way, way better, and is lower level, making it possible to do you own "async/await" if you really want to.
- flohofwoe 1y agoRead the article, the new Zig async/await interface doesn't imply the typical async/await state-machine code transformation. You can write a simple blocking runtime, or a green-thread implementation, or a thread-pool, or the state-machine approach via stackless coroutines (but AFAIK this needs a couple of language builtins which then must be implemented in an IO implementation).
- osa1 1y agoBy CPS do you mean lightweight threads + meeting point channels? (i.e. both the reader and writer get blocked until they meet at the read/write call) Or something else? Why is CPS better and lower level than async/await?
- burnt-resistor 1y agoBecause it allows multiple topologies of producers and consumers.
- osa1 1y agoNo idea what that means.. Do you have a concrete example of what CPS allows and async/await doesn't?
- mikojan 1y agoI believe async/await means you have a single consumer (caller) and a single producer (callee) and only a single value will be produced (resolved). With CPS you may send and receive many times over to whomever and from whomever you like. In JavaScript you may write.. const fetchData = async () => { // snip return data; } const data = await fetchData(); And in Go you might express the same like.. channel := make(chan int); go func() { // snip channel <- data; }() data := <-channel But you could also, for example, keep sending data and send it to as many consumers as you like.. go func() { for { // Infinite loop: // snip channel1 <- data; channel2 <- data; channel3 <- data; } }()
- noelwelsh 1y agoOk, they are implementing an effect system. Is there any acknowledgement that they are going down an established path?
- ryeats 1y agoWhen I watched the release notes they didn't sound like it was some ground breaking new pattern it's just a new approach that fits best with zig.
- wucke13 1y agoIs this in effect introducing algebraic effects by concept? E.g. the io passed in is an effect handler, and it is the effect handler's choice whether to perform stack switching (or other means of non-blocking waiting) to enable asynchronicity?
- runeks 1y agoIn my view, algebraic effects enable specifying different kinds of effects (with different interpretations) — e.g. read a file, run DB query, network access — as opposed to just a single 'Io' effect that allows everything.
- Cloudef 1y agoI like the IO interface simply for the fact that it would allow me to create language level vfs
- crabmusket 1y agoSeeing the example code made me wonder if this would allow introducing capability based security. E.g. passing an `io` instance to a library which can only read a subtree of the filesystem. Edit: not quite https://news.ycombinator.com/item?id=44549430 https://news.ycombinator.com/item?id=44549430
- Cloudef 1y agoOnly if you are sure all the code uses the IO instance, if you mean language level sandboxing of untrusted code then no, zig code can always call syscalls directly. But you can compile zig to wasm which will give you capability based security.
- wordofx 1y agoDamn that’s some ugly async syntax.
- Yoric 1y agoInteresting. This is a bit reminiscent of how OCaml handles async these days.
- hardwaresofton 1y agoNote that this same concept is "sans io" and was previously discussed for it's use in Rust: https://www.firezone.dev/blog/sans-io https://www.firezone.dev/blog/sans-io https://sans-io.readthedocs.io/ https://sans-io.readthedocs.io/ https://news.ycombinator.com/item?id=40872020 https://news.ycombinator.com/item?id=40872020
- jwolfe 1y agoIf the functions are still calling I/O methods directly rather than the I/O being externally driven, I don't think that qualifies as sans-io, based on my previous exposure / based on your second link: > For byte-stream based protocols, the protocol implementation can use a single input buffer and a single output buffer. For input (that is, receiving data from the network), the calling code is responsible for delivering code to the implementation via a single input (often via a method called receive_bytes, or something similar). The implementation will then append these bytes to its internal byte buffer. At this point, it can choose to either eagerly process those bytes, or do so lazily at the behest of the calling code. > When it comes to generating output, a byte-stream based protocol has two options. It can either write its bytes to an internal buffer and provide an API for extracting bytes from that buffer, as done by hyper-h2, or it can return bytes directly when the calling code triggers events (more on this later), as done by h11. The distinction between these two choices is not enormously important, as one can easily be transformed into the other, but using an internal byte buffer is recommended if it is possible that the act of receiving input bytes can cause output bytes to be produced: that is, if the protocol implementation sometimes automatically responds to the peer without user input.
- matu3ba 1y agoYep, that would be more like structured concurrency also mentioned in linked blog post. sans-io is about state machine as interface, but unfortunately does not specify a formal model or how to synthesize/derive one etc.
- hardwaresofton 1y agoAh good point -- sans I/O as described in that second link is a bit more narrow than what Zig is doing here. The sans I/O discussed there is more for protocols specifically and less for general I/O. I guess a better name for this approach might be "explicitly managed I/O".
- lenkite 1y agoLove the no function coloring solution! I am so looking forward to Zig 1.0. Finally, a system programming language that I can actually read and understand without putting in heavy labor. Hell, I could fully follow this blog post, without actually knowing anything much about Zig. Broke my head on async Rust several times before throwing in the towel.
- anonymoushn 1y agoI think this design is a regression from the previous design, in which you could use compile time introspection to check whether things are actually async (calling convention) or not. Additionally, I don't necessarily want to delegate the management of the memory backing the futures to an Io, or pass around a blob of syscalls and an associated runtime, which accesses everything via a vtable. I would prefer to have these things be compile time generic only.
- audunw 1y agoYour preference to have them be compile time generic shouldn’t come at the cost of those that would want runtime virtualisation. As the article concludes, you get the best of both worlds here, where the result is effectively compile time generic if you only use one io implementation in your program. In theory it’d also partially compile time generic if you exclusively use one io for one set of libraries/functions and a different io for another set of libraries/functions. I see this as the objectively correct design based on the existing design decisions in Zig. It follows from the allocator interface decision.
- anonymoushn 1y agoYes, I understand that the designers prefer the Allocator situation and that Reader and Writer being anytype was downstream of the difficulty of using async readers and writers otherwise. So the intention was always to go with the design that I do not prefer. One reason I do not prefer it is that the Reader and Writer interfaces were already staggeringly inefficient, despite the lack or virtualization. We have avoided the issue by reimplementing a bunch of their API in some specific readers and writers and modifying the stdlib Reader and Writer to dispatch to these methods if they are present. To be honest, I just do not have much faith in the commitment to optimality, when it seems like the team has not spent time doing things like profiling a program that spends a lot of time formatting integers as decimal syrings, and noticing that the vast majority of that formatting runtime is UTF-8 validation. I am happy to continue using the language, because it makes it easy enough to fix these issues oneself. The only aspect that may not be recoverable by the end user is the "am I async/is this async" reflection issue, though a core team member has clarified in this comment section that the code in the article is a sketch and the design of stackless coroutines is far from done, so we may yet get this. Some other philosophical point is, like, lua's coroutine.create/resume/yield/clone are control flow primitives for use within a single thread of execution. It's fine to ship an async runtime, which embodies the view they they are not control flow primitives for use within a single thread of execution, for doing I/O. But focusing the primitives for creating and switching between execution contexts too narrowly on the async runtime use case is liable to he harmful to other use cases for these operations. Ideally, we would be able to write things like a prominent SNES emulator that uses stack switching to ensure the simulation of different components proceeds in an order known to be more correct than other orders, and we would be able to do it using native language features, which would compile down to something a bit cheaper than dumping all of our registers onto the stack. Ideally when we do this we would not be asked by the language to consider what it would mean to "cancel" the execution context managing one of the components, in the same way that we do not need to consider what it means to cancel an arbitrary struct, or the function which is calling the function currently executing.
- deleted 1y ago[deleted]
- bigswede 1y agoSpeaking of colors… I like the color scheme of the code snippets, is it a standard scheme available in VSCode?
- xmorse 1y agoThis is a good time to implement "context", a way to pass down the call stack parameters instead of having to add a io argument to every function
- sbszllr 1y agoI don't know if it's still true in the recent versions of Scala (stopped caring in 2018) but it used to have implicit parameters designed specifically for passing context like this. A notable example was passing around an implicit ExecutionContext for thread pools, e.g. in Akka :)
- Galanwe 1y agoSo you have to do: io.async(saveFile, .{io, data, "saveA.txt"}).await(io); That is 3 references to `io` in a single call. Considering there is very little use case for mix and matching different Ios, any chance of having some kind of default / context IO to avoid all these ?
- messe 1y agoIf you're going to immediately await it, you can just do saveFile(io, data, "saveA.txt"); EDIT: following up on that, I'm actually not sure that io.async(saveFile, .{io, data, "saveA.txt"}).await(io); will even be valid code. Futures in this article are declared as var, meaning mutable. This appears to be because Future.await is going to take a pointer as its initial argument. However, because it's a temporary and therefore treated as const, the return value of io.async will not be passable to a .await function expecting a *Future as its initial argument without first being stored in a mutable var. So this would be valid: var save_future = io.async(saveFile, .{io, data, "saveA.txt"}); save_future.await(io); But the original presented in the parent comment would be equivalent to the following, and therefore invalid: const save_future = io.async(saveFile, .{io, data, "saveA.txt"}); save_future.await(io); // compile error
- deleted 1y ago[deleted]
- rastignack 1y agoI wrote a simple ssh server in zig to learn the language in my spare time. The new design makes the event loop / io much easier to reason about. Thanks Andy
- runeks 1y ago> The new design makes the event loop / io much easier to reason about. Interesting. How so?
- schaefer 1y agoIs there any chance you've published your project? It would be fun to read the code.
- rastignack 1y agoI’ll finish it this summer, hopefully.
- garaetjjte 1y agoI'm confused. The trouble with "colored" functions is that they either do processing on the stack, or unwind the stack. They claim defeat of function coloring, and describe that IO implementation can use blocking/thread pool/green threads. But... these are all blocking methods, which weren't the problem in the first place! If you keep convention to never do IO using global state, you could do that practically in any language. Stackless coroutines being left for later feels like "draw the rest of the owl" situation. To actually have truly universal functions, I think there are two solutions: - Make every function async, and provide extra parameter indicating to not actually unwind the stack and execute synchronously instead. Comes with performance penalty. - Compile each function twice, picking appropiate variant at call site. Increases code size and requires some hackery with handling function pointers.
- bob1029 1y ago> Make every function async, and provide extra parameter indicating to not actually unwind the stack and execute synchronously instead. Comes with performance penalty. I think ValueTask<T> in C#/.NET can approach this use case - It avoids overhead if the method actually completes synchronously. Otherwise, you can get at the Task<T> if needed. From a code perspective, you await it like you normally would and the compiler/runtime figures out what to do.
- throwawaymaths 1y agoI am not on the core team but i believe the plan is to do exactly what you are talking about, but after the API is nailed down and kinks have been ironed out by users of the existing semiblocking implementation (to possibly include the compiler), as the default LLVM coro state machine compiler has problems (for example: I think I remember Andrew complaining that it has an obligatory libc/malloc dependency). since the new io interface has userland async/await methods, then dropping in a proper frame jumping solution will be less painful, and easier to debug, and if using coroutines proves to be challenging with the api hopefully changes to io api would be minor, versus going after stackless coroutines NOW and making large API changes often as the warts with the system uncover themselves.
- thrwyexecbrain 1y agoI miss the mention of boost::asio in this thread. At first glance this new Io interface feels not that dissimilar to it: Both are generic interfaces over an event loop/executor supporting async or blocking operations. Both ship a thread-pool and a stackful coroutine backend and both can be used through their respective language's stackless coroutine implementation (co_yield in cpp and yet-unimplemented in zig).
- davidkunz 1y agoI'm a bit concerned when library authors only test it with blocking Io and the consuming app with a different kind. Wouldn't this potentially lead to bugs?
- nextaccountic 1y agoWhat about timers? That's the other resource an event loop must offer, besides I/O. If you are using I/O with green threads but sleep the OS thread, you block other green threads. Likewise, if you have stackless coroutines (when they exist in Zig) but sleep the OS thread, you block other coroutines. So is there an io.sleep? Also - does any of this use io_uring on Linux?
- gpderetta 1y agoThe discussion around function color misses the distinction between the typical await approach and other solutions. For example, consider a library that implements the C preprocessor; it implements a single function that takes a string to be processed and applies the C pre-processing algorithm to it and returns the preprocessed string. c_preprocessor_v1(body: string) -> string The C preprocessor has includes operations, so it might need to (recursively) open additional files. Instead of making assumptions about what's the include path is or even the existence of a filesystem, the designer of the c_preprocessor decided, in v2, to delegates the file opening to a separate function [1]: c_preprocessor_v2(file: path, loader : path->string) -> string c_preprocessor_v2 will incrementally call loader as it discover new include statements, possibly from the output of loader itself. _v1 can of course be implemented in term of _v2 given a default loader definition. Now you want to implement a preprocessor-as-a-service. It provides a rich API for the user to submit an initial file to your service and for the service to ask the user to submit the additional files on demand. And of course you want to use the c_preprocessor library. You expect your service to have to server hundreds of thousands of concurrent requests, so you want to make it async, in particular you want to make the loading async. If you are using JS I believe you are screwed: you can't use the library as is: c_preprocessor_v2 and the async loader live in separate worlds: red (async) functions can call blue (sync) functions, but not vice versa; you need to ask the maintainer for a new async c_preprocessor_v3 that takes an async loader. In some other languages (rust, c#, python) can wrap your async loader with a wrapper that blocks (in a way, closing over the async-ness of the function), but this is hardly ideal, the resulting call to c_preprocessor_v2 would not be async and prevent you from scaling to hundreds of thousands of requests. You might play around with offloading to thread pools, but as the bulk of the work is inside the c_preprocessor function it is never going to work well. In practice your blue functions can call red functions, but the resulting function is blue. There is a third class of languages that allow you to combine blue and red functions producing red ones (Go, lua, scheme, and I believe this new Zig proposal); in these languages the caller can sandwich calls to sync functions across async domains, while still allowing suspending the whole call stack. One disadvantage of the third class is that, as side effects are often unrestricted, if c_preprocessor relies on hidden global state, it might not be able to handle reentrancy correctly. There is then a fourth class of languages where, not only side effects are always explicit (Haskel, some effectful programming languages), it is possible, and indeed idiomatic to be able to abstract over it. So c_processor_v2 might not only be able to call synchronous or asynchronous loaders transparently, but the idiomatic implementation might even be able to extract additional concurrency by not imposing dependencies unless necessary. One interpretation is that in these languages functions are always red, but I think that's reductive and not useful. [1] this example uses higher order functions, but an OOP example would be of course completely equivalent.