AndyKelley a day ago

Overall this article is accurate and well-researched. Thanks to Daroc Alden for due diligence. Here are a couple of minor corrections:

> When using an Io.Threaded instance, the async() function doesn't actually do anything asynchronously — it just runs the provided function right away.

While this is a legal implementation strategy, this is not what std.Io.Threaded does. By default, it will use a configurably sized thread pool to dispatch async tasks. It can, however, be statically initialized with init_single_threaded in which case it does have the behavior described in the article.

The only other issue I spotted is:

> For that use case, the Io interface provides a separate function, asyncConcurrent() that explicitly asks for the provided function to be run in parallel.

There was a brief moment where we had asyncConcurrent() but it has since been renamed more simply to concurrent().

  • setupminimal 18 hours ago

    Daroc here — I've gone ahead and applied two corrections to the article based on this comment. If you want to be sure that feedback or corrections reach us in the future (and not, as in this case, because I'm reading HN when I should be getting ready for bed), you're welcome to email lwn@lwn.net.

    Thanks for the corrections, and for your work on Zig!

  • landr0id a day ago

    Hey Andrew, question for you about something the article litely touches on but doesn't really discuss further:

    > If the programmer uses async() where they should have used asyncConcurrent(), that is a bug. Zig's new model does not (and cannot) prevent programmers from writing incorrect code, so there are still some subtleties to keep in mind when adapting existing Zig code to use the new interface.

    What class of bug occurs if the wrong function is called? Is it "UB" depending on the IO model provided, a logic issue, or something else?

    • AndyKelley a day ago

      A deadlock.

      For example, the function is called immediately, rather than being run in a separate thread, causing it to block forever on accept(), because the connect() is after the call to async().

      If concurrent() is used instead, the I/O implementation will spawn a new thread for the function, so that the accept() is handled by the new thread, or it will return error.ConcurrencyUnavailable.

      async() is infallible. concurrent() is fallible.

  • cryptonector 3 hours ago

    > > When using an Io.Threaded instance, the async() function doesn't actually do anything asynchronously — it just runs the provided function right away.

    > [...]

    Well, yeah, but even if you spin up a thread to run "the provided function right away" it still will only be for some value of "right away" that is not instantaneous. Creating a thread and getting it up and running is often an asynchronous operation -- it doesn't have to be, in that the OS can always simply transfer the caller's time quantum, on-CPU state, and priority to the new thread, taking the caller off the CPU if need be. APIs like POSIX just do not make that part of their semantics. Even if they did then the caller would be waiting to get back on CPU, so thread creation is fundamentally an async operation.

  • JoelJacobson 15 hours ago

    What a really like about concurrent(), is that it improves readability and expressiveness, making it clear when writing and reading that "this code MUST run in parallel".

woodruffw a day ago

I think this design is very reasonable. However, I find Zig's explanation of it pretty confusing: they've taken pains to emphasize that it solves the function coloring problem, which it doesn't: it pushes I/O into an effect type, which essentially behaves as a token that callers need to retain. This is a form of coloring, albeit one that's much more ergonomic.

(To my understanding this is pretty similar to how Go solves asynchronicity, expect that in Go's case the "token" is managed by the runtime.)

  • flohofwoe a day ago

    If calling the same function with a different argument would be considered 'function coloring', every function in a program is 'colored' and the word loses its meaning ;)

    Zig actually also had solved the coloring problem in the old and abandondend async-await solution because the compiler simply stamped out a sync- or async-version of the same function based on the calling context (this works because everything is a single compilation unit).

    • zarzavat 20 hours ago

      In that case JS is not colored either because an async function is simply a normal function that returns a Promise.

      As far as I understand, coloring refers to async and sync functions having the same calling syntax and interface, I.e.

          b = readFileAsync(p)
          b = readFileSync(p)
      
      share the same calling syntax. Whereas

          b = await readFileAsync(p)
          readFileAsync(p).then(b => ...)
          
          b = readFileSync(b)
      
      are different.

      If you have to call async functions with a different syntax or interface, then it's colored.

      • flohofwoe 11 hours ago

        > In that case JS is not colored either because an async function is simply a normal function that returns a Promise.

        Exactly, IMHO at least, JS doesn't suffer from the coloring problem because you can call async functions from sync functions (because the JS Promise machinery allows to fall back to completion callbacks instead of using await). It's the 'virality' of await which causes the coloring problem, but in JS you can freely mix await and completion callbacks for async operations).

        • wavemode 5 hours ago

          No, async and callbacks in JS are extremely viral. If a function returns a Promise or takes a callback, there is no possible way to execute it synchronously. Hence, coloring.

          The reason this coloring isn't a problem for the JS ecosystem, is that it's a single-threaded language by design. So, async/callbacks are the only reasonable way to do anything external to the JS runtime (i.e. reading files, connecting to APIs, etc.)

          (notwithstanding that node.js introduced some synchronous external operations in its stdlib - those are mostly unused in practice.)

          To put it a different way - yes, JS has function coloring, but it's not a big deal because almost the entire JS ecosystem is colored red anyway.

        • zarzavat 10 hours ago

          await isn't viral per se, it's a purely local transformation. The virality is from CPS/callbacks and Promise.

    • woodruffw a day ago

      > If calling the same function with a different argument would be considered 'function coloring', than every function in a program is 'colored' and the word loses its meaning ;)

      Well, yes, but in this case the colors (= effects) are actually important. The implications of passing an effect through a system are nontrivial, which is why some languages choose to promote that effect to syntax (Rust) and others choose to make it a latent invariant (Java, with runtime exceptions). Zig chooses another path not unlike Haskell's IO.

    • unscaled 13 hours ago

      Let's revisit the original article[1]. It was not about arguments, but about the pain of writing callbacks and even async/await compared to writing the same code in Go. It had 5 well-defined claims about languages with colored functions:

      1. Every function has a color.

      This is true for the new zig approach: functions that deal with IO are red, functions that do not need to deal with IO are blue.

      2. The way you call a function depends on its color.

      This is also true for Zig: Red functions require an Io argument. Blue functions do not. Calling a red function means you need to have an Io argument.

      3. You can only call a red function from within another red function.

      You cannot call a function that requires an Io object in Zig without having an Io in context.

      Yes, in theory you can use a global variable or initialize a new Io instance, but this is the same as the workarounds you can do for calling an async function from a non-async function For instance, in C# you can write 'Task.Run(() -> MyAsyncMethod()).Wait()'.

      4. Red functions are more painful to call.

      This is true in Zig again, since you have to pass down an Io instance.

      You might say this is not a big nuisance and almost all functions require some argument or another... But by this measure, async/await is even less troublesome. Compare calling an async function in Javascript to an Io-colored function in Zig:

        function foo() {
          blueFunction(); // We don't add anything
        }
      
        async function bar() {
          await redFunction(); // We just add "await"
        }
      
      And in Zig:

        fn foo() void {
          blueFunction()
        }
      
        fn bar(io: Io) void {
          redFunction(io); // We just add "io".
        }
      
      
      Zig is more troublesome since you don't just add a fixed keyword: you need a add a variable that is passed along through somewhere.

      5. Some core library functions are red.

      This is also true in Zig: Some core library functions require an Io instance.

      I'm not saying Zig has made the wrong choice here, but this is clearly not colorless I/O. And it's ok, since colorless I/O was always just hype.

      ---

      [1] https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...

      • flohofwoe 10 hours ago

        > This is also true for Zig: Red functions require an Io argument. Blue functions do not. Calling a red function means you need to have an Io argument.

        I don't think that's necessarily true. Like with allocators, it should be possible to pass the IO pointer into a library's init function once, and then use that pointer in any library function that needs to do IO. The Zig stdlib doesn't use that approach anymore for allocators, but not because of technical restrictions but for 'transparency' (it's immediately obvious which function allocates under the hood and which doesn't).

        Now the question is, does an IO parameter in a library's init function color the entire library, or only the init function? ;P

        PS: you could even store the IO pointer in a public global making it visible to all code that needs to do IO, which makes the coloring question even murkier. It will be interesting though how the not-yet-implemented stackless coroutine (e.g. 'code-transform-async') IO system will deal with such situations.

      • ghosty141 12 hours ago

        In my opinion you must have function coloring, it's impossible to do async (in the common sense) without it. If you break it down one function has a dependency on the async execution engine, the other one doesn't, and that alone colors them. Most languages just change the way that dependency is expressed and that can have impacts on the ergonomics.

        • spacechild1 4 hours ago

          Not necessarily! If you have a language with stackful coroutines and some scheduler, you can await promises anywhere in the call stack, as long as the top level function is executed as a coroutine.

          Take this hypothetical example in Lua:

            function getData()
              -- downloadFileAsync() yields back to the scheduler. When its work
              -- has finished, the calling function is resumed.
              local file = downloadFileAsync("http://foo.com/data.json"):await()
              local data = parseFile(file)
              return data
            end
          
            -- main function
            function main()
              -- main is suspended until getData() returns
              local data = getData()
              -- do something with it
            end
              
            -- run takes a function and runs it as a coroutine
            run(main)
          
          Note how none of the functions are colored in any way!

          For whatever reason, most modern languages decided to do async/await with stackless coroutines. I totally understand the reasoning for "system languages" like C++ (stackless coroutines are more efficient and can be optimized by the compiler), but why C#, Python and JS?

    • SkiFire13 a day ago

      > Zig actually also had solved the coloring problem in the old and abandondend async-await solution because the compiler simply stamped out a sync- or async-version of the same function based on the calling context (this works because everything is a single compilation unit).

      AFAIK this still leaked through function pointers, which were still sync or async (and this was not visible in their type)

      • throwawaymaths a day ago

        Pretty sure the Zig team is aware of this and has plans to fix it before they re-release async.

    • adamwk a day ago

      The subject of the function coloring article was callback APIs in Node, so an argument you need to pass to your IO functions is very much in the spirit of colored functions and has the same limitations.

      • jakelazaroff a day ago

        In Zig's case you pass the argument whether or not it's asynchronous, though. The caller controls the behavior, not the function being called.

        • layer8 a day ago

          The coloring is not the concrete argument (Io implementation) that is passed, but whether the function has an Io parameter in the first place. Whether the implementation of a function performs IO is in principle an implementation detail that can change in the future. A function that doesn't take an Io argument but wants to call another function that requires an Io argument can't. So you end up adding Io parameters just in case, and in turn require all callers to do the same. This is very much like function coloring.

          In a language with objects or closures (which Zig doesn't have first-class support for), one flexibility benefit of the Io object approach is that you can move it to object/closure creation and keep the function/method signature free from it. Still, you have to pass it somewhere.

          • messe a day ago

            > Whether the implementation of a function performs IO is in principle an implementation detail that can change in the future.

            I think that's where your perspective differs from Zig developers.

            Performing IO, in my opinion, is categorically not an implementation detail. In the same way that heap allocation is not an implementation detail in idiomatic Zig.

            I don't want to find out my math library is caching results on disk, or allocating megabytes to memoize. I want to know what functions I can use in a freestanding environment, or somewhere resource constrained.

            • Zambyte 14 hours ago

              > Performing IO, in my opinion, is categorically not an implementation detail. In the same way that heap allocation is not an implementation detail in idiomatic Zig.

              It seems you two are coming at this from opposing perspectives. From the perspective of a library author, Zig makes IO an implementation detail, which is great for portability. It lets library authors freely use IO abstractions if it makes sense for their problem.

              This lets you, as an application developer, decide the concrete details of how such libraries behave. Don't want your math library to cache to disk? Give it an allocating writer[0] instead of a file writer. Want to use an library with async functionality on an embedded system without multi threading? Pass it a single threaded io[1] runtime instance, implement the io interface yourself as is best for your target.

              Of course someone has to decide implementation details. The choices made in designing Zig tend to focus on giving library authors useful abstractions thst give application authors meaningful control over important decisions for their application.

              [0] https://ziglang.org/documentation/master/std/#std.Io.Writer....

              [1] https://ziglang.org/documentation/master/std/#std.Io.Threade...

            • simonask 21 hours ago

              This is also why function coloring is not a problem, and is in fact desirable a lot of the time.

              • hxtk 13 hours ago

                The problem with function coloring is that it makes libraries difficult to implement in a way that's compatible with both sync and async code.

                In Python, I needed to write both sync and async API clients for some HTTP thing where the logical operations were composed of several sequential HTTP requests, and doing so meant that I needed to implement the core business logic as a Generator that yields requests and accepts responses before ultimately returning the final result, and then wrote sync and async drivers that each ran the generator in a loop, pulling requests off, transacting them with their HTTP implementation, and feeding the responses back to the generator.

                This sans-IO approach, where the library separates business logic from IO and then either provides or asks the caller to implement their own simple event loop for performing IO in their chosen method and feeding it to the business logic state machine, has started to appear as a solution to function coloring in Rust, but it's somewhat of an obtuse way to support multiple IO concurrency strategies.

                On the other hand, I do find it an extremely useful pattern for testability, because it results in very fuzz-friendly business logic implementation, isolated side-effect code, and a very simple core IO loop without much room in it for bugs, so despite being somewhat of a pain to write I still find it desirable at times even when I only need to support one of the two function colors.

                • simonask 12 hours ago

                  My opinion is that if your library or function is doing IO, it should be async - there is no reason to support "sync I/O".

                  Also, this "sans IO" trend is interesting, but the code boils down to a less ergonomic, more verbose, and less efficient version of async (in Rust). It's async/await with more steps, and I would argue those steps are not great.

                  • wavemode 39 minutes ago

                    > there is no reason to support "sync I/O"

                    I disagree strongly.

                    From a performance perspective, asynchronous IO makes a lot of sense when you're dealing concurrently with a large number of tasks which each spend most of their time waiting for IO operations to complete. In this case, running those tasks in a single-threaded event loop is far more efficient than launching off thousands of individual threads.

                    However, if your application falls into literally any other category, then suddenly you are actually paying a performance penalty, since you need the overhead of running an event loop any time you just want to perform some IO.

                    Also, from a correctness perspective, non-concurrent code is simply a lot less complex and a lot harder to get wrong than concurrent code. So applications which don't need async also end up paying a maintainability, and in some cases memory safety / thread safety, penalty as well.

              • zarzavat 14 hours ago

                Exactly, there is nothing wrong with function coloring. It's a design choice.

                Colored functions are easier to reason about, because potential asynchronicity is loudly marked.

                Colorless functions are more flexible because changing a function to be async doesn't virally break its interface and the interface of all its callers.

                Zig has colored functions, and that's just fine. The problem is the (unintentional) gaslighting where we are told that Zig is colorless when the functions clearly have colors.

                • gf000 11 hours ago

                  As mentioned, the problem with coloring is not that you see the color, the problem is that you can't abstract over the colors.

                  Effectful languages basically add user-definable "colors", but they let you write e.g. a `map` function that itself turns color based on its parameter (e.g. becoming async if an async function is passed).

            • amluto 17 hours ago

              > I don't want to find out my math library is caching results on disk, or allocating megabytes to memoize. I want to know what functions I can use in a freestanding environment, or somewhere resource constrained.

              On that vein, I would often like to know whether the function I can is creating a task/thread/greenlet/whatever that will continue executing, concurrently, after it returns. Making that be part of the signature is approximately called “structured concurrency”, and Zig’s design seems to conflate that with taking an io parameter. This seems a bit disappointing to me.

          • derriz a day ago

            > A function that doesn't take an Io argument but wants to call another function that requires an Io argument can't.

            Why? Can’t you just create an instance of an Io of whatever flavor you prefer and use that? Or keep one around for use repeatedly?

            The whole “hide a global event loop behind language syntax” is an example of a leaky abstraction which is also restrictive. The approach here is explicit and doesn’t bind functions to hidden global state.

            • layer8 a day ago

              You can, but then you’re denying your callers control over the Io. It’s not really different with async function coloring: https://news.ycombinator.com/item?id=46126310

              Scheduling of IO operations isn’t hidden global state. Or if it is, then so is thread scheduling by the OS.

          • quantummagic a day ago

            Is that a problem in practice though? Zig already has this same situation with its memory allocators; you can't allocate memory unless you take a parameter. Now you'll just have to take a memory allocator AND an additional io object. Doesn't sound very ergonomic to me, but if all Zig code conforms to this scheme, in practice there will only-one-way-to-do-it. So one of the colors will never be needed, or used.

    • jcranmer a day ago

      > If calling the same function with a different argument would be considered 'function coloring', than every function in a program is 'colored' and the word loses its meaning ;)

      I mean, the concept of "function coloring" in the first place is itself an artificial distinction invented to complain about the incongruent methods of dealing with "do I/O immediately" versus "tell me when the I/O is done"--two methods of I/O that are so very different that it really requires very different designs of your application on top of those I/O methods: in a sync I/O case, I'm going to design my parser to output a DOM because there's little benefit to not doing so; in an async I/O case, I'm instead going to have a streaming API.

      I'm still somewhat surprised that "function coloring" has become the default lens to understand the semantics of async, because it's a rather big misdirection from the fundamental tradeoffs of different implementation designs.

      • omnicognate 21 hours ago

        100% agree, but fortunately I don't think it is the "default lens". If it were nobody would be adding new async mechanisms to languages, because "what color is your function" was a self-described rant against async, in favour of lightweight threads. It does seem to have established itself as an unusually persistent meme, though.

      • conradev 16 hours ago

        My understanding of this design is that you can write the logic separately from the decision to "do I/O immediately" versus "tell me when the I/O is done"

        You can write a parser thats outputs a DOM and run it on a stream, or write a parser with a streaming API and run it synchronously on a buffer. You should pick the optimal tool for the situation, but there is no path dependence anymore.

        • thinkharderdev 8 hours ago

          Honestly I don't see how that is different than how it works in Rust. Synchronous code is a proper subset of asynchronous code. If you have a streaming API then you can have an implementation that works in a synchronous way with no overhead if you want. For example, if you already have the whole buffer in memory sometimes then you can just use it and the stream will work exactly like a loop that you would write in the sync version.

      • zelphirkalt 20 hours ago

        Function coloring is the issue, that arises in practice, which is why people discuss, whether some approach solves it or does not.

        Why do you think it automatically follows, that with an async I/O you are going to have a streaming API? An async I/O can just like the sync I/O return a whole complete result, only that you are not waiting for that to happen, but the called async procedure will call you back once the result is calculated. I think a streaming API requires additional implementation effort, not merely async.

    • rowanG077 a day ago

      If your functions suddenly requires (currently)unconstructable instance "Magic" which you now have to pass in from somewhere top level, that indeed suffers from the same issue as async/await. Aka function coloring.

      But most functions don't. They require some POD or float, string or whatever that can be easily and cheaply constructed in place.

      • mk12 19 hours ago

        Colors for 2 ways of doing IO vs colors for doing IO or not are so different that it’s confusing to call both of them “function coloring problem”. Only the former leads to having to duplicate everything (sync version and async version). If only the latter was a thing, no one would have coined the term and written the blog post.

        • rowanG077 19 hours ago

          IMO the problem was never about it actually doing IO or an async actions or whatever. It's about not being able to a call a async function from a sync function. Because in my experience you almost never wholesale move from sync to async everywhere. In fact I would consider that an extremely dangerous practice.

  • throwawaymaths a day ago

    1) zig's io is not a viral effect type, you can in principle declare a global io variable and use it everywhere that any library calls for it. Not best practice for a library writer, but if you're building an app, do what you want.

    2) There are two things here, there is function coloring and the function coloring problem. The function coloring problem is five things:

    https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...

    1. Every function has a color.

    2. The way you call a function depends on its color.

    3. You can only call a red function from within another red function.

    4. Red functions are more painful to call.

    5. Some core library functions are red.

    You'll have some convincing to do that zig's plan satisfies 4. It's almost certain that it won't satisfy 5.

    It's open to debate if zig's plan will work at all, of course.

    • woodruffw 21 hours ago

      > 1) zig's io is not an effect type, you can in principle declare a global io variable and use it everywhere that any library calls for it.

      That's an effect, akin to globally intermediated I/O in a managed runtime.

      To make it intuitive: if you have a global token for I/O, does your concurrent program need to synchronize on it in order to operate soundly? Do programs that fail to obtain the token behave correctly?

      • throwawaymaths 21 hours ago

        how do you "fail to obtain the token"?

        • woodruffw 21 hours ago

          The token guards a fallible resource (I/O). You can (temporarily or permanently) fail to obtain it for any reason that would affect the underlying I/O.

          • throwawaymaths 18 hours ago

            the io isnt a single resource? it's a module grouping together a pile of code. and you can swap out implementations. the io modules should be responsible for handing out many failable resources, and synchronization is going to be up to the io module code, and thats whether or not it's globalized or passed.

  • jayd16 a day ago

    Actually it seems like they just colored everything async and you pick whether you have worker threads or not.

    I do wonder if there's more magic to it than that because it's not like that isn't trivially possible in other languages. The issue is it's actually a huge foot gun when you mix things like this.

    For example your code can run fine synchronously but will deadlock asynchronously because you don't account for methods running in parallel.

    Or said another way, some code is thread safe and some code isn't. Coloring actually helps with that.

    • flohofwoe a day ago

      > Actually it seems like they just colored everything async and you pick whether you have worker threads or not.

      There is no 'async' anywhere yet in the new Zig IO system (in the sense of the compiler doing the 'state machine code transform' on async functions).

      AFAIK the current IO runtimes simply use traditional threads or coroutines with stack switching. Bringing code-transform-async-await back is still on the todo-list.

      The basic idea is that the code which calls into IO interface doesn't need to know how the IO runtime implements concurrency. I guess though that the function that's called through the `.async()` wrapper is expected to work properly both in multi- and single-threaded contexts.

      • jayd16 a day ago

        > There is no 'async'

        I meant this more as simply an analogy to the devX of other languages.

        >Bringing code-transform-async-await back is still on the todo-list.

        The article makes it seem like "the plan is set" so I do wonder what that Todo looks like. Is this simply the plan for async IO?

        > is expected to work properly both in multi- and single-threaded contexts.

        Yeah... about that....

        I'm also interested in how that will be solved. RTFM? I suppose a convention could be that your public API must be thread safe and if you have a thread-unsafe pattern it must be private? Maybe something else is planned?

        • messe a day ago

          > The article makes it seem like "the plan is set" so I do wonder what that Todo looks like. Is this simply the plan for async IO?

          There's currently a proposal for stackless coroutines as a language primitive: https://github.com/ziglang/zig/issues/23446

  • esjeon 5 hours ago

    AFACT, the only practically critical issue of colored function is the duplication of code b/w sync and async code paths. Zig avoids this with dependency injection, and that’s enough for practical usages (which basically means “ergonomic”). Other points raised by the original article (like calling async function is more difficult) are pretty much unavoidable for the sake of precise control.

  • doyougnu a day ago

    Agreed. the Haskeller in me screams "You've just implemented the IO monad without language support".

    • AndyKelley a day ago

      It's not a monad because it doesn't return a description of how to carry out I/O that is performed by a separate system; it does the I/O inside the function before returning. That's a regular old interface, not a monad.

      • tome 6 hours ago

        > 1. a description of how to carry out I/O that is performed by a separate system

        > 2. does the I/O inside the function before returning

        How do you distinguish those two things? To put my cards on the table, I believe Haskell does 2, and I think my Haskell effect system Bluefin makes this abundantly clear. (Zig's `Io` seems to correspond to Bluefin's `IOE`.)

        There is a persistent myth in the Haskell world (and beyond) that Haskell does 1. In fact I think it's hard to make it a true meaningful statement, but I can probably just about concede it is with a lot of leeway on what it means for I/O to be "performed by a separate system", and even then only in a way that it's also true and meaningful for every other language with a run time system (which is basically all of them).

        The need to believe that Haskell does 1 comes from the insistence that Haskell be considered a "pure" language, and the inference that means it doesn't do I/O, and therefore the need that "something else" must do I/O. I just prefer not to call Haskell a "pure" language. Instead I call it "referentially transparent", and the problem vanishes. In Haskell program like

            main :: IO ()
            main = do
               foo
               foo
        
            foo :: IO ()
            foo = putStrLn "Hello"
        
        I would say that "I/O is done inside `foo` before returning". Simple. No mysteries or contradiction.

        https://hackage-content.haskell.org/package/bluefin/docs/Blu...

        • erooke 3 hours ago

          I'm also pretty sure that its immaterial if Haskell does 1 or not. This is an implementation detail and not at all important to something being a Monad or not.

          My understanding is requiring 1 essentially forces you to think of every Monad as being free.

      • endgame a day ago

        So it's the reader monad, then? ;-)

        • tylerhou a day ago

          Yes.

          • AndyKelley 20 hours ago

            Can you explain for those of us less familiar with Haskell (and monads in general)?

            • themk 17 hours ago

              A Monad is a _super_ generic interface that can be implemented for a whole bunch of structures/types. When people talk about "monads", they are usually referring to a specific instance. In this case, the Reader monad is a specific instance that is roughly equivalent to functions that take an argument of a particular type and return a result of any type. That is, any function that looks like this (r -> a) where `r` is fixed to some type, and `a` can be anything.

              Functions of that form can actually implement the Monad interface, and can make use of Haskells syntax support for them.

              One common use-case for the reader monad pattern is to ship around an interface type (say, a struct with a bunch of functions or other data in it). So, what people are saying here is that passing around a the `Io` type as a function argument is just the "reader monad" pattern in Haskell.

              And, if you hand-wave a bit, this is actually how Haskell's IO is implemented. There is a RealWorld type, which with a bit of hand waving, seems to pretty much be your `Io` type.

              Now, the details of passing around that RealWorld type is hidden in Haskell behind the IO type, So, you don't see the `RealWorld` argument passed into the `putStrLn` function. Instead, the `putStrLn` function is of type `String -> IO ()`. But you can, think of `IO ()` as being equivalent to `RealWorld -> ()`, and if you substitute that in you see the `String -> RealWorld -> ()` type that is similar to how it appears you are doing it in Zig.

              So, you can see that Zig's Io type is not the reader monad, but the pattern of having functions take it as an argument is.

              Hopefully that helps.

              ---

              Due to Haskell's laziness, IO isn't actually the reader monad, but actually more closely related to the state monad, but in a strict language that wouldn't be required.

            • itishappy 17 hours ago

              A reader is just an interface that allows you to build up a computation that will eventually take an environment as a parameter and return a value.

              Here's the magic:

                  newtype Reader env a = Reader { runReader :: env -> a }
                  
                  ask = Reader $ \x -> x
                  
                  instance Functor (Reader env) where
                    fmap f (Reader g) = Reader $ \x -> f (g x)
                  
                  instance Applicative (Reader env) where
                    pure x = Reader (\_ -> x)
                    ff <*> fx = Reader $ \x -> (runReader ff x) (runReader fx x)
                  
                  instance Monad (Reader env) where
                    (Reader f) >>= g = Reader $ \x -> runReader (g (f x)) x
              
              That Monad instance might be the scariest bit if you're unfamiliar with Haskell. The (>>=) function takes a Monad (here a Reader) and a continuation to call on it's contents. It then threads the environment through both.

              Might be used like this:

                  calc :: Reader String Int
                  calc = do
                    input <- ask
                    pure $ length input
                  
                  test :: Int
                  test = runReader calc "Test"
                  -- returns: 4
              
              Not sure how this compares to Zig!

              https://stackoverflow.com/questions/14178889/what-is-the-pur...

              Edit: Added Applicative instance so code runs on modern Haskell. Please critique! Also added example.

              • itishappy 15 hours ago

                Here's a minimal python translation of the important bits:

                    class Reader:
                        def __init__(self, func):
                            self.run = func
                        def pure(x):
                            return Reader(lambda _: x)
                        def bind(self, f):
                            return Reader(lambda env: f(self.run(env)).run(env))
                
                    ask = Reader(lambda env: env)
                
                    def calc():
                        return ask.bind(lambda input_str:
                            Reader.pure(len(input_str)))
                
                    test = calc().run("test")
                    print(test)
                
                Admittedly this is a bit unwieldy in Python. Haskell's `do` notation desugars to repeated binds (and therefore requires something to be a Monad), and does a lot of handiwork.

                    -- this:
                    calc :: Reader String Int
                    calc = do
                      input <- ask
                      pure $ length input
                
                    -- translates to:
                    calc' :: Reader String Int
                    calc' = ask >>= (\input -> pure $ length input)
            • throwaway17_17 18 hours ago

              I see I’ve been beaten to the punch, but I’ll post my try anyway.

              Your comment about IO handled by an external system In response to a comment about the more general concept of a monad is what they are, somewhat abruptly referring to in the above two comments.

              The IO monad in Haskell is somewhat ‘magical’ in that it encapsulates a particular monad instance that encodes computational actions which Haskell defers to an external system to execute. Haskell chose to encode this using a monadic structure.

              To be a bit more particular:

              The Reader monad is the Haskell Monad instance for what can generically be called an ‘environment’ monad. It is the pattern of using monadic structure to encapsulate the idea of a calling context and then taking functions that do not take a Context variable and using the encapsulating Monad to provide the context for usage within that function that needs it.

              Based on your streams in the new system I don’t see a monad, mostly because the Reader instance would basically pipe the IO parameter through functions for you and Zig requires explicit passage of the IO (unless you set a global variable as IO but that’s not a monad, that’s just global state) to each function that uses it.

              From my perspective Zig’s IO looks to be more akin to a passed effect token outside the type system ‘proper’ that remains compile time checked by special case.

            • dan-robertson 19 hours ago

              Reader monad is a fancy way of saying ‘have the ability to read some constant value throughout the computation’. So here they mean the io value that is passed between functions.

              • AndyKelley 18 hours ago

                Well I don't think that fits at all. In Zig, an Io instance is an interface, passed as a parameter. You can draw some connections between what Zig is doing and what Haskell is doing but it's not a monad. It's plain old interfaces and parameters, just like Allocator.

                • themk 16 hours ago

                  Passing an interface as a parameter is a monad. (Io -> _) is an instance of Monad in Haskell.

                  Haskell just has syntax to make using (any) monad much nicer. In this case, it let's you elide the `Io` parameter in the syntax if you are just going to be passing the same Io to a bunch of other functions. But it still is there.

                  • doyougnu 6 hours ago

                    Couldn't have said it better myself. But IIUC Andrew stated that its not a monad because it does not build up a computation and then run. Rather, its as if every function runs a `runIO#` or `runReader` every time the io parameter is used.

                    • tome 6 hours ago

                      Is it necessary that a monad "builds up a computation and then runs"? In fact it's very hard for a monad to do that because the type of bind is

                          (>>=) :: m a -> (a -> m b) -> m b
                      
                      so you can really only make progress if you first build a bit (`m a`), then run it (to get `a`) then build the next bit (applying `a` to `a -> m b`), then run that. So "building" and "running" must necessarily be interleaved. It's an odd myth that "Haskell's IO purely builds an impure computation to run".
                      • AndyKelley an hour ago

                        Are you saying "monad" is a synonym of "interface"?

                        • tome 39 minutes ago

                          Not a synonym, but `Monad` is one of the commonly used interfaces in Haskell (not the only one).

            • _jackdk_ 18 hours ago

              Let's see if I can do it without going too far off the deep end. I think your description of the _IO type_ as "a description of how to carry out I/O that is performed by a separate system" is quite fair. But that is a property of the IO type, not of monads. A monad in programming is often thought of as a type constructor M (that takes and returns a type), along with some functions that satisfy certain conditions (called the "monad laws").

              The `IO` type is a type constructor of one argument (a type), and returns a type: we say that it has kind `Type -> Type`, using the word "kind" to mean something like "the 'type' of a type". (I would also think of the Zig function `std.ArrayList` as a type constructor, in case that's correct and useful to you.) `IO String` is the type of a potentially side-effecting computation that produces a `String`, which can be fed to other `IO`-using functions. `readLine` is an example of a value that has this type.

              The Haskell function arrow `(->)` is also a type constructor, but of two arguments. If you provide `(->)` with two types `a` and `b`, you get the type of functions from `a` to `b`:

              `(->)` has kind `Type -> Type -> Type`.

              `(->) Char` has kind `Type -> Type`.

              `(->) Char Bool` has kind `Type`. It is more often written `Char -> Bool`. `isUpper` is an example of a value that has this type.

              The partially-applied type constructor `(->) r`, read as the "type constructor for functions that accept `r`", is of the same kind as `IO`: `Type -> Type`. It also turns out that you can implement the functions required by the monad interface for `(->) r` in a way that satisfies the necessary conditions to call it a monad, and this is often called the "reader monad". Using the monad interface with this type constructor results in code that "automatically" passes a value to the first argument of functions being used in the computation. This sometimes gets used to pass around a configuration structure between a number of functions, without having to write that plumbing by hand. Using the monad interface with the `IO` type results in the construction of larger side-effecting computations. There are many other monads, and the payoff of naming the "monad" concept in a language like Haskell is that you can write functions which work over values in _any_ monad, regardless of which specific one it is.

              I tried to keep this brief-ish but I wasn't sure which parts needed explanation, and I didn't want to pull on all the threads and make a giant essay that nobody will read. I hope it's useful to you. If you want clarification, please let me know.

              • throwaway17_17 18 hours ago

                This is pretty concise, but is still really technical. That aside, I think the actual bone of contention is that Zig’s IO is not a Reader-esque structure. The talks and articles I’ve read indicate that function needing the IO ‘context’ must be passed said context as an argument. Excepting using a global variable to make it available everywhere, but as I said in a sibling comment, that’s just global state not a monad.

                In a manner of speaking, Zig created the IO monad without the monad (which is basically just an effect token disconnected from the type system). Zig’s new mechanism take a large chunk of ‘side-effects’ and encapsulates them in a distinct and unique interface. This allows for a similar segregation of ‘pure’ and ‘side-effecting’ computations that logically unlined Haskell’s usage of IO. Zig however lacks the language/type system level support for syntactically and semantically using IO as an inescapable Monad instance. So, while the side effects are segregated via the IO parameter ‘token’ requirement they are still computed as with all Zig code. Finally, because Zig’s IO is not a special case of Monad there is no restriction on taking IO requiring results of a function and using them as ‘pure’ values.

                • _jackdk_ 15 hours ago

                  A series of functions all passing the same `io: IO` value around exhibit exactly the behavior of the reader monad.

            • justinhj 4 hours ago

              Reader monads have been used to implement dependency injection in Haskell and Scala libraries. A monad in general is the ability to compose two functions that have pure arguments and return values that encode some effect... in this case the effect is simply to pass along some read only environment.

              Based on my understanding of above, passing an environment as a parameter is not the Reader monad, in fact passing the parameter explicitly through chains of function calls is what the Reader monad intends to avoid in typed, pure functional programming.

    • throwawaymaths 18 hours ago

      i mean not really? it absolutely does nothing to segregate stateful impurity into a type theoretically stateless token

  • SkiFire13 a day ago

    The function coloring problem actually comes up when you implement the async part using stackless coroutines (e.g. in Rust) or callbacks (e.g. in Javascript).

    Zig's new I/O does neither of those for now, so hence why it doesn't suffer from it, but at the same time it didn't "solve" the problem, it just sidestepped it by providing an implementation that has similar features but not exactly the same tradeoffs.

    • bloppe a day ago

      How are the tradeoffs meaningfully different? Imagine that, instead of passing an `Io` object around, you just had to add an `async` keyword to the function, and that was simply syntactic sugar for an implied `Io` argument, and you could use an `await` keyword as syntactic sugar to pass whatever `Io` object the caller has to the callee.

      I don't see how that's not the exact same situation.

      • bevr1337 a day ago

        In the JS example, a synchronous function cannot poll the result of a Promise. This is meaningfully different when implementing loops and streams. Ex, game loop, an animation frame, polling a stream.

        A great example is React Suspense. To suspend a component, the render function throws a Promise. To trigger a parent Error Boundary, the render function throws an error. To resume a component, the render function returns a result. React never made the suspense API public because it's a footgun.

        If a JS Promise were inspectable, a synchronous render function could poll its result, and suspended components would not need to use throw to try and extend the language.

        • int_19h 21 hours ago

          .NET has promises that you can poll synchronously. The problem with them is that if you have a single thread, then by definition while your synchronous code is running, none of the async callbacks can be running. So if you poll a Task and it's not complete yet, there's nothing you can do to wait for its completion.

          Well, technically you can run a nested event loop, I guess. But that's such a heavy sync-wrapping-async solution that it's rarely used other than as a temporary hack in legacy code.

        • bloppe a day ago

          I see. I guess JS is the only language with the coloring problem, then, which is strange because it's one of the few with a built-in event loop.

          This Io business is isomorphic to async/await in Rust or Python [1]. Go also has a built-in "event loop"-type thing, but decidedly does not have a coloring problem. I can't think of any languages besides JS that do.

          [1]: https://news.ycombinator.com/item?id=46126310

          • unbrice a day ago

            > Go also has a built-in "event loop"-type thing, but decidedly does not have a coloring problem.

            context is kind of a function color in go, and it's also a function argument.

      • mk12 18 hours ago

        It’s not the same situation because with async/await you end up with two versions of every function or library (see Rust’s std and crates like async_std, Node’s readFile and readFileSync). In Zig you always pass the “io” parameter to do I/O and you don’t have to duplicate everything.

      • VMG a day ago

        Maybe I have this wrong, but I believe the difference is that you can create an Io instance in a function that has none

        • bloppe a day ago

          In Rust, you can always create a new tokio runtime and use that to call an async function from a sync function. Ditto with Python: just create a new asyncio event loop and call `run`. That's actually exactly what an Io object in Zig is, but with a new name.

          Looking back at the original function coloring post [1], it says:

          > It is better. I will take async-await over bare callbacks or futures any day of the week. But we’re lying to ourselves if we think all of our troubles are gone. As soon as you start trying to write higher-order functions, or reuse code, you’re right back to realizing color is still there, bleeding all over your codebase.

          So if this is isomorphic to async/await, it does not "solve" the coloring problem as originally stated, but I'm starting to think it's not much of a problem at all. Some functions just have different signatures from other functions. It was only a huge problem for JavaScript because the ecosystem at large decided to change the type signatures of some giant portion of all functions at once, migrating from callbacks to async.

          [1]: https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...

    • zamalek a day ago

      It's sans-io at the language level, I like the concept.

      So I did a bit of research into how this works in Zig under the hood, in terms of compilation.

      First things first, Zig does compile async fns to a state machine: https://github.com/ziglang/zig/issues/23446

      The compiler decides at compile time which color to compile the function as (potentially both). That's a neat idea, but... https://github.com/ziglang/zig/issues/23367

      > It would be checked illegal behavior to make an indirect call through a pointer to a restricted function type when the value of that pointer is not in the set of possible callees that were analyzed during compilation.

      That's... a pretty nasty trade-off. Object safety in Rust is really annoying for async, and this smells a lot like it. The main difference is that it's vaguely late-bound in a magical way; you might get an unexpected runtime error and - even worse - potentially not have the tools to force the compiler to add a fn to the set of callees.

      I still think sans-io at the language level might be the future, but this isn't a complete solution. Maybe we should be simply compiling all fns to state machines (with the Rust polling implementation detail, a sans-io interface could be used to make such functions trivially sync - just do the syscall and return a completed future).

      • matu3ba 21 hours ago

        > I still think sans-io at the language level might be the future, but this isn't a complete solution. Maybe we should be simply compiling all fns to state machines (with the Rust polling implementation detail, a sans-io interface could be used to make such functions trivially sync - just do the syscall and return a completed future).

        Can you be more specific what is missing in sans-io with explicit state machine for static and dynamic analysis would not be a complete solution? Serializing the state machine sounds excellent for static and dynamic analysis. I'd guess the debugging infrastructure for optimization passes and run-time debugging are missing or is there more?

        • zamalek 21 hours ago

          Exactly the caveat that they themselves disclose: some scenarios are too dynamic for static analysis.

      • algesten a day ago

        I wouldn't define it as Sans-IO if you take an IO argument and block/wait on reading/writing, whether that be via threads or an event loop.

        Sans-IO the IO is _outside_ completely. No read/write at all.

        • zamalek 21 hours ago

          Oof, you're completely right. I'm not sure where I got that wire crossed.

  • dan-robertson 18 hours ago

    This solves a problem for library authors which is that blocking and event-based io implementations of functionality look the same but are not actually the same so users end up complaining when you do one but not the other.

    It adds a problem of needing to pass the global kind of io through a program. I think this mostly isn’t a huge problem because typical good program design has io on the periphery and so you don’t tend to need to pass this io object that ‘deep’. This is not too different from the type-system effect of IO in Haskell (except that one only does evented IO IIRC). It isn’t as bad because it only affects input types (data which can be closed over, I assume) rather than output types. Eg in Haskell you need various special functions to change from [ IO a ] to IO [ a ] but in the zig model you iterate over your list in the normal way using an io value from an outer scope.

    The one case where Io-colouring was annoying to me in Haskell was adding printf debugging (there is a function to cheat the type system for this). Zig may have other solutions to that, eg a global io value for blocking io in debug builds or some global logging system.

    • themk 17 hours ago

      There is nothing special about the [IO a] -> IO [a] in Haskell. You can iterate over it using the "normal" methods of iterating just fine.

          forM ios $ \io -> io
      
      But there are better ways to do it (e.g. sequence), but those are also not "special" to IO in any way. They are common abstractions usable by any Monad.
      • dan-robertson 5 hours ago

        Haskell is a bit tricky to talk about here because it has other big differences on laziness and suchlike, and this means there are pervasive monads. If you instead consider a language more like JavaScript where async functions return values wrapped in promises and therefore require special versions of lots of things like Array.prototype.forEach for async-returning functions (ok, language feature of generators helps here). The point I’m trying to get at is that putting io-ness into an argument works better than putting it into the return type because it is easier to pass an extra argument when using other language features an harder to do an extra thing with returned values.

  • dundarious a day ago

    There is a token you must pass around, sure, but because you use the same token for both async and sync code, I think analogizing with the typical async function color problem is incorrect.

  • eikenberry a day ago

    Function coloring is specifically about requiring syntax for a function, eg. the async keyword. So if you want an async and non-async function you need to write both in code. If you pass the "coloring" as an argument you avoid the need for extra syntax and multiple function definitions and therefor the function has no color. You can solve this in various ways with various tradeoffs but as long as there is a single function (syntactically) is all that matters for coloring.

    • woodruffw a day ago

      > Function coloring is specifically about requiring syntax for a function, eg. the async keyword.

      Someone should tell the inventor of the phrase, because they don't mention the async keyword at all[1]. As-written, function coloring is about callbacks (since that's semantic mechanism that JavaScript happens to pick for their asynchronous model).

      Function coloring is just an informal way to describe encoding a function's effect. You can encode that in syntax if you want (an `async` keyword), or in the type system (returning `() -> T` instead of `T`), or in the runtime itself (by controlling all I/O and treating it the same). But you can't avoid it.

      [1]: https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...

      • eikenberry 20 hours ago

        They specifically called it out as a syntactical issue, where the issue was based around the requirement to have the 'red' or 'blue' keyword. The section on "2. The way you call a function depends on its color." makes this pretty explicit...

            2. The way you call a function depends on its color.
        
            Imagine a “blue call” syntax and a “red call” syntax. Something like:
        
            doSomethingAzure()blue;
            doSomethingCarnelian()red;
        
            When calling a function, you need to use the call that corresponds to its color.
        • woodruffw 18 hours ago

          I don’t think so? The implication is that it’s a callback, which of course is going to require another call to realize the evaluation. But it’s not inherently another keyword; the keyword is just sugar for deferred evaluation.

    • IshKebab a day ago

      > Function coloring is specifically about requiring syntax for a function, eg. the async keyword.

      It isn't really. It's about having two classes of functions (async and sync), and not being able to await async functions from sync ones.

      It was originally about Javascript, where it is the case due to how the runtime works. In a sync function you can technically call an async one, but it returns a promise. There's no way to get the actual result before you return from your sync function.

      That isn't the case for all languages though. E.g. in Rust: https://docs.rs/futures/latest/futures/executor/fn.block_on....

      I think maybe Python can do something similar but don't quote me on that.

      There's a closely related problem about making functions generic over synchronicity, which people try and solve with effects, monads, etc. Maybe people call that "function colouring" now, but that wasn't exactly the original meaning.

  • rowanG077 a day ago

    Having used zig a bit as a hobby. Why is it more ergonomic? Using await vs passing a token have similar ergonomics to me. The one thing you could say is that using some kind of token makes it dead simple to have different tokens. But that's really not something I run into often at all when using async.

    • messe a day ago

      > The one thing you could say is that using some kind of token makes it dead simple to have different tokens. But that's really not something I run into often at all when using async.

      It's valuable to library authors who can now write code that's agnostic of the users' choice of runtime, while still being able to express that asynchronicity is possible for certain code paths.

      • rowanG077 a day ago

        But that can already be done using async await. If you write an async function in Rust for example you are free to call it with any async runtime you want.

        • messe a day ago

          But you can't call it from synchronous rust. Zig is moving toward all sync code also using the Io interface.

          • tcfhgj a day ago

            yes, you can:

                runtime.block_on(async { })
            
            https://play.rust-lang.org/?version=stable&mode=debug&editio...
            • messe a day ago

              Let me rephrase, you can't call it like any other function.

              In Zig, a function that does IO can be called the same way whether or not it performs async operations or not. And if those async operations don't need concurrency (which Zig expresses separately to asynchronicity), then they'll run equally well on a sync Io runtime.

              • tcfhgj a day ago

                > In Zig, a function that does IO can be called the same way whether or not it performs async operations or not.

                no, you can't, you need to pass a IO parameter

                • messe a day ago

                  You will need to pass that for synchronous IO as well. All IO in the standard library is moving to the Io interface. Sync and async.

                  If I want to call a function that does asynchronous IO, I'll use:

                     foo(io, ...);
                  
                  If I want to call one that does synchronous IO, I'll write:

                      foo(io, ...);
                  
                  If I want to express that either one of the above can be run asynchronously if possible, I'll write:

                      io.async(foo, .{ io, ... });
                  
                  If I want to express that it must be run concurrently, then I'll write:

                      try io.concurrent(foo, .{ io, ... });
                  
                  Nowhere in the above do I distinguish whether or not foo does synchronous or asynchronous IO. I only mark that it does IO, by passing in a parameter of type std.Io.
                  • tcfhgj a day ago

                    what about non-io code?

                    • messe a day ago

                      What about it? It gets called without an Io parameter. Same way that a function that doesn't allocate doesn't get an allocator.

                      I feel like you're trying to set me up for a gotcha "see, zig does color functions because it distinguishes functions that do io and those that don't!".

                      And yes, that's true. Zig, at least Zig code using std, will mark functions that do Io with an Io parameter. But surely you can see how that will lead to less of a split in the ecosystem compared to sync and async rust?

                      • torginus a day ago

                        This creates the drill-down issue we see with React props where we have to pass objects around in the call chain just so that somewhere down the line we can use it.

                        React gets around this with the context hook and which you can access implicitly if it has been injected at a higher level.

                        Do you know if Zig supports something of the sort?

                        • throwaway17_17 18 hours ago

                          I think (and I’m not a Zig user at anything above a hobbyist level) based on what the developers have discussed publically:

                          React has a ‘roughly’ functional slant to the way it does things and so needs to provide a special case ‘hook’ for a certain type of context object. Zig however is an imperative language that allows for global state (and mutable global state for that matter), which means that there is always a way to access global variable, no hook required. On the other hand, I am relatively certain (almost 100% to be honest) there can not be a context/IO , or any data/variable, passed into a function higher up the call stack and have that propagate to the lower level via implicit inclusion.

                        • messe a day ago

                          It doesn't and likely never will.

                          This has been a non-issue for years with Allocator. I fail to see why it will be a problem with IO.

                          • throwaway17_17 18 hours ago

                            I think the view that it’s a non-issue comes down to familiarity via language usage. I am on the ‘everything explicit all the time’ team and see no issues with Allocator, or the proposed IO mechanism. But, programmers coming from other languages, particularly those with an expectation of implicitness being a semantic and syntactic feature can not envision programming without all of the alleged time saving/ergonomic ‘benefits’.

                            I have had multiple ‘arguments’ about the reasoning advantages, complete lack of time loss (over any useful timeframe for comparison), and long-term maintenance benefits of explicitness in language design. I have never convinced a single ‘implicit team’ dev that I’m right. Oh well, I will keep doing what I do and be fine and will support in whatever ways I can languages and language development that prioritizes explicitness.

                          • torginus 11 hours ago

                            What do you mean by non-issue? You just accept passing it around in every function, and now passing around another param for io as well?

                            Or do you create a context struct and pass that around?

                            • messe 11 hours ago

                              > You just accept passing it around in every function

                              In every function that needs to allocate yes. Sometimes, it'll be stored in a struct, but that's rare. And not every function needs to allocate.

                              > and now passing around another param for io as well?

                              Yes. Not everything needs to do Io.

                              You should try reading some idiomatic Zig code. Ghostty would be an example (as would much of the Zig standard library).

                          • rowanG077 16 hours ago

                            Well it's not a "problem" in the sense that it's a blocker. But it's also not an improvement over standard async await in other languages. Which is not bad, don't get me wrong.

                        • echelon 5 hours ago

                          > This creates the drill-down issue we see with React props where we have to pass objects around in the call chain just so that somewhere down the line we can use it.

                          Oh dear God. That's hell.

                          Refactoring and plumbing code to change where io happens is going to be a nightmare.

                          • messe 4 hours ago

                            > Refactoring and plumbing code to change where io happens is going to be a nightmare.

                            I doubt it. It's pretty rare that I want to change something deep in my code to suddenly want to do IO.

                            This has been a non-issue for me with Zig's approach to Allocators, and I doubt it will become an issue with Io.

                      • tcfhgj a day ago

                        > But surely you can see how that will lead to less of a split in the ecosystem compared to sync and async rust?

                        not yet

            • whytevuhuni a day ago

              Here's a problem with that:

                  Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks.
              
              https://play.rust-lang.org/?version=stable&mode=debug&editio...
              • thinkharderdev 8 hours ago

                Right, because this would deadlock. But it seems like Zig would have the same issue. If I am running something in a evented IO system and then I try and do some blocking IO inside it then I will get a deadlock. The idea that you can write libraries that are agnostic to the asynchronous runtime seems fanciful to me beyond trivial examples.

              • tcfhgj a day ago

                just pass around handles like you do in zig, alright?

                also: spawn_blocking for blocking code

                • whytevuhuni a day ago

                  But that's the thing, idiomatic Rust sync code almost never passes around handles, even when they need to do I/O.

                  You might be different, and you might start doing that in your code, but almost none of either std or 3rd party libraries will cooperate with you.

                  The difference with Zig is not in its capabilities, but rather in how the ecosystem around its stdlib is built.

                  The equivalent in Rust would be if almost all I/O functions in std would be async; granted that would be far too expensive and disruptive given how async works.

                  • tcfhgj a day ago

                    > But that's the thing, idiomatic Rust sync code almost never passes around handles, even when they need to do I/O.

                    Because they don't use async inside.

                    Zig code is passing around handles in code without io?

                    • whytevuhuni a day ago

                      > Because they don't use async inside.

                      But they use I/O inside, and we arrive at this issue:

                      I'm writing async, and I need to call std::fs::read. I can't, because it blocks the thread; I could use spawn_blocking but that defeats the purpose of async. So instead I have to go look for a similar function but of the other color, probably from tokio.

                      In Zig, if you're writing sync, you call the standard library function for reading files. If you're writing async, you call the same library function for reading files. Then, the creator of the `io` object decides whether the whole thing will be sync or async.

    • hansvm 17 hours ago

      Making it dead simple to have different tokens is exactly the goal. A smattering of examples recently on my mind:

      As a background, you might ask why you need different runtimes ever. Why not just make everything async and be done with it, especially if the language is able to hide that complexity?

      1. In the context of a systems language that's not an option. You might be writing an OS, embedded code, a game with atypical performance demands requiring more care with the IO, some kernel-bypass shenanigan, etc. Even just selecting between a few builtin choices (like single-threaded async vs multi-threaded async vs single-threaded sync) doesn't provide enough flexibility for the range of programs you're trying to allow a user to write.

      2. Similarly, even initializing a truly arbitrary IO effect once at compile-time doesn't always suffice. Maybe you normally want a multi-threaded solution but need more care with respect to concurrency in some critical section and need to swap in a different IO. Maybe you normally get to interact with the normal internet but have a mode/section/interface/etc where you need to send messages through stranger networking conditions (20s ping, 99% packet loss, 0.1kbps upload on the far side, custom hardware, etc). Maybe some part of your application needs bounded latency and is fine dropping packets but some other part needs high throughput and no dropped packets at any latency cost. Maybe your disk hardware is such that it makes sense for networking to be async and disk to be sync. And so on. You can potentially work around that in a world with a single IO implementation if you can hack around it with different compilation units or something, but it gets complicated.

      Part of the answer then is that you need (or really want) something equivalent to different IO runtimes, hot-swappable for each function call. I gave some high-level ideas as to why that might be the case, but high-level observations often don't resonate, so let's look at a concrete case where `await` is less ergonomic:

      1. Take something like TLS as an example (stdlib or 3rd-party, doesn't really matter). The handshake code is complicated, so a normal implementation calls into an IO abstraction layer and physically does reads and writes (as opposed to, e.g., a pure state-machine implementation which returns some metadata about which action to perform next -- I hacked together a terrible version of that at one point [0] if you want to see what I mean). What if you want to run it on an embedded device? If it were written with async it would likely have enough other baggage that it wouldn't fit or otherwise wouldn't work. What if you want to hide your transmission in other data to sneak it past prying eyes (steganography, nowadays that's relatively easy to do via LLMs interestingly enough, and you can embed arbitrary data in messages which are human-readable and purport to discuss completely other things without exposing hi/lo-bit patterns or other such things that normally break steganography)? Then the kernel socket abstraction doesn't work at all, and "just using await" doesn't fix the problem. Basically, any place you want to use that library (and, arguably, that's the sort of code where you should absolutely use a library rather than rolling it yourself), if the implementer had a "just use await" mentality then you're SOL if you need to use it in literally any other context.

      I was going to write more concrete cases, but this comment is getting to be too long. The general observation is that "just use await" hinders code re-use. If you're writing code for your own consumption and also never need those other uses then it's a non-issue, but with a clever choice of abstraction it _might_ be possible (old Zig had a solution that didn't quite hit the mark IMO, and time will tell if this one is good enough, but I'm optimistic) to enable the IO code people naturally write to be appropriately generic by default and thus empower future developers via a more composable set of primitives.

      They really nailed that with the allocator interface, and if this works then my only real concern is a generic "what next" -- it's pushing toward an effect system, but integrating those with a systems language is mostly an unsolved problem, and adding a 3rd, 4th, etc explicit parameter to nearly every function is going to get unwieldy in a hurry (back-of-the-envelope idea I've had stewing if I ever write a whole "major" language is to basically do what Zig currently does and pack all those "effects" into a single effect parameter that you pass into each function, still allowing you to customize each function call, still allowing you to inspect which functions require allocators or whatever, but making the experience more pleasant if you have a little syntactic sugar around sub-effects and if the parent type class is comptime-known).

      [0] https://github.com/hmusgrave/rayloop/blob/d5e797967c42b9c891...

      • thinkharderdev 8 hours ago

        > If it were written with async it would likely have enough other baggage that it wouldn't fit or otherwise wouldn't work

        I'm unclear what this means. What is the other baggage in this context?

      • rowanG077 16 hours ago

        The case I'm making is not that different Io context are good. The point I'm making is that mixing them is almost never what is needed. I have seen valid cases that do it, but it's not in the "used all the time" path. So I'm more then happy with the better ergonomics of traditional async await in the style of Rust , that sacrifices super easy runtime switching. Because the former is used thousands of times more.

        • hansvm 15 hours ago

          If I'm understanding correctly (that most code and/or most code you personally write doesn't need that flexibility) then that's a valid use case.

          In practice it should just be a po-tay-to/po-tah-to scenario, swapping around a few symbols and keywords vs calls to functions with names similar to those keywords. If that's all you're doing then passing around something like IO (or, depending on your app, just storing one once globally and not bothering to adhere to the convention of passing it around) is not actually more ergonomic than the alternative. It's not worse (give or take a bunch of bike-shedding on a few characters here and there), but it's not better either.

          Things get more intriguing when you consider that most nontrivial projects have _something_ interesting going on. As soon as your language/framework/runtime/etc makes one-way-door assumptions about your use case, you're definitionally unable to handle those interesting things within the confines of the walls you've built.

          Maybe .NET Framework has an unavoidable memory leak under certain usage patterns forcing you to completely circumvent their dependency-injection code in your app. Maybe your GraphQL library has constrained socket assumptions forcing you to re-write a thousand lines of entrypoint code into the library (or, worse, re-write the entire library). Maybe the stdlib doesn't have enough flexibility to accomodate your atypical IO use-case.

          In any one app you're perhaps not incredibly likely to see that with IO in particular (an off-the-cuff guesstimate says that for apps needing _something_ interesting you'll need IO to be more flexible 30% of the time). However, when working in a language/framework/runtime/etc which makes one-way-door assumptions frequently, you _are_ very likely to find yourself having to hack around deficiencies of some form. Making IO more robust is just one of many choices enabling people to write the software they want to write. When asking why an argument-based IO is more ergonomic, it's precisely because it satisfies those sorts of use cases. If you literally never need them (even transitively) then maybe actually you don't care, but a lot of people do still want that, and even more people want a language which "just works" in any scenario they might find themselves in, including when handling those sorts of issues.

          === Rust async rant starts here ===

          You also called out Rust's async/await as having good ergonomics as a contrast against TFA, and ... I think it's worth making this comment much longer to talk about that?

          (1) Suppose your goal is to write a vanilla application doing IO stuff. You're forced to use Tokio and learn more than you want about the impact of static lifetimes and other Rust shenanigans, else you're forced to ignore most of the ecosystem (function coloring, yada yada). Those are workable constraints, but they're not exactly a paragon of a good developer experience. You're either forced to learn stuff you don't care about, or you're forced to write stuff you don't think you should have to write. The lack of composability of async Rust as it's usually practiced is common knowledge and one of the most popularly talked about pain points of the language.

          (2) Suppose your goal is to write a vanilla _async_ application doing IO stuff. At least now something like Tokio makes sense in your vision, but it's still not exactly easy. The particular implementation of async used by Tokio forces a litany of undesirable traits and lifetime issues into your application code. That code is hard to write. Moreover, the issues aren't really Rust-specific. Rust surfaces those issues early in the development cycle, but the problem is that Tokio has a lot of assumptions about your code which must be satisfied for it to work correctly, and equivalent libraries (and ecosystem problems) in other langugages will make those same assumptions and require the same kinds of code modifications from you, the end user. Contrasted with, e.g., Python's model of single-threaded async "just working" (or C#'s or something if you prefer multi-threaded stuff and ignore the syntactic sharp edges), a Tokio-style development process is brutally difficult and arguably not worth the squeeze if you also don't have the flexbility to do the async things your application actually demands. Just write golang greenthreads and move on with your life.

          (3) Suppose your goal is something more complicated. You're totally fucked. That capability isn't exposed to you (it's exposed a little, but you have to write every fucking thing yourself, removing one of the major appeals of choosing a popular language).

          I get that Zig is verbose and doesn't appeal to everyone, and I really don't want to turn this into Rust vs Zig, but Rust's async is one of the worst parts of the language and one of the worst async implementations I've ever seen anywhere. I don't have a lot of comment on TFA's implementation (seems reasonable, but I might change my mind after I try using it for awhile), but I'm shocked reading that Rust has a good async model. What am I missing?

thefaux a day ago

This design seems very similar to async in scala except that in scala the execution context is an implicit parameter rather than an explicit parameter. I did not find this api to be significantly better for many use cases than writing threads and communicating over a concurrent queue. There were significant downsides as well because the program behavior was highly dependent on the execution context. It led to spooky action at a distance problems where unrelated tasks could interfere with each and management of the execution context was a pain. My sense though is that the zig team has little experience with scala and thus do not realize the extent to which this is not a novel approach, nor is it a panacea.

  • pron a day ago

    > I did not find this api to be significantly better for many use cases than writing threads and communicating over a concurrent queue.

    The problem with using OS threads, you run into scaling problems due to Little's law. On the JVM we can use virtual threads, which don't run into that limitation, but the JVM can implement user-mode threads more efficiently than low-level languages can for several reasons (the JIT can see through all virtual calls, the JVM has helpful restrictions on pointers into the stack, and good GCs make memory management very cheap in exchange for a higher RAM footprint). So if you want scalability, low-level languages need other solutions.

ethin a day ago

One thing the old Zig async/await system theoretically allowed me to do, which I'm not certain how to accomplish with this new io system without manually implementing it myself, is suspend/resume. Where you could suspend the frame of a function and resume it later. I've held off on taking a stab at OS dev in Zig because I was really, really hoping I could take advantage of that neat feature: configure a device or submit a command to a queue, suspend the function that submitted the command, and resume it when an interrupt from the device is received. That was my idea, anyway. Idk if that would play out well in practice, but it was an interesting idea I wanted to try.

  • throwawaymaths 6 hours ago

    > suspend/resume

    special @asyncSuspend and @asyncResume builtins, they will be the low level detail you can build an evented io with.

    new Io is an abstraction over the higher level details that are common between sync, threaded, and evented, so you shouldn't expect the suspension mechanism to be in it.

    • ethin 4 hours ago

      Oh really? That's perfect.

  • your_sweetpea 16 hours ago

    I suspect the end goal is to have suspend/resume (or some analogue) be userspace standard library functions used to implement Io.Evented. It'll end up being a userspace implementation like you see with many of the C coroutine libraries floating around on GitHub (minicoro, llco, etc).

    Edit: Looking at the working prototype of Io.Evented, this may not be true actually. Perhaps this is the domain of a 3rd-party library except with stackless coroutines?

    Another thing you may want to pay attention to, however, are the current proposals for getting evented IO working on WASM -- namely, stackless coroutines as a language feature: https://github.com/ziglang/zig/issues/23446

  • nine_k a day ago

    Can you create a thread pool consisting of one thread, and suspend / resume the thread?

    • ethin 4 hours ago

      I mean I guess I could... But threads are pretty heavyweight. Though there may be a more lightweight way of implementing them in kernel mode (not coroutines or fibers, but actual threads) and I just have never heard of it.

    • RossBencina a day ago

      Doesn't that negate the point of using coroutines? light-weight concurrency

      • nine_k 17 hours ago

        It does, sadly.

  • NooneAtAll3 a day ago

    what's the point of implementing cooperative "multithreading" (coroutines) with preemptive one (async)?

    • wchar_t 18 hours ago

      There was this beautiful little time period where async/await was basically just that: resumable functions. I abused it to implement generators.

Someone 8 hours ago

If I understand this correctly, in this example

    const std = @import("std");
    const Io = std.Io;

    fn saveFile(io: Io, data: []const u8, name: []const u8) !void {
        const file = try Io.Dir.cwd().createFile(io, name, .{});
        defer file.close(io);
        try file.writeAll(io, data);
    }
the phrase “Either way, the operation is guaranteed to be complete by the time writeAll() returns” is too weak. Given that the function can, over time, be called with different implementations of IO and users can implement IO themselves, I think the only way this can work is that the operation is guaranteed to be complete when the defer starts (if not, what part of the code makes sure the createFile must have completed when writeAll starts? (The IO instance could know, but it would either have to allow for only one ‘in flight’ call or have to keep track of in-progress calls and know of dependency between creating a file and writing to it)

But then, how is this really different from a blocking call?

Also, if that’s the case, why is that interface called IO? It looks more like a “do this in a different context” thing than specific to I/O to me (https://ziglang.org/documentation/master/std/#std.Io seems to confirm that. It doesn’t mention I/O at all)

amluto a day ago

I find this example quite interesting:

       var a_future = io.async(saveFile, .{io, data, "saveA.txt"});
        var b_future = io.async(saveFile, .{io, data, "saveB.txt"});

        const a_result = a_future.await(io);
        const b_result = b_future.await(io);
In Rust or Python, if you make a coroutine (by calling an async function, for example), then that coroutine will not generally be guaranteed to make progress unless someone is waiting for it (i.e. polling it as needed). In contrast, if you stick the coroutine in a task, the task gets scheduled by the runtime and makes progress when the runtime is able to schedule it. But creating a task is an explicit operation and can, if the programmer wants, be done in a structured way (often called “structured concurrency”) where tasks are never created outside of some scope that contains them.

From this example, if the example allows the thing that is “io.async”ed to progress all by self, then I guess it’s creating a task that lives until it finishes or is cancelled by getting destroyed.

This is certainly a valid design, but it’s not the direction that other languages seem to be choosing.

  • jayd16 a day ago

    C# works like this as well, no? In fact C# can (will?) run the async function on the calling thread until a yield is hit.

    • throwup238 a day ago

      So do Python and Javascript. I think most languages with async/await also support noop-ing the yield if the future is already resolved. It’s only when you create a new task/promise that stuff is guaranteed to get scheduled instead of possibly running immediately.

      • amluto a day ago

        I can't quite parse what you're saying.

        Python works like this:

            import asyncio
        
            async def sleepy() -> None:
                print('Sleepy started')
                await asyncio.sleep(0.25)
                print('Sleepy resumed once')
                await asyncio.sleep(0.25)
                print('Sleepy resumed and is done!')
        
        
            async def main():
                sleepy_future = sleepy()
                print('Started a sleepy')
        
                await asyncio.sleep(2)
                print('Main woke back up.  Time to await the sleepy.')
        
                await sleepy_future
        
            if __name__ == "__main__":
                asyncio.run(main())
        
        Running it does this:

            $ python3 ./silly_async.py
            Started a sleepy
            Main woke back up.  Time to await the sleepy.
            Sleepy started
            Sleepy resumed once
            Sleepy resumed and is done!
        
        So there mere act of creating a coroutine does not cause the runtime to run it. But if you explicitly create a task, it does get run:

            import asyncio
        
            async def sleepy() -> None:
                print('Sleepy started')
                await asyncio.sleep(0.25)
                print('Sleepy resumed once')
                await asyncio.sleep(0.25)
                print('Sleepy resumed and is done!')
        
        
            async def main():
                sleepy_future = sleepy()
                print('Started a sleepy')
        
                sleepy_task = asyncio.create_task(sleepy_future)
                print('The sleepy future is now in a task')
        
                await asyncio.sleep(2)
                print('Main woke back up.  Time to await the task.')
        
                await sleepy_task
        
            if __name__ == "__main__":
                asyncio.run(main())
        
            $ python3 ./silly_async.py
            Started a sleepy
            The sleepy future is now in a task
            Sleepy started
            Sleepy resumed once
            Sleepy resumed and is done!
            Main woke back up.  Time to await the task.
        
        I personally like the behavior of coroutines not running unless you tell them to run -- it makes it easier to reason about what code runs when. But I do not particularly like the way that Python obscures the difference between a future-like thing that is a coroutine and a future-like thing that is a task.
        • throwup238 a day ago

          That’s exactly the behavior I’m describing.

          `sleepy_future = sleepy()` creates the state machine without running anything, `create_task` actually schedules it to run via a queue, `asyncio.sleep` suspends the main task so that the newly scheduled task can run, and `await sleepy_task` either yields the main task until sleepy_task can finish, or no-ops immediately if it has already finished without yielding the main task.

          My original point is that last bit is a very common optimization in languages with async/await since if the future has already resolved, there’s no reason to suspend the current task and pay the switching overhead if the task isn’t blocked waiting for anything.

        • int_19h 21 hours ago

          > I personally like the behavior of coroutines not running unless you tell them to run -- it makes it easier to reason about what code runs when.

          In .NET the difference was known as "hot" vs "cold" tasks.

          "Hot" tasks - which is what .NET does with C# async/await - have one advantage in that they get to run any code that validates the arguments right away and fail right there at the point of the call, which is easier to debug.

          But one can argue that such validation should properly be separate from function body in the first place - in DbC terms it's the contract of the function.

        • metaltyphoon 21 hours ago

          In C# that Task is ALWAYS hot, aka scheduled to run.

  • throwawaymaths 21 hours ago

    is it not the case that in zig, the execution happens in a_future.await?

    I presume that:

    io.async 1 stores in io "hey please work on this"

    io.async 2 stores in io "hey also please work on this"

    in the case where io is evented with some "provided event loop":

    await #1 runs through both 1 and 2 interleavedly, and if 2 finishes before 1, it puts a pin on it, and then returns a_result when 1 is completed.

    await #2 "no-executions" if 1 finished after 2, but if there is still work to be done for 2, then it keeps going until the results for 2 are all in.

    There's no "task that's running somewere mysteriously" unless you pick threaded io, in which case, yeah, io.async actually kicks shit off, and if the cpu takes a big fat nap on the calling thread between the asyncs and the awaits, progress might have been made (which wouldn't be the case if you were evented).

    • amluto 17 hours ago

      There’s a material distinction. In Zig (by my reading of the article — I haven’t tried it), as you say:

      > await #1 runs through both 1 and 2 interleavedly, and if 2 finishes before 1, it puts a pin on it, and then returns a_result when 1 is completed.

      In Rust or Python, awaiting a future runs that future and possibly other tasks, but it does not run other non-task futures. The second async operation would be a non-task future and would not make progress as a result of awaiting the first future.

      It looks like Zig’s io.async sometimes creates what those other languages call a task.

      • throwawaymaths 16 hours ago

        i am not familiar with rust and i gave up on python async years ago so i have no frame of reference here. but im really not sure why theres a need to distinguish between tasks and non tasks?

        importantly in zig the execution isnt just limited to #1 and #2. if the caller of this function initiated a #3 before all of this it could also get run stuffed in that .await, for example.

  • nmilo a day ago

    This is how JS works

  • messe a day ago

    It's not guaranteed in Zig either.

    Neither task future is guaranteed to do anything until .await(io) is called on it. Whether it starts immediately (possibly on the same thread), or queued on a thread pool, or yields to an event loop, is entirely dependent on the Io runtime the user chooses.

    • amluto a day ago

      It’s not guaranteed, but, according to the article, that’s how it works in the Evented model:

      > When using an Io.Threaded instance, the async() function doesn't actually do anything asynchronously — it just runs the provided function right away. So, with that version of the interface, the function first saves file A and then file B. With an Io.Evented instance, the operations are actually asynchronous, and the program can save both files at once.

      Andrew Kelley’s blog (https://andrewkelley.me/post/zig-new-async-io-text-version.h...) discusses io.concurrent, which forces actual concurrency, and it’s distinctly non-structured. It even seems to require the caller to make sure that they don’t mess up and keep a task alive longer than whatever objects the task might reference:

          var producer_task = try io.concurrent(producer, .{
              io, &queue, "never gonna give you up",
          });
          defer producer_task.cancel(io) catch {};
      
      Having personally contemplated this design space a little bit, I think I like Zig’s approach a bit more than I like the corresponding ideas in C and C++, as Zig at least has defer and tries to be somewhat helpful in avoiding the really obvious screwups. But I think I prefer Rust’s approach or an actual GC/ref-counting system (Python, Go, JS, etc) even more: outside of toy examples, it’s fairly common for asynchronous operations to conceptually outlast single function calls, and it’s really really easy to fail to accurately analyze the lifetime of some object, and having the language prevent code from accessing something beyond its lifetime is very, very nice. Both the Rust approach of statically verifying the lifetime and the GC approach of automatically extending the lifetime mostly solve the problem.

      But this stuff is brand new in Zig, and I’ve never written Zig code at all, and maybe it will actually work very well.

      • messe a day ago

        Ah, I think we might have been talking over each other. I'm referring to the interface not guaranteeing anything, not the particular implementation. The Io interface itself doesn't guarantee that anything will have started until the call to await returns.

et1337 a day ago

I’m excited to see how this turns out. I work with Go every day and I think Io corrects a lot of its mistakes. One thing I am curious about is whether there is any plan for channels in Zig. In Go I often wish IO had been implemented via channels. It’s weird that there’s a select keyword in the language, but you can’t use it on sockets.

  • jerf a day ago

    Wrapping every IO operation into a channel operation is fairly expensive. You can get an idea of how fast it would work now by just doing it, using a goroutine to feed a series of IO operations to some other goroutine.

    It wouldn't be quite as bad as the perennial "I thought Go is fast why is it slow when I spawn a full goroutine and multiple channel operations to add two integers together a hundred million times" question, but it would still be a fairly expensive operation. See also the fact that Go had fairly sensible iteration semantics before the recent iteration support was added by doing a range across a channel... as long as you don't mind running a full channel operation and internal context switch for every single thing being iterated, which in fact quite a lot of us do mind.

    (To optimize pure Python, one of the tricks is to ensure that you get the maximum value out of all of the relatively expensive individual operations Python does. For example, it's already handling exceptions on every opcode, so you could win in some cases by using exceptions cleverly to skip running some code selectively. Go channels are similar; they're relatively expensive, on the order of dozens of cycles, so you want to make sure you're getting sufficient value for that. You don't have to go super crazy, they're not like a millisecond per operation or something, but you do want to get value for the cost, by either moving non-trivial amount of work through them or by taking strong advantage of their many-to-many coordination capability. IO often involves moving around small byte slices, even perhaps one byte, and that's not good value for the cost. Moving kilobytes at a time through them is generally pretty decent value but not all IO looks like that and you don't want to write that into the IO spec directly.)

  • Zambyte a day ago

    > One thing I am curious about is whether there is any plan for channels in Zig.

    The Zig std.Io equivalent of Golang channels is std.Io.Queue[0]. You can do the equivalent of:

        type T interface{}
    
        fooChan := make(chan T)
        barChan := make(chan T)
    
        select {
        case foo := <- fooChan:
            // handle foo
        case bar := <- barChan:
            // handle bar
        }
    
    in Zig like:

        const T = void;
    
        var foo_queue: std.Io.Queue(T) = undefined;
        var bar_queue: std.Io.Queue(T) = undefined;
    
        var get_foo = io.async(Io.Queue(T).getOne, .{ &foo_queue, io });
        defer get_foo.cancel(io) catch {};
    
        var get_bar = io.async(Io.Queue(T).getOne, .{ &bar_queue, io });
        defer get_bar.cancel(io) catch {};
    
        switch (try io.select(.{
            .foo = &get_foo,
            .bar = &get_bar,
        })) {
            .foo => |foo| {
                // handle foo
            },
            .bar => |bar| {
                // handle bar
            },
        }
    
    Obviously not quite as ergonomic, but the trade off of being able to use any IO runtime, and to do this style of concurrency without a runtime garbage collector is really interesting.

    [0] https://ziglang.org/documentation/master/std/#std.Io.Queue.

  • ecshafer a day ago

    Have you tried Odin? Its a great language thats also a “better C” but takes more Go inspiration than Zig.

    • thegeekpirate 8 hours ago

      Completely replaced Go for me after using Go since inception.

      Wonderful language!

    • dismalaf a day ago

      Second vote for Odin but with a small caveat.

      Odin doesn't (and won't ever according to its creator) implement specific concurrency strategies. No async, coroutines, channels, fibers, etc... The creator sees concurrency strategy (as well as memory management) as something that's higher level than what he wants the language to be.

      Which is fine by me, but I know lots of people are looking for "killer" features.

  • osigurdson a day ago

    At least Go didn't take the dark path of having async / await keywords. In C# that is a real nightmare and necessary to use sync over async anti-patterns unless willing to re-write everything. I'm glad Zig took this "colorless" approach.

    • rowanG077 a day ago

      Where do you think the Io parameter comes from? If you change some function to do something async and now suddenly you require an Io instance. I don't see the difference between having to modify the call tree to be async vs modifying the call tree to pass in an Io token.

      • messe a day ago

        Synchronous Io also uses the Io instance now. The coloring is no longer "is it async?" it's "does it perform Io"?

        This allows library authors to write their code in a manner that's agnostic to the Io runtime the user chooses, synchronous, threaded, evented with stackful coroutines, evented with stackless coroutines.

        • simonask 21 hours ago

          The interesting question was always “does it perform IO”.

        • metaltyphoon 21 hours ago

          Except that now your library code lost context on how it runs. If you meant it to be sync and the caller gives you an multi threaded IO your code can fail in unexpected ways.

          • messe 18 hours ago

            How so? Aside from regular old thread safety issues that is.

            • metaltyphoon 18 hours ago

              This is exactly the problem, thread safety. The function being supplied with std.Io needs to understand what implementation is being used to take precautions with thread safety, in case a std.Io.Threaded is used. What if this function was designed with synchrony in mind, how do you prevent it taking a penalty guarding against a threaded version of IO?

              • messe 11 hours ago

                The function being called has to take into account thread safety anyway even if it doesn't do IO. This is an entirely orthogonal problem, so I can't really take it seriously as a criticism of Zig's approach. Libraries in general need to be designed to be thread-safe or document otherwise regardless of if the do IO, because a calling program could easily spin up a few threads and call it multiple times.

                > What if this function was designed with synchrony in mind, how do you prevent it taking a penalty guarding against a threaded version of IO?

                You document it and state that it will take a performance penalty in multithreaded mode? The same as any other library written before this point.

        • rowanG077 a day ago

          Rust also allows writing async code that is agnostic to the async runtime used. Subsuming async under Io doesn't change much imo.

  • kbd a day ago

    One of the harms Go has done is to make people think its concurrency model is at all special. “Goroutines” are green threads and a “channel” is just a thread-safe queue, which Zig has in its stdlib https://ziglang.org/documentation/master/std/#std.Io.Queue

    • jerf a day ago

      A channel is not just a thread-safe queue. It's a thread-safe queue that can be used in a select call. Select is the distinguishing feature, not the queuing. I don't know enough Zig to know whether you can write a bit of code that says "either pull from this queue or that queue when they are ready"; if so, then yes they are an adequate replacement, if not, no they are not.

      Of course even if that exact queue is not itself selectable, you can still implement a Go channel with select capabilities in Zig. I'm sure one exists somewhere already. Go doesn't get access to any magic CPU opcodes that nobody else does. And languages (or libraries in languages where that is possible) can implement more capable "select" variants than Go ships with that can select on more types of things (although not necessarily for "free", depending on exactly what is involved). But it is more than a queue, which is also why Go channel operations are a bit to the expensive side, they're implementing more functionality than a simple queue.

      • kbd a day ago

        > I don't know enough Zig to know whether you can write a bit of code that says "either pull from this queue or that queue when they are ready"; if so, then yes they are an adequate replacement, if not, no they are not.

        Thanks for giving me a reason to peek into how Zig does things now.

        Zig has a generic select function[1] that works with futures. As is common, Blub's language feature is Zig's comptime function. Then the io implementation has a select function[2] that "Blocks until one of the futures from the list has a result ready, such that awaiting it will not block. Returns that index." and the generic select switches on that and returns the result. Details unclear tho.

        [1] https://ziglang.org/documentation/master/std/#std.Io.select

        [2] https://ziglang.org/documentation/master/std/#std.Io.VTable

        • jerf a day ago

          Getting a simple future from multiple queues and then waiting for the first one is not a match for Go channel semantics. If you do a select on three channels, you will receive a result from one of them, but you don't get any future claim on the other two channels. Other goroutines could pick them up. And if another goroutine does get something from those channels, that is a guaranteed one-time communication and the original goroutine now can not get access to that value; the future does not "resolve".

          Channel semantics don't match futures semantics. As the name implies, channels are streams, futures are a single future value that may or may not have resolved yet.

          Again, I'm sure nothing stops Zig from implementing Go channels in half-a-dozen different ways, but it's definitely not as easy as "oh just wrap a future around the .get of a threaded queue".

          By a similar argument it should be observed that channels don't naively implement futures either. It's fairly easy to make a future out of a channel and a couple of simple methods; I think I see about 1 library a month going by that "implements futures" in Go. But it's something that has to be done because channels aren't futures and futures aren't channels.

          (Note that I'm not making any arguments about whether one or the other is better. I think such arguments are actually quite difficult because while both are quite different in practice, they also both fairly fully cover the solution space and it isn't clear to me there's globally an advantage to one or the other. But they are certainly different.)

          • kbd a day ago

            > channels aren't futures and futures aren't channels.

            In my mind a queue.getOne ~= a <- on a Go channel. Idk how you wrap the getOne call in a Future to hand it to Zig's select but that seems like it would be a straightforward pattern once this is all done.

            I really do appreciate you being strict about the semantics. Tbh the biggest thing I feel fuzzy on in all this is how go/zig actually go about finding the first completed future in a select, but other than that am I missing something?

            https://ziglang.org/documentation/master/std/#std.Io.Queue.g...

            • jerf 6 hours ago

              "but other than that am I missing something?"

              I think the big one is that a futures based system no matter how you swing it lacks the characteristic that on an unbuffered Go channel (which is the common case), successfully sending is also a guarantee that someone else has picked it up, and as such a send or receive event is also a guaranteed sync point. This requires some work in the compiler and runtime to guarantee with barriers and such as well. I don't think a futures implementation of any kind can do this because without those barriers being inserted by either the compiler or runtime this is just not a guarantee you can ever have.

              To which, naturally, the response in the futures-based world is "don't do that". Many "futures-based worlds" aren't even truly concurrently running on multiple CPUs where that could be an issue anyhow, although you can still end up with the single-threaded equivalent of a race condition if you work at it, though it is certainly more challenging to get there than with multi-threaded code.

              This goes back to, channels are actually fairly heavyweight as concurrency operations go, call it two or three times the cost of a mutex. They provide a lot, and when you need it it's nice to have something like that, but there's also a lot of mutex use in Go code because when you don't need it it can add up in price.

              • kbd 3 hours ago

                Thanks for taking the time to respond. I will now think of Channels as queue + [mutex/communication guarantee] and not just queue. So in Go's unbuffered case (only?) a Channel is more than a 1-item queue. Also, in Go's select, I now get that channels themselves are hooked up to notify the select when they are ready?

        • SkiFire13 a day ago

          Maybe I'm missing something, but how do you get a `Future` for receiving from a channel?

          Even better, how would I write my own `Future` in a way that supports this `select` and is compatible with any reasonable `Io` implementation?

      • jeffbee a day ago

        If we're just arguing about the true nature of Scotsmen, isn't "select a channel" merely a convenience around awaiting a condition?

        • jerf a day ago

          This is not a "true Scotsman" argument. It's the distinctive characteristic of Go channels. Threaded queues where you can call ".get()" from another thread, but that operation is blocking and you can't try any other queues, then you can't write:

              select {
              case result := <-resultChan:
                  // whatever
              case <-cxt.Done():
                  // our context either timed out or was cancelled
              }
          
          or any more elaborate structure.

          Or, to put it a different way, when someone says "I implement Go channels in X Language" I don't look for whether they have a threaded queue but whether they have a select equivalent. Odds are that there's already a dozen "threaded queues" in X Language anyhow, but select is less common.

          Again note the difference between the word "distinctive" and "unique". No individual feature of Go is unique, of course, because again, Go does not have special unique access to Go CPU opcodes that no one else can use. It's the more defining characteristic compared to the more mundane and normal threaded queue.

          Of course you can implement this a number of ways. It is not equivalent to a naive condition wait, but probably with enough work you could implement them more or less with a condition, possibly with some additional compiler assistance to make it easier to use, since you'd need to be combining several together in some manner.

        • SkiFire13 a day ago

          It's more akin to awaiting *any* condition from a list.

    • 0x696C6961 a day ago

      What other mainstream languages have pre-emptive green threads without function coloring? I can only think of Erlang.

      • smw a day ago

        I'm told modern Java (loom?) does. But I think that might be an exhaustive list, sadly.

    • thiht 4 hours ago

      What's the harm exactly?

    • dlisboa a day ago

      It was special. CSP wasn't anywhere near the common vocabulary back in 2009. Channels provide a different way of handling synchronization.

      Everything is "just another thing" if you ignore the advantage of abstraction.

ptx 7 hours ago

Isn't this (their async version of Io) essentially the same thing that Go is doing?

I seem to recall reading about some downsides to that approach, e.g. that calling C libraries is relatively expensive (because a real stack has to be allocated) and that circumventing libc to do direct syscalls is fragile and unsupported on some platforms.

Does the Zig implementation improve on Go's approach? Is it just that it makes it configurable, so that different tradeoffs can be made without changing the code?

mk12 18 hours ago

I think the new async IO is great in simple examples like the one shown in the article. But I’m much less sure how well it will work for more complex I/O like you need in servers. I filed an issue about it here: https://github.com/ziglang/zig/issues/26056

badmonster a day ago

Interesting to see Zig tackle async. The io_uring-first approach makes sense for modern systems, but the challenge is always making async ergonomic without sacrificing Zig's explicit control philosophy. Curious how colored functions will play out in practice.

  • klabb3 18 hours ago

    I don’t know the details but reading the article they got this right. It’s been my main gripe with Rust which imo totally botched it. Or rather, they botched the ergonomics. Rust still allows low level control just fine… (but so does a plain old explicit event loop). Go did much better, succeeding at ergonomics but failing at low level control (failed successfully that is, it was never a goal).

    The trick to retaining ergonomics and low level control is precisely to create a second layer, a ”runtime” layer, which is responsible for scheduling higher level tasks, IO and IPC. This isn’t easy, but it’s the only way. Otherwise you get an interoperability problem that the coloring and ecosystem fragmentation in Rust reflects.

hardwaresofton 20 hours ago

Explicit allocators and explicit io are sweet code smells for systems languages.

Really think Zig is right about this, excited to use it and feel it out.

epolanski 9 hours ago

jm2c, never had an issue with coloured functions, as long as they are tracked at the type level and you know what you're getting.

Yes, eventually you're gonna lift sync to async code, and that works fine as it is generally also the runtime model (asynchronous, event-based).

qudat a day ago

I'm excited to see where this goes. I recently did some io_uring work in zig and it was a pain to get right.

Although, it does seem like dependency injection is becoming a popular trend in zig, first with Allocator and now with Io. I wonder if a dependency injection framework within the std could reduce the amount of boilerplate all of our functions will now require. Every struct or bare fn now needs (2) fields/parameters by default.

  • messe a day ago

    > Every struct or bare fn now needs (2) fields/parameters by default.

    Storing interfaces a field in structs is becoming a bit of an an anti-pattern in Zig. There are still use cases for it, but you should think twice about it being your go-to strategy. There's been a recent shift in the standard library toward "unmanaged" containers, which don't store a copy of the Allocator interface, and instead Allocators are passed to any member function that allocates.

    Previously, one would write:

        var list: std.ArrayList(u32) = .init(allocator);
        defer list.deinit();
        for (0..count) |i| {
            try list.append(i);
        }
    
    Now, it's:

        var list: std.ArrayList(u32) = .empty;
        defer list.deinit(allocator);
        for (0..count) |i| {
            try list.append(allocator, i);
        }
    
    Or better yet:

        var list: std.ArrayList(u32) = .empty;
        defer list.deinit(allocator);
        try list.ensureUnusedCapacity(allocator, count); // Allocate up front
        for (0..count) |i| {
            list.appendAssumeCapacity(i); // No try or allocator necessary here
        }
    • turtletontine a day ago

      I’m not sure I see how each example improves on the previous (though granted, I don’t really know Zig).

      What happens if you call append() with two different allocators? Or if you deinit() with a different allocator than the one that actually handled the memory?

      • messe a day ago

        Storing an Allocator alongside the container is an additional 16-bytes. This isn't much, but starts adding up when you start storing other objects that keep allocators inside of those containers. This can improve cache locality.

        It also helps devirtualization, as the most common case is threading a single allocator through your application (with the occasion Arena allocator wrapping it for grouped allocations). When the Allocator interface is stored in the container, it's harder for the optimizer to prove it hasn't changed.

        > What happens if you call append() with two different allocators? Or if you deinit() with a different allocator than the one that actually handled the memory?

        It's undefined behaviour, but I've never seen it be an issue in practice. Expanding on what I mentioned above, it's typical for only a single allocator to be used for long live objects throughout the entire program. Arena allocators are used for grouped allocations, and tend to have a well defined scope, so it's obvious where deallocation occurs. FixedBufferAllocator also tends to be used in the same limited scope.

  • scuff3d a day ago

    I think a good compromise between a DI framework and having to pass everything individually would be some kind of Context object. It could be created to hold an Allocator, IO implementation, and maybe a Diagnostics struct since Zig doesn't like attaching additional information to errors. Then the whole Context struct or parts of it could be passed around as needed.

  • Mond_ a day ago

    Yes, and it's good that way.

    Please, anything but a dependency injection framework. All parameters and dependencies should be explicit.

  • SvenL a day ago

    I think and hope that they don’t do that. As far as I remember their mantra was „no magic, you can see everything which is happening“. They wanted to be a simple and obvious language.

    • qudat a day ago

      That's fair, but the same argument can be made for Go's verbose error handling. In that case we could argue that `try` is magical, although I don't think anyone would want to take that away.

idle_zealot 20 hours ago

Passing io into things over and over seems annoying. Like, you can use io to get a File instance, then you need to pass io into its methods to read/write it? When would you ever make a File with one io implementation and want to manipulate it with another?

  • ivanjermakov 20 hours ago

    No one can stop you from defining a global Io value, similar to global allocators. Definitely a bad idea for library code though.

spullara 19 hours ago

I think that Java virtual threads solve this problem in a much better way than most other languages. I'm not sure that it is possible in a language as low level as Zig however.

Ericson2314 a day ago

This is a bad explanation because it doesn't explain how the concurrency actually works. Is it based on stacks? Is there a heavy runtime? Is it stackless and everything is compiled twice?

IMO every low level language's async thing is terrible and half-baked, and I hate that this sort of rushed job is now considered de rigueur.

(IMO We need a language that makes the call stack just another explicit data structure, like assembly and has linearity, "existential lifetimes", locations that change type over the control flow, to approach the question. No language is very close.)

mono442 a day ago

It look like promising idea, though I'm a bit spectical that they can actually make it work with other executors like for example stackless coroutines transparently and it probably won't work with code that uses ffi anyway.

dylanowen a day ago

This seems a lot like what the scala libraries Zio or Kyo are doing for concurrency, just without the functional effect part.

breatheoften a day ago

Is there any way to implement structured concurrency on top of the std.Io primitive?

  • AndyKelley a day ago

        var group: Io.Group = .init;
        defer group.cancel(io);
    
    If you see this pattern, you are doing structured concurrency.

    Same thing with:

        var future = io.async(foo, .{});
        defer future.cancel(io);
debugnik a day ago

> Languages that don't make a syntactical distinction (such as Haskell) essentially solve the problem by making everything asynchronous

What the heck did I just read. I can only guess they confused Haskell for OCaml or something; the former is notorious for requiring that all I/O is represented as values of some type encoding the full I/O computation. There's still coloring since you can't hide it, only promote it to a more general colour.

Plus, isn't Go the go-to example of this model nowadays?

  • gf000 a day ago

    Haskell has green threads. Plus nowadays Java also has virtual threads.

    • debugnik a day ago

      And I bet those green threads still need an IO type of some sort to encode anything non-pure, plus usually do-syntax. Comparing merely concurrent computations to I/O-async is just weird. In fact, I suspect that even those green threads already have a "colourful" type, although I can't check right now.

      • iviv a day ago

        Pure actions can be run in parallel with https://hackage-content.haskell.org/package/parallel/docs/Co...

        Impure actions use the IO monad like always in Haskell: https://hackage.haskell.org/package/base-4.21.0.0/docs/Contr... (or the higher-level async library)

        I suppose an extreme version of the function coloring argument could be that all types are colors.

        • debugnik 21 hours ago

          In a sense, kinda? The function colour problem is that you can't call an async API from a non-async caller without modifying the entire call chain (or blocking the full thread). In async/await languages the conflict comes from changing the return types; the syntax is orthogonal.

          Maybe in practice some properties of code are better off being whole-program than represented as types, even if they're less modular or harder to check.

          Also thanks for the reference, I haven't touched Haskell in ages; I'm more of an F# and OCaml guy.

          • iviv 11 minutes ago

            Right. In Haskell we want to maintain the pure/impure distinction so… it’s not a problem I guess?

      • instig007 8 hours ago

        > And I bet those green threads still need an IO type of some sort to encode anything non-pure, plus usually do-syntax.

        There's no need for the do-syntax at all. The (IO a) is no different to other generic types that can be fmap-ed, pointfree-composed with other expressions, and joined/folded when required. The only difference is the fact that they represent actions that affect the real world, so that ordering of things suddenly becomes important.

        • debugnik 3 hours ago

          Right, and there's also no need for await syntax at all, they can be then-ed, ContinueWith-ed or whatever the language calls them, but people keep bringing syntax into a semantics battle, so I had to mention it.

codr7 a day ago

Love it, async code is a major pita in most languages.

  • giancarlostoro a day ago

    When Microsoft added Tasks / Async Await, that was when I finally stopped writing single threaded code as often as I did, since the mental overhead drastically went away. Python 3 as well.

    • codr7 a day ago

      Isn't this exactly the mess Zig is trying to get out of here?

      Every other example I've seen encodes the execution model in the source code.

  • themafia 19 hours ago

    It's one of the things JavaScript has an easier time of than other languages due to the event driven single threaded nature of the runtime itself. They're not as powerful but they are quite useful and exceedingly ergonomic.

LunicLynx a day ago

Pro tip: use postfix keyword notation.

Eg.

doSomethingAsync().defer

This removes stupid parentheses because of precedence rules.

Biggest issue with async/await in other languages.

cies a day ago

I like Zig and I like their approach in this case.

From the article:

    std.Io.Threaded - based on a thread pool.

      -fno-single-threaded - supports concurrency and cancellation.
      -fsingle-threaded - does not support concurrency or cancellation.

    std.Io.Evented - work-in-progress [...]
Should `std.Io.Threaded` not be split into `std.Io.Threaded` and `std.Io.Sequential` instead? Single threaded is another word for "not threaded", or am I wrong here?
ecshafer a day ago

I like the look of this direction. I am not a fan of the `async` keyword that has become so popular in some languages that then pollutes the codebase.

  • davidkunz a day ago

    In JavaScript, I love the `async` keyword as it's a good indicator that something goes over the wire.

  • Dwedit a day ago

    Async always confused me as to when a function would actually create a new thread or not.

    • darthwalsh 4 hours ago

      Zig doesn't make it simpler! Now in a single function, using async won't spawn threads, while using sync might.

      But I'm digging this abstraction.

    • metaltyphoon 21 hours ago

      Why? Asynchrony has nothing to do with multiple threads. In fact you can have async with only a single thread!

  • warmwaffles a day ago

    Async usually ends up being a coloring function that knows no bounds once it is used.

    • amonroe805-2 a day ago

      I’ve never really understood the issue with this. I find it quite useful to know what functions may do something async vs which ones are guaranteed to run without stopping.

      In my current job, I mostly write (non-async) python, and I find it to be a performance footgun that you cannot trivially tell when a method call will trigger I/O, which makes it incredibly easy for our devs to end up with N+1-style queries without realizing it.

      With async/await, devs are always forced into awareness of where these operations do and don’t occur, and are much more likely to manage them effectively.

      FWIW: The zig approach also seems great here, as the explicit Io function argument seems likely to force a similar acknowledgement from the developer. And without introducing new syntax at that! Am excited to see how well it works in practice.

      • newpavlov a day ago

        In my (Rust-colored) opinion, the async keyword has two main problems:

        1) It tracks code property which is usually omitted in sync code (i.e. most languages do not mark functions with "does IO"). Why IO is more important than "may panic", "uses bounded stack", "may perform allocations", etc.?

        2) It implements an ad-hoc problem-specific effect system with various warts. And working around those warts requires re-implementation of half of the language.

        • echelon a day ago

          > Why IO is more important than "may panic", "uses bounded stack", "may perform allocations", etc.?

          Rust could use these markers as well.

          • newpavlov a day ago

            I agree. But it should be done with a proper effect system, not a pile of ad hoc hacks built on abuse of the type system.

            • echelon a day ago

              `async` is in the type system. In your mind, how would you mark and bubble up panicky functions, etc.? What would that look like?

              I felt like a `panic` label for functions would be nice, but if we start stacking labels it becomes cumbersome:

                pub async panic alloc fn foo() {}
              
              That feels dense.

              I think ideally it would be something readers could spot at first glance, not something inferred.

              • newpavlov a day ago

                >`async` is in the type system.

                No, it's not. `async` is just syntax sugar, the "effect" gets emulated in the type system using `Future`. This is one of the reasons why the `async` system feels so foreign and requires so many language changes to make it remotely usable. `const` is much closer to a "true" effect (well, to be precise it's an anti-effect, but it's not important right now).

                Also, I think it's useful to distinguish between effect and type systems, instead of lumping them into just "type system". The former applies to code and the latter to data.

                >That feels dense.

                Yes. But why `async` is more important than `alloc`? For some applications it's as important to know about potential allocations, as for other applications to know about whether code potentially yields or not.

                Explicitly listing all effects would the most straightforward approach, but I think a more practical approach would be to have a list of "default effects", which can be overwritten on the crate (or maybe even module) level. And on the function level you will be able to opt-in or opt-out from effects if needed.

                >I think ideally it would be something readers could spot at first glance

                Well, you either can have "dense" or explicit "at first glance" signatures.

      • ecshafer a day ago

        Is this Django? I could maybe see that argument there. Some frameworks and ORMs can muddy that distinction. But most the code ive written its really clear if something will lead to io or not.

      • warmwaffles a day ago

        I've watched many changes over time where the non async function uses an async call, then the function eventually becomes marked as async. Once majority of functions get marked as async, what was the point of that boilerplate?