Live data from Hacker News

Zig's new plan for asynchronous programs

lwn.net

201–210 of 274 posts

Re: Zig's new plan for asynchronous programs

#201
post #186

Earlier quoted context omitted.

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

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.

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?

Re: Zig's new plan for asynchronous programs

#202

Earlier quoted context omitted.

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

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 -> (runRea…

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 >= (\input -> pure $ length input)

Re: Zig's new plan for asynchronous programs

#203
post #106
post #73

Earlier quoted context omitted.

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

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

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

Re: Zig's new plan for asynchronous programs

#204
post #106

Earlier quoted context omitted.

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

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

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.

Re: Zig's new plan for asynchronous programs

#205

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

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…

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

Re: Zig's new plan for asynchronous programs

#206
post #106

Earlier quoted context omitted.

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

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

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.

Re: Zig's new plan for asynchronous programs

#207
post #71

Earlier quoted context omitted.

yes, you can: runtime.block_on(async { }) https://play.rust-lang.org/?version=stable&mode=debug&editio...

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

Fixed it for you

https://play.rust-lang.org/?version=stable&mode=debug&editio...

Re: Zig's new plan for asynchronous programs

#208

Earlier quoted context omitted.

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…

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…

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.

Re: Zig's new plan for asynchronous programs

#209
post #206

Earlier quoted context omitted.

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

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

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.

Re: Zig's new plan for asynchronous programs

#210
post #135

Earlier quoted context omitted.

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?

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.

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?

Post reply on HN