16 ms·
Zig's New Async I/O
- 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?
- dminik 1y ago> but most modern apps rely on some form of dependency injection Does Zig actually do anything here? If anything, this seems to be anti-Zig, where everything must be explicit.
- philwelch 1y agoPassing in your dependencies as function arguments is a form of dependency injection. It is the simplest and thus arguably best form of dependency injection.
- almostgotcaught 1y agoThis is like saying arithmetic is a form of calculus, the simplest form. Ie it reduces the concept (DI) to a meaningless tautology.
- mlugg 1y agoThe key difference to typical async function coloring is that `Io` isn't something you need specifically for asynchronicity; it's something which (unless you make a point to reach into very low-level primitives) you will need in order to perform any IO, including reading a file, sleeping, getting the time, etc. It's also just a value which you can keep wherever you want, rather than a special attribute/property of a function. In practice, these properties solve the coloring problem: * It's quite rare for a function to unexpectedly gain a dependency on "doing IO" in general. In practice, most of your codebase will have access to an `Io`, and only leaf functions doing pure computation will not need them. * If a function does start needing to do IO, it almost certainly doesn't need to actually take it as a parameter. As in many languages, it's typical in Zig code to have one type which manages a bunch of core state, and which the whole codebase has easy access to (e.g. in the Zig compiler itself, this is the `Compilation` type). Because of this, despite the perception, Zig code doesn't usually pass (for instance) allocators explicitly all the way down the function call graph! Instead, your "general purpose allocator" is available on that "application state" type, so you can fetch it from essentially wherever. IO will work just the same in practice. So, if you discover that a code path you previously thought was pure actually does need to perform IO, then you don't need to apply some nasty viral change; you just grab `my_thing.io`. I do agree that in principle, there's still a form of function coloring going on. Arguably, our solution to the problem is just to color every function async-colored (by giving most or all of them access to an `Io`). But it's much like the "coloring" of having to pass `Allocator`s around: it's not a problem in practice, because you'll basically always have easy access to one even if you didn't previously think you'd need it. I think seasoned Zig developers will pretty invariably agree with the statement that explicitly passing `Allocator`s around really does not introduce function coloring annoyances in practice, and I see no reason that `Io` would be particularly different.
- dminik 1y ago> It's quite rare for a function to unexpectedly gain a dependency on ... If this was true in general, the function coloring problem wouldn't be talked about. However, the second point is more interesting. I think there's a bit of a Stockholm syndrome thing here with Zig programmers and Allocator. It's likely that Zig programmers won't mind passing around an extra param. If anything, it would make sense to me to have IO contain an allocator too. Allocation is a kind of IO too. But I guess it's going to be 2 params from now on.
- n42 1y agoAside from the ridiculous argument that function parameters color them, the assertion that you can’t call a function that takes IO from inside a function that does not is false, since you can initialize one to pass it in
- dminik 1y agoTo me, there's no difference between the IO param and async/await. Adding either one causes it to not be callable from certain places. As for the second thing: You can do that, but... You can also do this in Rust. Yet nobody would say Rust has solved function coloring. Also, check this part of the article: > 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, ... Doing that is an instant performance hit. Not to mention annoying to do.
- n42 1y agoYou’re allowed to not like it, but that doesn’t change that your argument that this is a form of coloring is objectively false. I’m not sure what Rust has to do with it.
- rowanG077 1y agoSure it is a function coloring. Just in a different form. `async` in other languages is something like an implicit parameter. In zig they made this implicit parameter explicit. Is that more better/more ergonomic? I don't know yet. The sugar is different, but the end result the same. Unless you can show me concrete example of things that the approach zig has taken can do that is not possible in say, rust. Than I don't buy that it's not just another form of function coloring.
- throwawaymaths 1y ago> Unless you can show me concrete example add io to a struct and let the struct keep track of its own io.
- ayuhito 1y agoGo also suffers from this form of “subtle coloring”. If you’re working with goroutines, you would always pass in a context parameter to handle cancellation. Many library functions also require context, which poisons the rest of your functions. Technically, you don’t have to use context for a goroutine and could stub every dependency with context.Background, but that’s very discouraged.
- tidwall 1y agoContext is not required in Go and I personally encourage you to avoid it. There is no shame in blazing a different path.
- nu11ptr 1y agoWhat would you use in its place? I've never had an issue with it. I use it for 1) early termination 2) carrying custom request metadata. I don't really think it is fully the coloring problem because you can easily call non-context functions from context functions (but not other way around, so one way coloring issue), but you need to be aware the cancellation chain of course stops then.
- schrodinger 1y agoWhy do you encourage avoiding it? Afaik it's the only way to early-abort an operation since Goroutines operate in a cooperative, not preemptive, paradigm. To be very clear, I'm asking this completely in good faith looking to learn something new!
- kristoff_it 1y agoHere's a trick to make every function red (or blue? I'm colorblind, you decide): var io: std.Io = undefined; pub fn main() !void { var impl = ...; io = impl.io(); } Just put io in a global variable and you won't have to worry about coloring in your application. Are your functions blue, red or green now? Jokes aside, I agree that there's obviously a non-zero amount of friction to using the `Io` intreface, but it's something qualitatively very different from what causes actual real-world friction around the use of async await. > but the general problem behind function coloring is that of context I would disagree, to me the problem seems, from a practical perspective that: 1. Code can't be reused because the async keyword statically colors a function as red (e.g. python's blocking redis client and asyncio-redis). In Zig any function that wants to do Io, be it blue (non-async) or red (async) still has to take in that parameter so from that perspective the Io argument is irrelevant. 2. Using async and await opts you automatically into stackless coroutines with no way of preventing that. With this new I/O system even if you decide to use a library that interally uses async, you can still do blocking I/O, if you want. To me these seems the real problems of function coloring.
- ismailmaj 1y agoThe global io trick would totally be valid if you’re writing an application (i.e. not a library) and don’t have use of two different implementations of io
- throwawaymaths 1y agoyou could still have a library-global io, let the user set it as desired. > use of two different implementations of io functionally rare situation.
- laserbeam 1y agoThere are plenty of libraries out there which require users to do an init() call of some sorts at startup. It is perfectly possible to design a library that only works with 1 io instance and gets it at init(). Whether people like or want that… I have no clue.
- 1y ago
- cryptonector 1y ago> Well, you don't have async/sync/red/blue anymore, but you now have IO and non-IO functions. > However, the coloring problem hasn't really been defeated. Well, yes, but if the only way to do I/O were to have an Io instance to do it with then Io would infect all but pure(ish, non-Io) functions, so calling Io functions would be possible in all but those contexts where calling Io functions is explicitly something you don't want to be possible. So in a way the color problem is lessened. And on top of that you get something like Haskell's IO monad (ok, no monad, but an IO interface). Not too shabby, though you're right of course. Next Zig will want monadic interfaces so that functions only have to have one special argument that can then be hidden.
- throwawaymaths 1y agoZig's not really about hiding things but you could put it in an options struct that has defaults unless overridden at compile time.
- nmilo 1y agoThe original “function colouring” blogpost has done irreparable damage to PL discussions because it’s such a stupid concept to begin with. Of course I want async functions to be “coloured” differently, they do different things! How else is a “normal function” supposed to call a function that gives you a result later——obviously you want to be forced to say what to do with the result; await it, ignore it, .then() in JS terms, etc. these are important decisions that you can’t just ignore because it’s “painful”
- yxhuvud 1y agoThere is nothing obvious around that - it is driven by what abstractions the language provides related to concurrency, and with different choices you will end needing different ways to interact with it. So yes, given how the language designers of C# and JavaScript choose to implement concurrency and the APIs around that, then coloring is necessary. But it is very much implementation driven and implementation of other concurrency models then other ways to do it that don't involve keywords can make sense. So when people complain about function coloring, they are complaining about the choice of concurrency model that a language uses.
- throwawaymaths 1y ago> Technically you could pass in a new executor, but is that really what you want? why does it have to be new? just use one executor, set it as const in some file, and use that one at every entrypoint that needs io! now your io doesn't propagate downwards.
- jaredklewis 1y agoSo this is a tangent from the main article, but this comment made me curious and I read the original "What color is Your Function" post. It was an interesting read, but I guess I came away confused about why "coloring" functions is a problem. Isn't "coloring" just another form of static typing? By giving the compiler (or interpreter) more meta data about your code, it can help you avoid mistakes. But instead of the usual "first argument is an integer" type meta data, "coloring" provides useful information like: "this function behaves in this special way" or "this function can be called in these kinds of contexts." Seems reasonable? Like the author seems very perturbed that there can be different "colors" of functions, but a function that merely calculates (without any IO or side-effects) is different than one that does perform IO. A function with only synchronous code behaves very differently than one that runs code inside another thread or in a different tick of the event loop. Why is it bad to have functions annotated with this meta data? The functions behave in a fundamentally different way whether you give them special annotations/syntax or not. Shouldn't different things look different? He mentions 2015 era Java as being ok, but as someone that’s written a lot of multithreaded Java code, it’s easy to mess up and people spam the “synchronized” keyword/“color” everywhere as a result. I don’t feel the lack of colors in Java makes it particularly intuitive or conceptually simpler.
- deleted 1y ago[deleted]
- com2kid 1y ago> Isn't "coloring" just another form of static typing? Yes, and so is declaring what exceptions a function can throw (checked exceptions in Java). > Why is it bad to have functions annotated with this meta data? The functions behave in a fundamentally different way whether you give them special annotations/syntax or not. Shouldn't different things look different? It really isn't a problem. The article makes people think they've discovered some clever gotcha when they first read it, but IMHO people who sit down for a bit and think through the issue come to the same conclusion you have - Function coloring isn't a problem in practice.
- kristoff_it 1y ago
- ozgrakkurt 1y agoYou are skipping the massive point here. If you are using a library in rust, it has to be async await, tokio, send+sync and all the other crap. Or if it is sync api then it is useless for async application. This approach of passing IO removes this problem and this is THE main problem. This way you don’t have to use procedural macros or other bs to implement multi versioning for the functions in your library, which doesn’t work well anyway in the end. https://nullderef.com/blog/rust-async-sync/ https://nullderef.com/blog/rust-async-sync/ You can find 50 other ones like this by searching. To be honest I don’t hope they will solve cooperative scheduling, high performance, optionally thread-per-core async soon and the API won’t be that good anyway. But hope it solves all that in the future.
- dwattttt 1y ago> Or if it is sync api then it is useless for async application. The rest is true, but this part isn't really an issue. If you're in an async function you can call sync functions still. And if you're worried it'll block and you can afford that, I know tokio offers spawn_blocking for this purpose.
- tcfhgj 1y ago> If you are using a library in rust, it has to be async await, tokio, send+sync and all the other crap Send and sync is only required if you want to access something from multiple threads, which isn't required by async await (parallelism vs concurrency) 1) You can use async await without parallelism and 2) send and sync aren't a product of async/await in Rust, but generally memory safety, i.e. you need Send generally when something can/is allowed to move between threads.
- m11a 1y agoYes, but async Rust is basically built on tokio's runtime, which is what most the big async libraries depend on, like hyper/axum/tokio etc. And tokio is a thread-per-core work-stealing architecture, which requires Send + Sync bounds everywhere. You can avoid them if you depend on tokio-proper, but it's more icky when building on something like axum, where your application handlers also require these bounds. A good article on this: https://emschwartz.me/async-rust-can-be-a-pleasure-to-work-with-without-send-sync-static/ https://emschwartz.me/async-rust-can-be-a-pleasure-to-work-w...
- flohofwoe 1y ago> In order to call such a function you also need to provide the context. Zig hasn't really solved this. It is much more flexible though since you don't need to pass the IO implementation into each function that needs to do IO. You could pass it once into an init function and then use that IO impl throughout the object or module. Whether that's good style is debatable - the Zig stdlib currently has containers that take an allocator in the init function, but those are on the way out in favour of explicitly taking the allocator in each function that needs to allocate - but the user is still free to write a minimal wrapper to restore the 'pass allocator into init' behaviour. Odin has an interesting solution in that it passes an implicit context pointer into each function, but I don't know if the compiler is clever enough to remove the overhead for called functions that don't access the context (since it also needs to peek into all called functions - theoretically Zig with it's single-compilation-unit approach could probably solve that problem better).
- tcfhgj 1y agoYou can write a wrapper in other langs, too, e.g. in Rust: block_on(async_fn)
- nurettin 1y agoI think the point is 3 doesn't fully apply anymore. And that was the main pain point. You couldn't call a blue in red even if it didn't use IO without some kind of execution wrapper or waiter. Now you clearly can.
- tcfhgj 1y ago> If anything, it does a great job at abstracting the usage from the implementation. This is something Rust fails at spectacularly. Could you expand on this? I don't get what you mean
- koito17 1y agoI am not very experienced in async Rust, but it seems there are some pieces of async Rust that rely too much on tokio internals, so using an alternative runtime (like pollster) results in broken code. Searching for comments mentioning "pollster" and "tokio" on HN brings a few results, but not one I recall seeing a while ago where someone demonstrated an example of a library (using async Rust) that crashes when not using tokio as the executor. Related documentation: https://rust-lang.github.io/async-book/08_ecosystem/00_chapter.html#determining-ecosystem-compatibility https://rust-lang.github.io/async-book/08_ecosystem/00_chapt...
- tcfhgj 1y agothanks, got it!
- pimeys 1y agoYep. The old async wars in the Rust ecosystem. It's the AsyncRead and AsyncWrite traits. Tokio has its own, there was a standard brewing at the same time in the futures crate. Tokio did their own thing, people burnt out, these traits were never standardized to std. So you cannot use most of the async crates easily outside Tokio.
- conradludgate 1y agoThere's two details that are important to highlight. tokio is actually 2 components, it's the async scheduler, and it's the IO runtime. Pollster is only a scheduler, and does not offer any IO functionality. You can actually use tokio libraries with pollster, but you need to register the IO runtime (and spawn a thread to manage it) - this is done with Runtime::enter() and it configures the thread local interface so any uses of tokio IO know what runtime to use. There are ideas to abstract the IO runtime interface into the async machinery (in Rust that's the Context object that schedulers pass into the Future) but so far that hasn't gotten anywhere.
- benreesman 1y agoI'll let a real category theorist get into the details that I'll likely flub, but the IO monad is where you end up if you start on this path. That context can be implicit, but it's there, and if you want any help from the compiler (to, for example, guide Claude Code towards useful outcomes) you've got to reify it as a real thing in the formality of the system. Async and coroutines are the graveyard of dreams for systems programming languages, and Andrew by independently rediscovering the IO monad and getting it right? Hope of a generation. Functions in the real world have colors: you can have predictable rules for moving between colors, or you can wing it and get C++ co_await and tokio and please kill me. This is The Way.
- throwawaymaths 1y agoit's not a monad, since you can do unholy (for fp) things with it, like stash it in a struct and pass the struct around (even to functions which have no clue theres an io call) or just grab a globalized io object and use that at will arbitrarily at many entrypoints in your function. most importantly, besides the obvious situations (creating the io object, binding it to another object), it's not generally going to be returned from a function as part of its value.
- benreesman 1y agoi'm relatively confident that andrew will happily break the language again (god love him for that) when it becomes clear that you really want that algebra. though i will say, for a systems language, it's probably better to invert the lift/unlift relationship, default to do-notation and explicitly unlift into pure functions. that's almost what const meant in C++ to begin with but it lost it's way.
- throwawaymaths 1y agoyou're just spreading FUD about zig breaking before 1.0. there is zero basis for you to be "relatively confident" about this matter, because 1. the io situation is basically restructuring analgously to the allocator situation, which is at this point battle tested. there are currently no "monads wrapping statefulness" anywhere in zig. 2. It's not in zig's nature to build something because it it satisifies an fp idiom. the abstractions and resulting "thing that the hardware does" (at least in release builds) are generally more or less obvious. the levels of compiler reinterpretation to achieve functional purity are not really the sort of thing that zig does. for example, zig does not have a privileged "iterator" method that the compiler reinterprets in a way that unrolls blocks or lambdas into loops without crossing a frame boundary.
- osigurdson 1y agoAgree that with something like go, there is truly no function coloring at all. However, since most real world async things require cancellation, a context parameter is always present so there is some "coloring" do to that. Still, it is much less viral than C# style async await as if you don't have a context in your call stack you can still create one when needed and call the function. I don't think it is reasonable to abstract cancellation in a way that nothing has to be passed in so perhaps the approach presented here is realistically as good as it gets.
- panzi 1y agoDoes Zig have closures? If yes, than at least in that case the IO pointer can be a bound parameter. For languages with the async keyword function coloring also applied to closures.
- gpderetta 1y agoI have a much longer rant elsethread, but the tl;dr; is: In some languages red can call blue, but blue cannot call red (JS). In some other languages blue can call red, but the resulting combined function is blue (traditional async with optional blocking). Finally some languages allow blue to call red and having the resulting combined function to be red (lua, scheme, go, and I believe Zig). As color is no longer a n unabstractable restriction in these languages, it no different than other kind of typing.
- 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.
- 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.
- 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.
- 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.
- 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 :)
- deleted 1y ago[deleted]
- 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.