Comment by dminik

Comment by dminik a day ago

152 replies

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

mlugg a day ago

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

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

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

  • [removed] a day ago
    [deleted]
ozgrakkurt a day ago

You 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/

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 a day 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 a day 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 21 hours ago

      Yes, 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-w...

      • tcfhgj 19 hours ago

        Iirc I had a situation a while back, in which I used async await with tokio with a non Send or Sync type and it compiled when I didn't use spawn[1] (implying multithreading) but a simple loop with sequential processing.

        Only when I wanted to enable parallelism using spawn, I got a compilation error.

        [1]: https://docs.rs/tokio/latest/tokio/task/fn.spawn.html

    • WhyNotHugo 17 hours ago

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

      In theory this is correct. In practice, a lot of APIs (including many in tokio) require both traits even for single-thread use cases.

  • dminik a day ago

    I'm not skipping anything. And in fact acknowledge this exact point:

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

  • junon 19 hours ago

    This comment is justly flatly incorrect. You don't need Tokio at all to write an async library. Nor do you need send sync. Not sure what other crap you are speaking of, either.

    Sync APIs can be spawned in worker threads as futures, too. Generally executors have helper methods for that.

kristoff_it a day ago

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

  • dminik a day ago

    Well, it's not really a joke. That's a valid strategy that languages use. In Go, every function is "async". And it basically blocks you from doing FFI (or at least it used to?). I wonder if Zig will run into similar issues here.

    > 1. Code can't be reused because the async keyword statically colors a function

    This is fair. And it's also a real pain point with Rust. However, it's funny that the "What color is your function?" article doesn't even really mention this.

    > 2. Using async and await opts you automatically into stackless coroutines with no way of preventing that

    This however I don't think is true. Async/await is mostly syntax sugar.

    In Rust and C# it uses stackless coroutines.

    In JS it uses callbacks.

    There's nothing preventing you from making await suspend a green thread.

    • kristoff_it a day ago

      I should have specified that better, of course async and await can be lowered to different things (that's what Zig does afterall), what I wanted to say is that that's how it works in general. JS is a good counter example, but for all other mainstream languages, async means stackless coroutines (python, ruby, c#, rust, ...).

      Which means that if I want to use a dependency that uses async await, it's stackless coroutines for me too whether I like it or not.

      • sczi 16 hours ago

        In ruby async is based on stackful fibers. With https://github.com/socketry/async-debug you can see a tree of all fibers with their full call stack. It also avoids the problem people talk about in this thread with go of passing a context parameter everywhere for cancellation as you can kill or raise any exception inside another fiber. I haven't used them but PHP fibers are also supposedly stackful. And Java and every JVM language has them since project loom in JDK 21.

    • thayne 10 hours ago

      > And it basically blocks you from doing FFI

      It doesn't block it. But it does make FFI much more expensive in go than in languages like Rust, because every foreign call needs to set up a c-compatible stack.

  • ismailmaj a day ago

    The 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

    • laserbeam a day ago

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

    • throwawaymaths a day ago

      you could still have a library-global io, let the user set it as desired.

      > use of two different implementations of io

      functionally rare situation.

benreesman 19 hours ago

I'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 18 hours ago

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

    • cynicalkane 17 hours ago

      When I write in Haskell, I find myself mentally glossing the returned monadic state, along the lines of, "Oh, an M x is just an x that does monady stuff to get the x". This becomes natural once you get the hang of do-notation and sometimes monad combinators. So I'm not really thinking about the monadic state in the return value a lot.

      It's not really any less natural than thinking stateful programming, except now the state is a reified thing, which I think is strictly advantageous once you get used to it.

    • benreesman 18 hours ago

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

      • Measter 14 hours ago

        The same Andrew who rejected even basic interfaces in favour of duck-typed generics or manually written vtables?

      • throwawaymaths 17 hours ago

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

ayuhito a day ago

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

  • arp242 a day ago

    Having all async happen completely transparently is not really logically possible. asynchronous logic is frequently fundamentally different from synchronous logic, and you need to do something different one way or the other. I don't think that's really the same as "function colouring".

    And context is used for more than just goroutines. Even a completely synchronous function can (and often does) take a context, and the cancellation is often useful there too.

  • osigurdson 19 hours ago

    I think the main point is in something like Go, the approach is non-viral. If you are 99 levels deep in synchronous code and need to call something with context, well, you can just create one. With C#, if you need to refactor all 99 levels above (or use bad practices which is of course what everyone does).

    Also, in general cancellation is something that you want to optionally have with any asynchronous function so I don't think there really exists an ideal approach that doesn't include it. In my opinion the approach taken by Zig looks pretty good.

  • phplovesong a day ago

    Like you said you dont NEED context. Its just something thats available if you need it. I still think Go/Erlang has one of the best concurrency stories out there.

  • tidwall a day ago

    Context is not required in Go and I personally encourage you to avoid it. There is no shame in blazing a different path.

    • atombender 14 hours ago

      It's not required, but eschewing it ends up going against the grain, since so much of the ecosystem is written to use contexts, including the standard library.

      For example, say you instead of contexts, you use channels for cancellation. You can have a goroutine like this:

          go func() {
            for {
              select {
              case <-stop:
                return
              case <-time.After(1*time.Second):
                resp := fetchURL(url)
                processResult(resp.Body)  // Simplified, of course
              }
            }
          }()
      
      If you want to be able to shut this goroutine down gracefully, you're going to have an issue where http.Get() may stall for a long time, preventing the goroutine from quitting.

      Likewise, processResult() may be doing stuff that cannot be aborted just by closing the stop channel. You could pass the stop channel to it, but now you're just reinventing contexts.

      Of course, you can choose to only use contexts where you're forced to, and invent wrappers around standard library stuff (e.g. the HTTP client), but at that point you're going pretty far to avoid them.

      I do think the context is problematic. For the purposes of cancellation, it's invasive and litters the call graph with parameters and variables. Goroutines really ought to have an implicit context inherited from its parent, since everything is using it anyway.

      Contexts are wildly abused for passing data around, leading to bloated contexts and situations where you can't follow the chain of data-passing without carefully reviewing the entire call graph. I always recommend not being extremely discriminating about where to pass values in context. A core principle is that it has to be something that is so pervasive that it would be egregious to pass around explicitly, such as loggers and application-wide feature flags.

    • schrodinger a day ago

      Why 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!

      • sapiogram 21 hours ago

        > Afaik it's the only way to early-abort an operation since Goroutines operate in a cooperative, not preemptive, paradigm.

        I'm not sure what you mean here. Preemptive/coorporative terminology refers to interrupting (not aborting) a CPU-bound task, in which case goroutines are fully preemptive on most platforms since Go 1.14, check the release notes for more info. However, this has nothing to do with context.

        If you're referring to early-aborting IO operations, then yes, that's what context is for. However, this doesn't really have anything to do with goroutines, you could do the same if the runtime was built on OS threads.

        • kbolino 20 hours ago

          Goroutines are preemptive only to the runtime scheduler. You, the application developer merely using the language, cannot directly preempt a goroutine.

          This makes goroutines effectively cooperative still from the perspective of the developer. The preemptive runtime "just" prevents things like user code starving out the garbage collector. To interrupt a goroutine, your options are generally limited to context cancelation and closing the channel or socket being read, if any. And the goroutine may still refuse to exit (or whatever else you want it to do), though that's largely up to how you code it.

          This difference is especially stark when compared with Erlang/BEAM where you can directly address, signal, and terminate its lightweight processes.

      • pjmlp a day ago

        You are expecting them to actually check the value, there is nothing preemptive.

        Another approach is special messages over a side channel.

    • nu11ptr a day ago

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

  • oefrha a day ago

    The thing about context is it can be a lot more than a cancellation mechanism. You can attach anything to it—metadata, database client, logger, whatever. Even Io and Allocator if you want to. Signatures are future-proof as long as you take a context for everything.

    At the end of the day you have to pass something for cooperative multitasking.

    Of course it’s also trivial to work around if you don’t like the pattern, “very discouraged” or not.

  • zer00eyz a day ago

    > If you’re working with goroutines, you would always pass in a context parameter to handle cancellation.

    The utility of context could be called a subtle coloring. But you do NOT need context at all. If your dealing with data+state (around queue and bus processing) its easy to throw things into a goroutine and let the chips fall where they will.

    > which poisons the rest of your functions. You are free to use context dependent functions without a real context: https://pkg.go.dev/context#TODO

osigurdson 19 hours ago

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

n42 a day ago

Aside 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 a day ago

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

    • delamon a day ago

      > Doing that is an instant performance hit. Not to mention annoying to do.

      The cost of virtual dispatch on IO path is almost always negligible. It is literally one conditional vs syscall. I doubt it you can even measure the difference.

    • bmurphy1976 a day ago

      >To me, there's no difference between the IO param and async/await.

      You can't pass around "async/await" as a value attached to another object. You can do that with the IO param. That is very different.

      • jolux a day ago

        > You can't pass around "async/await" as a value attached to another object

        Sure you can? You can just pass e.g. a Task around in C# without awaiting it, it's when you need a result from a task that you must await it.

        • [removed] a day ago
          [deleted]
      • watusername 8 hours ago

        Other commenters have already provided examples for other languages, and it's the same for Rust: async functions are just regular functions that return an impl Future type. As a sync function, you can call a bunch of async functions and return the futures to your caller to handle, or you can block your current thread with the block_on function typically available through a handle (similar to the Io object here) provided by your favorite async runtime [0].

        In other words, you don't need such an Io object upfront: You need it when you want to actually drive its execution and get the result. From this perspective, the Zig approach is actually less flexible than Rust.

        [0]: https://docs.rs/tokio/latest/tokio/runtime/struct.Handle.htm...

      • MrJohz a day ago

        Sure you can. An `async` function in Javascript is essentially a completely normal function that returns a promise. The `async`/`await` syntax is a convenient syntax sugar for working with promises, but the issue would still exist if it didn't exist.

        More to the point, the issue would still exist even if promises didn't exist — a lot of Node APIs originally used callbacks and a continuation-passing style approach to concurrency, and that had exactly the same issues.

      • dminik a day ago

        Conceptually, there's not much of a difference.

        If you have a sync/non-IO function that now needs to do IO, it becomes async/IO. And since IO and async are viral, it's callers must also now be IO/async and call it with IO/await. All the way up the call stack.

    • throwawaymaths a day ago

      > Adding either one causes it to not be callable from certain places.

      you can call a function that requires an io parameter from a function that doesn't have one by passing in a global io instance?

      as a trivial example the fn main entrypoint in zig will never take an io parameter... how do you suppose you'd bootstrap the io parameter that you'd eventually need. this is unlike other languages where main might or might not be async.

      • masklinn a day ago

        You can call an async function from a function that is not async by passing in a global runtime (/ event loop).

        As a trivial example the main entry point in rust is never async. How’d you suppose you’d bootstrap the runtime that you’d eventually need.

        This is pretty much like every other langage.

      • ginko a day ago

        >you can call a function that requires an io parameter from a function that doesn't have one by passing in a global io instance?

        How will that work with code mixing different Io implementations? Say a library pulled in uses a global Io instance while the calling code is using another.

        I guess this can just be shot down with "don't do that" but it feels like a new kind of pitfall get get into.

    • n42 a day ago

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

      • dminik a day ago

        It's funny, but I do actually like it. It's just that it walks like a duck, swims like a duck and quacks like a duck.

        I don't have a problem with IO conceptually (but I do have a problem with Zig ergonomics, allocator included). I do have a problem with claiming you defeated function coloring.

        Like, look. You didn't even get rid of await ...

        > try a_future.await(io);

      • rowanG077 a day ago

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

    • [removed] a day ago
      [deleted]
  • throwawaymaths 20 hours ago

    > 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

    that's not true. suppose a function foo(anytype) takes a struct, and expects method bar() on the struct.

    you could send foo() the struct type Sync whose bar() does not use io. or you could send foo() the struct type Async whose bar uses an io stashed in the parameter, and there would be no code changes.

    if you don't prefer compile time multireification, you can also use type erasure and accomplish the same thing with a vtable.

    • n42 14 hours ago

      It's hard to parse your comment, but I think we are agreeing? I was refuting the point of the parent. you have given another example of calling an IO-taking function inside a non-IO taking function. the example I gave was initializing an IO inside the non-IO taking function. you could also, as pointed out elsewhere, use global state.

cryptonector a day 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 a day ago

    Zig's not really about hiding things but you could put it in an options struct that has defaults unless overridden at compile time.

jaredklewis a day ago

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

  • dminik a day ago

    Yes, the main character of that article really is mostly JavaScript. The main issue there is that some things must be async, and that doesn't mesh well with things that can't be.

    If you're writing a game, and you need to render a new enemy, you might want to reduce performance by blocking rather than being shot by an invisible enemy because you can only load the model async.

    But even the article acknowledges that various languages tackle this problem better. Zig does a good job, but claiming it's been defeated completely doesn't really fly for me.

  • vips7L 17 hours ago

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

    Async as a keyword doesn’t solve this or make writing parallel code any easier. You can still mess this up even if every function is annotated as async.

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

    I think this is conflating properties of multiple runtimes. This is true in JavaScript because the runtime works on an event loop. In Java an “async” function that reads from a file or makes an http call doesn’t run in a different threads and doesn’t run in a different tick of an event loop. So what value does it have in that type of runtime?

    Personally for me I think “async” is putting pain on a lot of developers where 99% of all code is not parallel and doesn’t share memory.

  • ezst 21 hours ago

    I believe the point is less about "coloring" not having value as a type-system feature, and more about its bad ergonomics, and its viral nature in particular.

  • [removed] a day ago
    [deleted]
  • raincole a day ago

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

    It is. Function coloring is static typing.

    But people never ever agree on what to put in typing system. For example, Java's checked exceptions are a form of typing... and everyone hates them.

    Anyway it's always like that. Some people find async painful and say fuck it I'm going to manage threads manually. In the meanwhile another bunch of people work hard to introduce async to their language. Grass is always greener on the other side.

    • vips7L 17 hours ago

      > But people never ever agree on what to put in typing system. For example, Java's checked exceptions are a form of typing... and everyone hates them.

      I love checked exceptions. Checked errors are fantastic and I think most developers would agree they want errors to be in the type system, but Java as a language just hasn’t provided the language syntax to make them usable. They haven’t made it easy to “uncheck” when you can’t possibly handle an error. You have to write boilerplate:

          Something s;
          try {
              s = something();
          } catch (SomethingException e) {
              throw new RuntimeException(e);
          }
      
      
      It sucks when you face that situation a lot. In Swift this is really simple:

          var s = try! something();
      
      Java also hasn’t made them usable with lambdas even though both Scala [0] and Swift have shown it’s possible with a sufficiently strong type system:

          try {
              someCall(s -> {
                  try {
                      another(s);
                  } catch (CheckedException ex) {
                      throw new UncheckedException(ex);
                  }
              });
          } catch (UncheckedException ex) {
              // handle somehow
          }
      
      
      It sucks. I’m hopeful one day we’ll get something like try! or try? and better lambdas. Maybe once all resources stop being poured into Valhalla.

      [0] https://docs.scala-lang.org/scala3/reference/experimental/ca...

  • dwattttt a day ago

    > Isn't "coloring" just another form of static typing?

    In a very direct way. Another example in languages that don't like you ignoring errors, changing a function from infallible to fallible is a breaking change, a la "it's another colour".

    I'm glad it is: if a function I call can suddenly fail, at the very least I want to know that it can, even if the only thing I do is ignore it (visibly).

  • com2kid a day 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 a day ago

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

      I dunno man, have you seen people complain about async virality in Rust being annoying? Have you ever tried to read a backtrace from a program that does stackless coroutines (it's not fun)? Have you seen people do basically duplicate work to maintain a blocking and an async version of the same networking library?

      • pimeys 21 hours ago

        People do complain, like they do from things like systemd. Then there is us, the silent majority, who just get shit done with these tools.

        I respect what zig has done here, and I will want to try it out when it stabilizes. But Rust async is just fine.

      • com2kid 15 hours ago

        The alternative is to jump through a bunch of hoops to hide the "coloring" behind some opaque abstraction that is complicated and that will still get in the way when things go wrong.

        Everyone complained when async IO was done with callbacks, so sugar was added to the callbacks, and now everyone has spent over a decade complaining about what flavor of sugar tastes best.

        Y'all at Zig have a solution, I trust Zig's solution will be a good one (zig is lots of fun to use as a language) but at the end of the day, IO is slow, that needs to get hidden somehow, or not.

        Everyone should have to do embedded for awhile and setup their own DMA controller operations. Having async IO offloaded to an actual hardware block is... A different type of amusing.

flohofwoe a day 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 a day ago

    You can write a wrapper in other langs, too, e.g. in Rust: block_on(async_fn)

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

tcfhgj a day 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 a day ago

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

    • conradludgate a day ago

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

    • pimeys a day ago

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

    • [removed] 21 hours ago
      [deleted]
  • dminik a day ago

    Sure. Let's do an imaginary scenario. Let's say that you are the author of a http request library.

    Async hasn't been added yet, so you're using `std::net::TcpStream`.

    All is well until async comes along. Now, you have a problem. If you use async, your previous sync users won't be able to (easily) call your functions. You're looking at an API redesign.

    So, you swallow your pride and add an async variant of your functionality. Since Tokio is most popular, you use `tokio::net::TcpStream`.

    All is well, until a user comes in and says "Hey, I would like to use your library with smol (a different async runtime)". Now what do you do? Add a third variant of your code using `smol::net::TcpStream`? It's getting a bit ridiculous, and smol isn't the only alternative runtime.

    One solution is to do what Zig does, but there isn't really an agreed upon solution. The stdlib does not even provide AsyncRead/AsyncWrite so you could invert your code and just work with streams provided from above and keep your libary executor agnostic.

andyferris a day ago

I 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 a day 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 a day ago

      Passing 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 a day ago

        This is like saying arithmetic is a form of calculus, the simplest form. Ie it reduces the concept (DI) to a meaningless tautology.

nmilo a day ago

The 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 a day ago

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

nurettin a day ago

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