The 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.
> 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.
> If anything, it would make sense to me to have IO contain an allocator too. Allocation is a kind of IO too.
Io in zig is for “things that can block execution”. Things that could semantically cause a yield of any kind. Allocation is not one of those things.
Also, it’s perfectly reasonable and sometimes desireable to have 13 different allocators in your program at once. Short lived ones, long lived ones, temporary allocations, super specific allocators to optimize some areas of your game…
There are fewer reasons to want 2 different strategies to handle concurrency at the same time in your program as they could end up deadlocking on each other. Sure, you may want one in debug builds, another in release, another when running tests, but there are much fewer usecases of them running side by side.
Yielding in this context means to a different “thread” in your context, not the OS. If you want to express “this is a point where the program can do something else” it is a yield. If you block and can’t switch to something else… it is not.
So if you’re using an API like mmap like that you should think of it as IO (I don’t think you can, but am not sure).
Stockholm syndrome? Many years ago, I was specifically wanting a programming language where I could specify an allocator as a parameter! That's one of Zig's selling points.
> But I guess it's going to be 2 params from now on.
>> 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
Python, for example, will let you call async functions inside non-async functions, you just have to set up the event loop yourself. This isn't conceptually different than the Io thing here.
But the asyncio event loop is not reentrant, so your faux sync functions cannot be safely called from other async functions. It is an extremely leaky abstraction. This is not a theoretical possibility, I stumbled on the issue 15 minutes into my foray into asyncio (turns out that jupyter notebooks use asyncio internally).
There are ways around that (spawn a separate thread with a dedicated event loop, then block), or monkey patch asyncio, but they are all ugly.
except you cant "pass the same event loop in multiple locations". its also not an easy lift. the zig std will provide a few standard implementations which would be trivial to drop in.
I thought it was the exact same; an event loop in Python is just whatever Io is in Zig, make it a param, get it from an import and a lookup (`import asyncio; loop = asyncio.get_running_loop()`). I might be misunderstanding what you're saying though.
hm maybe. i guess ive only used python in situations where it injects it into amain so i could have been confused. i thought python async was a wrapper around generators, and so the mechanism cant be instantiated in multiple places. i stand corrected.
Per thread—once you start working in multiple threads you have the choice to have one global event loop, which comes at the cost of all async code being effectively serialized as far as threads are concerned*, or one event loop per thread.
* Which can be fine if your program is mostly not async but you have that one stubborn library. Yay async virality.
I can't say there's no good reason to have per thread event loops, but I think I can say if you do know of one you're suffering a terrible curse. I can only imagine the constraints that would force me to do this.
Because you have a main application running a web server and a daemon worker thread performing potentially long running tasks that use async libraries and you don't want to block the responsiveness of your web server. It's really not that bad, at least in Python.
Well, here we go I guess. Why can't you just use FastAPI? Or Tornado? Isn't there also an async Flask? Isn't Django also async now? What minor god have you angered to be chained to a non-async framework?
Of all the responses, this was perhaps my least expected one when I was talking about being chained to an async framework. Async isn't a replacement for threads, async doesn't let you spread your work out over multiple cores and doesn't give you time slicing. In Python the asyncio module actually gives you a threadpool to run computationally intensive work in as kind of one-offs. But when you need something like background job processing and want to also reap the benefits of asyncio, like being able to pull multiple tasks off the queue, and progress on others while a job does io, then you need an event loop in the other thread. It was specifically avoiding locking up FastAPI that lead me to use multiple event loops in the first place.
You're free to spin the job worker off to another process but however you swing it it's still multiple event loops you deal with. But with threads you get to only load your Python app into memory once.
Skipping to the end here: if you're inside a Python thread and you're making your own event loop, something has gone badly. Maybe you made a bad choice (using threads inside an async task instead of an executor), maybe you have some legacy nightmare dependency thing to deal with, but multiple event loops, let alone nested event loops, are suboptimal from a resource usage standpoint.
I do something like that with event driven firmware. There is an allocator as part of the context. And the idea that the function is executing under some context seems fine to me.
> 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
I feel like there are two issues with this approach:
- you basically rely on the compiler/stdlib to silently switch the async implementation, effectively implementing a sort of hidden control flow which IMO doesn't really fit Zig
- this only solves the "visible" coloring issue of async vs non-async functions, but does not try to handle the issue of blocking vs non-blocking functions, rather it hides it by making all functions have the same color
- you're limiting the set of async operations to the ones supported in the `Io`'s vtable. This forces it to e.g. include mutexes, even though they are not really I/O, because they might block and hence need async support. But if I wrote my own channel how would this design support it?
Colouring every function async-coloured by default is something that's been attempted in the past; it was called "threads".
The innovation of async over threads is simply to allocate call stack frames on the heap, in linked lists or linked DAGs instead of fixed-size chunks. This sounds inefficient, and it is: indexing a fixed block of memory is much cheaper. It comes with many advantages as well: each "thread" only occupies the amount of memory it actually uses, so you can have a lot more of them; you can have non-linear graphs, like one function that calls two functions at the same time; and by reinventing threading from scratch you avoid a lot of thread-local overhead in libraries because they don't know about your new kind of threads yet. Because it's inefficient (and because for some reason we run the new threading system on top of the old threading system), it also became useful to run CPU-bound functions in the old kind of stack.
If you keep the linked heap activation records but don't colour functions, you might end up with Go, which already does this. Go can handle a large number of goroutines because they use linked activation records (but in chunks, so that not every function call allocates) and every Go function uses them so there is no colour.
You do lose advantages that are specific to coloured async - knowing that a context switch will not occur inside certain function calls.
As usual, we're beating rock with paper in the moment, declaring that paper is clearly the superior strategy, and missing the bigger picture.
It's basically how Java does it (circa 17) as well.
It's something you really can't do without a pretty significant language runtime. You also really need people working within your runtime to prefer being in your runtime. Environments that do a lot of FFI don't work well with a colorblind runtime. That's because if the little C library you call does IO then you've got an incongruous interaction that you need to worry about.
Neither Java nor Go make all functions async. What they provide is stackful coroutines (or equivalently one shot continuations) that allow composing async and sync functions transparently.
Golang isn't color-blind. The magic of async/await isn't that the program isn't blocked, it's that the CALLER doesn't have to be blocked. It gives the caller the flexibility to continue and synchronize at its discretion.
In Golang to avoid blocking the CALLER you'd still have to wrap the call in a Goroutine and use something like a channel(or shared mem) to communicate back to the caller.
Guess what ends up happening IRL? People create a set of functions that return channels, and a set of functions that don't for maximum flexibility. Two colors.
And that's viral much like async/await. You block on that channel? Now your caller needs to wrap you in a Goroutine. Or you have to return the/a channel. etc etc etc.
> It's quite rare for a function to unexpectedly gain a dependency on "doing IO" in general.
From the code sample it looks like printing to stdio will now require an Io param. So won’t you now have to pass that down to wherever you want to do a quick debug printf?
Zig has specifically a std.debug.print() function for debug printing and a std.log module for logging. Those don't necessarily need to be wired up with the whole stdio machinery.
Yes. You can always use the blocking syscalls your OS provides and ignore the Io system for stuff like that. No idea how they’d do that by default in the stdlib, but it will definitely be possible.
I think the key is, if you don't have an "io" in your call stack you can always create one. At least I hope that is how it would work. Otherwise it is equally viral to async await.
> * It's quite rare for a function to unexpectedly gain a dependency on "doing IO" in general.
I don't know where you got this, but it's definitely not the case, otherwise async would never cause problems either. (Now the problem in both cases is pretty minor, you just need to change the type signature of the call stack, which isn't generally that big, but it's exactly the same situation)
> In practice, most of your codebase will have access to an `Io`, and only leaf functions doing pure computation will not need them.
So it's exactly similar to making all of your functions async by default…
I' scratching my head here, because many languages avoid colouring. Effectively all I think you've done is specify an interface for the event loop. Python and I expect a few other languages have pluggable event loops that use the same technique.
Granted some languages like Rust don't, or at least Rust's std library doesn't standardise the event loop interface. That has lead to what can only be described as a giant mess, because there are many async frameworks, and you have to choose. If you implement some marvelous new protocol in Rust, people can't just plug it in unless you have provided the glue for the async framework they use. Zig has managed to avoid Rust's mistake with it's Io interface, but then most async implementations do avoid it in one way or another.
What you haven't avoided is the colouring that occurs between non-async code and async code. Is the trade-off "all code shall be async"? That incurs a cost to single threaded code, as all blocking system calls now become two calls (one to do the operation, and one wait for the outcome).
Long ago Rust avoided that by deciding other whether to do a blocking call, or a schedule call followed by a wait when the system call is done. But making decision also incurs it's over overhead on each and every system call, which Rust decided was too much of an imposition.
For Rust, there is possibly a solution: monomorphisation. The compiler generates one set of code when the OS scheduler is used, and another when the process has it's own event loop. I expect they haven't done that because it's hard and disruptive. I would be impressed if Zig had done it, but I suspect it hasn't.
* 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.