Remix.run Logo
woodruffw 7 hours 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.)

throwawaymaths 15 minutes ago | parent | next [-]

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 9 minutes ago | parent [-]

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

flohofwoe 7 hours ago | parent | prev | next [-]

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

woodruffw 7 hours ago | parent | next [-]

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

SkiFire13 4 hours ago | parent | prev | next [-]

> 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 14 minutes ago | parent [-]

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

adamwk 6 hours ago | parent | prev | next [-]

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 6 hours ago | parent [-]

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 5 hours ago | parent [-]

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 3 hours ago | parent | next [-]

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

2 hours ago | parent | prev | next [-]
[deleted]
derriz 3 hours ago | parent | prev | next [-]

> 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 2 hours ago | parent [-]

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 4 hours ago | parent | prev [-]

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 6 hours ago | parent | prev | next [-]

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

6 hours ago | parent | prev | next [-]
[deleted]
rowanG077 7 hours ago | parent | prev [-]

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.

jayd16 6 hours ago | parent | prev | next [-]

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.

11 minutes ago | parent | next [-]
[deleted]
flohofwoe 6 hours ago | parent | prev [-]

> 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 6 hours ago | parent [-]

> 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 6 hours ago | parent [-]

> 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

eikenberry an hour ago | parent | prev | next [-]

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 16 minutes ago | parent | next [-]

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

IshKebab an hour ago | parent | prev [-]

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

doyougnu 6 hours ago | parent | prev | next [-]

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

AndyKelley 4 hours ago | parent [-]

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.

endgame 3 hours ago | parent [-]

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

tylerhou 19 minutes ago | parent | next [-]

Yes.

3 hours ago | parent | prev [-]
[deleted]
SkiFire13 4 hours ago | parent | prev | next [-]

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 3 hours ago | parent | next [-]

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 2 hours ago | parent | next [-]

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 7 minutes ago | parent | next [-]

.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 2 hours ago | parent | prev [-]

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 24 minutes ago | parent [-]

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

VMG 3 hours ago | parent | prev [-]

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 2 hours ago | parent [-]

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 3 hours ago | parent | prev [-]

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 2 minutes ago | parent | next [-]

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

algesten an hour ago | parent | prev [-]

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.

dundarious 7 hours ago | parent | prev | next [-]

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.

rowanG077 7 hours ago | parent | prev [-]

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 7 hours ago | parent [-]

> 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 6 hours ago | parent [-]

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 6 hours ago | parent [-]

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

tcfhgj 5 hours ago | parent [-]

yes, you can:

    runtime.block_on(async { })
https://play.rust-lang.org/?version=stable&mode=debug&editio...
messe 4 hours ago | parent | next [-]

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 4 hours ago | parent [-]

> 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 3 hours ago | parent [-]

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 3 hours ago | parent [-]

what about non-io code?

messe 3 hours ago | parent [-]

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 19 minutes ago | parent | next [-]

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?

messe 13 minutes ago | parent [-]

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.

tcfhgj 3 hours ago | parent | prev [-]

> 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 4 hours ago | parent | prev [-]

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...
tcfhgj 4 hours ago | parent [-]

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

also: spawn_blocking for blocking code

whytevuhuni 3 hours ago | parent [-]

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 38 minutes ago | parent [-]

> 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 32 minutes ago | parent [-]

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