Comment by mlugg

Comment by mlugg a day ago

36 replies

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.

dminik a day 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.

  • laserbeam a day ago

    > 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.

    • chrisohara a day ago

      > 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.

      The allocator may yield to the OS when requesting or releasing memory (e.g. sbrk, mmap, munmap)?

      • messe a day ago

        I don't find that a particularly compelling argument in this case, because so can accessing any memory address if it's not currently swapped in.

      • laserbeam 21 hours ago

        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).

        • throwawaymaths 20 hours ago

          the page allocator which is the root of many allocators calls mmap. of course the fixed buffer allocator does not.

      • [removed] 21 hours ago
        [deleted]
  • pharrington an hour ago

    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.

  • throwawaymaths a day ago

    > 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

    • camgunz a day ago

      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.

      • gpderetta 10 hours ago

        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.

      • throwawaymaths 21 hours ago

        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.

  • Gibbon1 a day ago

    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.

SkiFire13 21 hours ago

> 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?

FlyingSnake a day ago

> Arguably, our solution to the problem is just to color every function async-colored.

This is essentially how Golang achived color-blindness.

  • cogman10 16 hours ago

    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.

    • gpderetta 10 hours ago

      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.

ginko a day ago

> 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?

  • flohofwoe a day ago

    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.

  • throwawaymaths a day ago

    std.debug.print(..) prints to stderr whuch does not need an io param.

  • dwattttt a day ago

    I'm not familiar with Zig, but won't the classic "blocking" APIs still be around? I'd rather a synchronous debug print either way.

    • laserbeam a day ago

      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.

  • osigurdson 19 hours ago

    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.

littlestymaar 17 hours ago

> * 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…

immibis 10 hours ago

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.