What color is your function? (2015)

133 points by tosh a day ago on hackernews | 176 comments

frankfrank13 | a day ago

My first ever EM showed me this piece ~10 years ago, and I still think about it a lot. One pattern I've adopted is to keep as much code to be synchronous as possible. On larger teams, especially when the slop-cannon is really going, I can at least depend on codeowners to tag me if someone tries to convert something to async (eg. adding a DB call somewhere), because they chain of things that need to be converted to async is so long. Then I can jump in and say "this entire chain of code is sync, if you want a DB call, do it somewhere else"

gordonhart | a day ago

For Python backends I've seen good success with just making it company policy that everything is synchronous (normal-colored) and bypassing the developer overhead from async/await. Cooperative multitasking is a pain because, well, it requires cooperation. You can go pretty far by just adding more threads, processes, and replicas before it's worth the overhead.

graemep | a day ago

I just do not want to do async in Python. If you need async its questionable whether Python is a good choice at all, and if you use Python maybe look at another solution if at all possible (even using more processes and throwing hardware at it).

seabrookmx | a day ago

You not only leave performance on the table (which depending on your use case/environment, may not matter if you can just throw more threads at it) but also some developer ergonomics.

asyncio.gather is a lot less code than having to manage a thread pool or something like Celery with all it's underlying infrastructure.

If you're in an ecosystem where a lot of the async boilerplate is free/cheap (ex: FastAPI) then the developer overhead of sprinkling awaits on your I/O bound calls is pretty low IMO.

bigstrat2003 | a day ago

Performance aside (which I would argue is premature optimization, as most programs will not feel the theoretical overhead of threads), async is a bad approach for developer ergonomics. Threads are so much easier to work with and reason about than async. There are reasons to use async (like if you're in the rare case when thread overhead is noticeable), but developer ergonomics are absolutely not a reason.

seabrookmx | a day ago

Say I need the results from two expensive REST API calls, so I want to run them concurrently. Managing a thread pool you find a _better_ experience than

one, two = await asyncio.gather(callOne(), callTwo())

?

wtetzner | 20 hours ago

Doesn't Python support futures?

    with ThreadPoolExecutor() as executor:
        one, two = executor.map(lambda f: f(), [callOne, callTwo])
I'm sure you could write a nicer helper function that's more similar to gather as well.

seabrookmx | 13 hours ago

Sure, in a web server context you'd probably want to instantiate that executor globally and re-use it, Then you could write helper functions around that. But it's still considerably more code and legwork than the async alternative, while also being slower and using more memory if you care about those things.

ian_j_butler | a day ago

> something like Celery with all it's underlying infrastructure.

Unpopular opinion, but combining this with the other "no thanks" sentiments in this subthread is the right answer. Your app is so complicated you need async? Then it's complicated enough that you can benefit from infrastructure. I don't want to watch coworkers try to badly rebuild message queue or scheduling semantics in an application code base. Just use infrastructure that's made by people who know what they are doing. That was problematic in 2015, but in 2026 it's a bit of docker, and it's not just about web/microservices. Very easy for sufficiently complex apps to simply leverage a local sandbox of celery, redis, graphdb's and whatever. Stand-alone is overrated since we don't have to do it anymore.. app devs should get more comfortable working with ensembles like this so they have access to best-in-class solutions.

You don't like infrastructure AND have such a need for performance AND don't want threads or multiprocess? Consider using another language. Async is mostly a solution in search of a problem, and the enduring popularity of TFA goes to show this has been the right conclusion for ~10 years.

seabrookmx | a day ago

I said nothing about "stand-alone" services. I'm all about using the right solution to the problem. We run on Kubernetes and have access to message queues. But if all I want to do is make a couple of HTTP calls concurrently, I don't think I should have to manage a thread/process pool to do so, or lean on a message queue or redis based RPC mechanism. In an async context I can do this with a single line of code.

Every rich client-side experience in your browser is written using async code in Javascript or Typescript, as is every electron app. Every developer at my company is comfortable with this pattern, and frameworks like FastAPI make this a similarly smooth experience when using Python.

If async was a solution in search of a problem, it wouldn't have been stolen from C# and added to Rust, Python, Kotlin, etc. The engineering effort required to bring this solution to all these languages is immense, so I'm clearly not the only person seeing value in it.

nickcw | a day ago

Go doesn't have colored functions due to its nice fat runtime hiding all the async magic away for us.

That makes it a pleasure to code concurrent stuff for IMHO.

It does have its own similar problems though - does a function return an error? If so you are going to need to plumb the error return through all the callers. Does a function need a context.Context? Ditto.

I guess you can't win them all :-)

And Haskell is an ensemble of rainbows. It's very fun and pretty to look at.

Type classes can smooth over some of it but it's not unusual to have to do some plumbing.

jerf | a day ago

This is a subtle point that I've seen missed repeatedly, but: The reason that "color" is important is that if you have a function ten layers down in your stack that is the wrong "color", you now have to change that top-level function. There is no other option.

Propagating errors up the stack is not the same, because the top-level function is not developing an error return because of the 10-level-nested function. It is developing one because the function it called has one, and apparently, it needs to return it to its local caller. It's a local consideration. It is true that it may be a recursive local consideration where this was true 10 times, but the reason it is different is that it doesn't have to be that way. It could have been the case that the function 7 layers down handled the error somehow and it stopped propagating up the stack. But at each point, the consideration was local, and as such, amenable to local solutions other than just tossing the error up. If you choose to "correctly" plumb the error through all your functions, well, good on you for apparently being willing to apply good software engineering practices even when it's annoying, but this is just normal day-to-day function activity stuff.

By contrast, in a function coloring situation, if the color is wrong 10 layers down, you must change the calling function. It's a non-local consideration. You don't get to decide not to change it. You can't encapsulate it. You don't get a choice. It pollutes the entire stack, forcibly.

Another way to look at it is, if the function 10 levels down developed what you think is a color, but there is a way for the function 9 levels down to hide the color from the rest of the stack, even via a hack like simply dropping an error you really need or hackily constructing an object of some type to pass in, then it is by definition not a color. A color change can't be stopped by any way of writing an intermediate function. It must be propagated all the way up the stack.

If you don't have this, you don't have "color". Like, some people will say that in their language that maybe there is some way to encapsulate "async". If you can, then you don't have an async color. Although I will say that if your "encapsulation" is basically to run it in a non-concurrent environment, that's really not encapsulation. It isn't really "encapsulation" if you're giving up an entire major feature of the language, because that is something very visible to the rest of the program.

Go's context.Context is similarly not a color. You can always just create a context.Background() and pass that down. If you didn't have any context already in hand, which means you must not care about any of the features context offers, then that is usually a fine thing to do. Context is trivially bypassed if you don't want it. It can be encapsulated within a portion of the stack without "polluting" the rest of the stack like any other function parameter.

The key aspect of color is that it is not optional. It isn't something that you can just decide to ignore and stop passing up, or trivially create a value for passing down to other functions. You have to change the "color". Async is a color in many environments. There aren't really that many colors in programming languages because they are very, very quickly inconvenient and we tend to squeeze them out. (Haskell really sticks out here as a language that is not only capable of creating arbitrary colors, but where this is an explicit tool used by the community rather than a limitation, and they even have ways of combining colors together deliberately.) Statement versus expression distinctions are another one, where a "statement" may not be usable in an "expression", and you'll note how languages have in general erased that one over time because it's really just a cost without much benefit.

lukax | 23 hours ago

That's just not true. Let's say you have a form validation library with a public api that supports custom validators Validate(name string, value string) bool. Then you decide that your validator now needs to make an HTTP request. This request needs context so that tracing is propagated and needs to return (bool, error) so that error is propagated up instead of silently ignoring it or logging it and returning false. This is coloring. You can use context.Background the same way you can use blocking in other languages. It just doesn't feel right and it breaks things.
If "caring about arguments" is "color" then the concept is useless and who cares? If everything is a "color", nothing is.

The color concept is interesting precisely because it isn't just "arguments to functions". The difference I describe is a real one that has real effects at scale. Trying to collapse it down to "it's just function arguments" doesn't make it go away, it just makes it so anyone who refuses to draw the distinction loses the ability to see it. It's not a good idea and it's not a good argument. You're just smearing vaseline on your eyes.

gpderetta | 2 hours ago

With (unchecked) exceptions the custom validator can abort the whole stack non locally and transport the error to the original call stack, the validation library being none the wiser.

With stackful coroutines, the custom validator can transport the error to the original call site, handle it and resume back into the validation library if the error was recovered, or abandon the validation (as with exceptions) if unrecoverable.

With HKTs, the validation library can be agnostic on the specific return type of the custom validator.

hutao | 20 hours ago

> Propagating errors up the stack is not the same, because the top-level function is not developing an error return because of the 10-level-nested function. It is developing one because the function it called has one, and apparently, it needs to return it to its local caller. It's a local consideration ...

> By contrast, in a function coloring situation, if the color is wrong 10 layers down, you must change the calling function. It's a non-local consideration. You don't get to decide not to change it. You can't encapsulate it. You don't get a choice. It pollutes the entire stack, forcibly.

I think this is an interesting perspective, where I would raise a counterpoint. Both result types and async/await are instances of monads (the abstraction which approximates the article's idea of a function color, since you mentioned Haskell, I assume you know this). Just as you can "eliminate" the result type by explicitly handling the success and error cases, you could, theoretically, "eliminate" the async function by blocking on it. Doing so would treat the entire async subprogram, at the top-level function boundary, as synchronous IO, while the async subprogram would still benefit from concurrency internal to the function.

Compare Example #1:

    int topLevel() {
      return match fallibleSubprogram() {
        Ok(()) => 0,
        Err(_) => 255,
      };
    }

    Result<(), Err> fallibleSubprogram() {
      let x = f()?;
      let y = g()?;
      return h(x, y);
    }
Compare Example #2:

    int topLevel() {
      block_on(asyncSubprogram);
      return 0;
    }

    async void asyncSubprogram() {
      let promiseX = f();
      let promiseY = g();
      let [x, y] = await Promise.all([promiseX, promiseY]);
      return await h(x, y);
    }
In the above pseudo-code, you have the same program "structure," but the first uses results and the second uses promises. In the latter example, asyncSubprogram() gets called as if it were synchronous, but you still benefit from asynchronicity because f() and g() can execute concurrently within its body.

The main difference is that compared to pattern matching on Result types, programming languages typically make it unidiomatic to block on a promise. There are various reasons why this is the case, but my point is that Result types and async/await are more similar than they may initially appear.

"Just as you can "eliminate" the result type by explicitly handling the success and error cases, you could, theoretically, "eliminate" the async function by blocking on it"

You really can't. Shutting down the async loop doesn't just do damage to the performance of the program, it can actually affect correctness. It doesn't fix the color problem. That's why I said 'Although I will say that if your "encapsulation" is basically to run it in a non-concurrent environment, that's really not encapsulation. It isn't really "encapsulation" if you're giving up an entire major feature of the language, because that is something very visible to the rest of the program.'

Monads actually aren't really relevant, either. A monad can express something you can't escape from, but it isn't required; "Option" isn't a color because you can still deconstruct it any time you like. It's specifically IO, which traps you not because it is a "monad" but because it has no escape hatches at all. (Modulo "unsafe", which is always something we have to say, but we also always tend to ignore unsafe in these discussions because otherwise everything collapses to one big unsafe pile in all languages of note.)

seabrookmx | a day ago

Same with the BEAM languages like Erlang, Elixir, and Gleam. Though it still bothers me that they call their green threads "processes".

chiffaa | a day ago

That's mostly because BEAM uses an actor-style approach while predating the concept of actors, isn't it? Interesting artefact of history if so

Edit: upon rechecking, apparently that's not exactly right, and Erlang designers learned of actors after designing the language, which makes it all the more interesting

zokier | 23 hours ago

Actors predate both Erlang and BEAM by significant factor

ihumanable | 23 hours ago

I've spent the last decade in erlang / elixir / OTP. I think a lot of the naming comes from the early use of erlang as effectively an "OS" for telecom switches.

I always joke that BEAM wants to be the operating system.

Munksgaard | 22 hours ago

The terminology in Erlang predates green threads by a decade or so.

klas_segeljakt | 12 hours ago

They are (lightweight) processes since they have no shared memory. Each process has its own stack and heap.

notnullorvoid | 20 hours ago

I'd argue that Go and all other implicit async approaches do have function colours. You're much less likely to notice the colour, but in the edge cases where it can be noticed such systems are harder to work with.

preommr | a day ago

> You still can’t call a function that returns a future from synchronous code. (Well, you can, but if you do, the person who later maintains your code will invent a time machine, travel back in time to the moment that you did this and stab you in the face with a #2 pencil.)

Author makes up a lie.

Then lampshades it away with a colorful non sequitur.

---

The alternatives that people praise like golang, have other tradeoffs that are much worse because the async logic is now implicit. Your entire codebase is now a surface area that is at risk of being blocked by waiting on a channel; the the mitigation of this is through responsible use of coroutines, but then you're right back around to extra information about your code that is analogous to colring, except not as explicit as async/await.

assbuttbuttass | a day ago

> Your entire codebase is now a surface area that is at risk of being blocked

The point of goroutines is that they can freely block when needed. It's not like async where you have to be paranoid at every moment about writing blocking code

stymaar | a day ago

You can only freely block the goroutines that you designed that way, there's plenty of ways of shooting yourself in the foot with goroutines without even touching “blocking” code (because everything is blocking).

sheept | 23 hours ago

Why would there be paranoia when writing blocking code with async?

The downside of goroutines is that you have no control when the goroutine context switches, so naively accessing a global value can lead to race conditions (which the language has no warnings for despite being such a concurrent language), while the same code works fine in JavaScript because context switches don't happen in synchronous code.

assbuttbuttass | 23 hours ago

> Why would there be paranoia when writing blocking code with async?

In languages like JavaScript, you have to be careful to avoid blocking the event loop, and use something like worker threads for CPU-intensive tasks. Otherwise you will end up with long tail latencies. In Go, the runtime automatically manages this and can suspend and resume long-running goroutines.

> naively accessing a global value can lead to race conditions

Fair point that the language doesn't automatically catch this, but that's what a mutex is for. In return you get actual parallelism that can use all your CPU cores

Jtsummers | 23 hours ago

Amusingly, Go, a language designed for concurrent programming, also had problems with blocking code for years. They had two releases that fixed it with proper preemption (1.2 added preemption, and 1.14 fixed other issues with preemption).

shawnz | a day ago

Your entire codebase is already at risk of being blocked by a spinlock or CPU-intensive operation, so what's the difference?

dullcrisp | a day ago

If you haven’t taken a lock, any other code can start executing at any time, so any invariant you might have established on one line may no longer be true on the next line.

If you don’t depend on anything mutable that anyone else can modify then this is mitigated, but that’s a very specific discipline you have to abide by.

overgard | 20 hours ago

I don't see how that's a lie; calling an async function from synchronous code is generally a mistake. There are cases where it's appropriate but it's rare

satvikpendem | a day ago

We need algebraic effects in more languages, this solves the function coloring problem. OCaml 5 has them and it seems to be doing quite well, combine that with the semantics of the borrow checker in the form of OxCaml and we might just have an ideal language. I'd like to see algebraic effects in Rust as well but sadly it seems their keyword generics initiative is languishing.

Related, one of the former React maintainers wrote a primer on algebraic effects that's a good read: https://overreacted.io/algebraic-effects-for-the-rest-of-us/

zokier | a day ago

I don't think effects alone solve function coloring problem, in worst case they make it worse because every library can have its own colors

wtetzner | 20 hours ago

Effects solve it in the sense that the caller can handle the effects to work however they like, making the functions that use the effects more reusable.

E.g., if you install IO handlers that are async and call a function that does IO, it's now an async function.

bre1010 | a day ago

I wish the key word was instead dontawait and was used inversely to how await is used. 99% of the time I'm using an async function, despite however slow it is, there's nothing for my code to do but wait for it to finish. But if for some reason I would like the next line of code to run before the current one is done, I'll let you know.

Like, why can't my sync function await something asynchronous? If it has to lock up the whole thread while that function executes, that's fine because that's how it was going to work anyway 99% of the time

axus | a day ago

Like the & at the end of a shell command?

enragedcacti | a day ago

> Like, why can't my sync function await something asynchronous?

The answer, at least for Python, is that it is an intentional limitation because the alternatives introduce some quite bad trade-offs.

Option 1: your awaited promise goes into the main async event loop. This is bad because it means that your single-threaded sync function now needs to be thread-safe, and so does any sync code that calls your sync function despite it not even knowing that you're doing anything async. This is essentially unworkable without throwing away the option of writing non-thread-safe code.

Option 2: Your awaited promise goes into its own new event loop that only contains sibling and child promises. There's nothing technically stopping someone from doing this[1], but now you've lost a ton of the value of async because you will inevitably end up with a ton of siloed event loops that leave the process idle despite other async tasks existing that could run. Effective async code needs to share an event loop at as high of a level as possible, which means tainting as many methods with async as possible. At that point, you might as well enforce it at the language level and avoid the inevitable pain and fragmentation that comes from other devs across the ecosystem mixing sync and async code.

[1] https://pypi.org/project/nest-asyncio/

As explained by Guido: https://github.com/python/cpython/issues/66435#issuecomment-...

zokier | a day ago

I think the downsides of option 2 are overstated here. In lots of cases you don't care about the "value of async", you just want code that works well enough and option 2 does accomplish that in anything that is not perf critical.

enragedcacti | 23 hours ago

I agree in isolation, and I have used nest-asyncio a couple of times where it really was a lot easier than the alternative, but from an ecosystem perspective I'm glad it isn't the default. Most of the time someone wants to do this it's a junior trying to work around a non-issue (e.g copy-pasting from a guide that includes asyncio.run()), and the trade-off is a massively increased surface for performance footguns throughout your code base and all the libraries you use. Linters could save you from the first case but it would be a lot more work to profile, track down, and fix spots in all your dependencies that cause your event loop to get fragmented.

tabwidth | 19 hours ago

It may depend on the runtime giving you a sync wait that doesn't deadlock the loop you came from. In JS you just can't. `dontawait` would need V8 to be a different VM.

gpderetta | 2 hours ago

Option 1 could be easily solved by having an atomic {} blocks that statically error if call any potentially async function in it. This is better as it document where an externally visible invariant is temporarily broken (i.e. reentrancy is required), instead of being implied by the code and potentially being broken a a future code change.

Implicit thread safety across async blocks of course break if you introduce actual shared multithreading in the language, while if you have atomic blocks at least you can build transactional memory on top.

sheept | a day ago

At least in JavaScript, it's nice to be able to see explicitly where you can expect the function to yield, so it's clear when race conditions can occur, or if you're calling it in a loop, whether you should consider running things in parallel.

Plus, you probably don't want to lock up the whole thread if you're writing anything more than a quick script, like a web server or a GUI.

ameliaquining | 15 hours ago

Probably more importantly, in browser JavaScript locking up the main thread means making the page unresponsive. If your script needs to make a network call, there may be nothing for it to do but wait until that call returns, but the user still needs to be able to scroll, click, etc., while that's happening.

This is why asynchronous I/O APIs became prevalent in JavaScript (initially with callbacks, with promises and then async/await syntax added later to make things nicer). Ryan Dahl then realized that this could also be used in a server-side context and would thereby solve the C10K problem, and so Node.js was initially designed with that same discipline (which was later relaxed a little with APIs like fs.readFileSync).

If Brendan Eich had realized in 1995 that this was how things were going to work, perhaps he'd have added green threads and browser events would spawn new ones (and so could block without locking up the page), but that's not the order things happened in.

SatvikBeri | 22 hours ago

Julia does this – you generally write synchronous, single-threaded functions most of the time, and can use code like `t = @spawn foo(b)` to get a Task, and then `output = fetch(t)` to wait for it and get the value.

I like this general approach a lot, it's overall quite nice for Julia's core use case of number crunching, it means you typically make decisions around concurrency at the call sites. Though it does rely heavily on Julia's runtime, and it can be a bit difficult to figure out what's going on under the hood.

gpderetta | 2 hours ago

See also Cilk/Cilk++

Weebs | 21 hours ago

Waiting for async to finish and await are two different things. Async functions essentially have a different calling convention than standard functions. They're either converting your code into continuation passing style, state machines (C#, Rust), or using some sort of stack save+restore usually.

I agree we shouldn't need to `await` everything though. Effects with inference and implicit perform/await is possible

moth-fuzz | 13 hours ago

This is so true. In webgpu, the functions to request a GPU device / GPU adapter are both async, and I often wonder, what is my engine going to do in the few milliseconds before it's grabbed a handle to the GPU? It can't render anything, it can't load anything... If I really had to guess I would think it's so that when compiled for web, the page doesn't lock up when the browser is showing the "allow this site to access your GPU? yes/no" popup. But it makes far less sense in desktop land.

gpderetta | 2 hours ago

I know approximately zero about webgpu, but I assume it allows for pipelining.

mgoetzke | 11 hours ago

You mean like you just store the Promise in a var and await after you are done with the rest (JS like) ?

virexene | 10 hours ago

That's basically exactly what Go's `go` keyword does. Good design in my opinion.

Timwi | 7 hours ago

> Like, why can't my sync function await something asynchronous?

It can in C# (just call .Result). I'm not sure why other async/await languages, like JS, don't just add that too.

gnarlouse | a day ago

> Spidermouth the Night Clown

Thanks for my next horror shortfilm plot. Twist: he's the protagonist

qihqi | a day ago

Colored functions are good. It reflects the language design on signaling what is important, and what are the properties it want the writer to pay attention. Other examples of colored functions:

* Haskell: pure function and non-pure (IO monads) looks different. * Rust: unsafe functions (or block) requires special markers.

clintonc | a day ago

My mind went to Java's checked exceptions -- not sure if anyone today believes that coloring is still a good idea.

zokier | a day ago

The problem with checked exceptions afaik was far more in the execution than in the idea itself. And also late 90s-early 00s was different time in general.

vips7L | 21 hours ago

Java just makes them hard to use. They're not fully apart of the type system and they're hard to escape when you actually want to panic. Everyone around here praises Rust's result, checked exceptions are the same idea:

    fn someFn() -> Result<T, E>
    T someFn() throws E
    fun someFn(): T | E  // Kotlin's proposed error unions

Checked exceptions actually compose a little better when you have a function that can throw multiple types:

    T someFn() throws E, F, G
This is like a union type of E | F | G. I don't know about Rust, but most languages won't let you do that over generic types like Result<T, E | F | G>.

The main problem for Java's checked exceptions is just how boilerplatey they are, especially when you can't handle something. In Java if you need to become "unchecked" or panic you need to:

    try {
        someFn();
    } catch (SomeException ex) {
        throw new RuntimeException(ex); // dunno panic
    }
Ideally that would just be:

    someFn()!!!!; // shut up compile panic if this happens

steveklabnik | 21 hours ago

Rust makes you define an enum of E, F, and G, but also provides a conversion API so you can pass any of the three and it feels like it does, at least at the site of returning the error.

It also provides an error interface so sometimes you don’t need the enum, if all the types return that interface.

vips7L | 20 hours ago

Wouldn't you lose a little compile time safety a little by returning the interface, like catching Exception?

i.e. as types you don't know about get introduced the compiler won't stop bad things from happening:

    catch (Exception ex) {
        switch (ex) {
            case SomeException1 se1 -> ..
            case SomeException2 se2 -> ..
            default -> throw new IllegalStateException(ex); // panic
        }
    }

steveklabnik | 20 hours ago

Depends on what you mean by "safety," what this is really about is open vs closed set. An interface means that there's an open set of things that could be returned, whereas an enum is a closed set. Which one is correct for you depends on your code and requirements.

It's true that if you return an open set of things, you'll have to handle cases you didn't explicitly account for.

vips7L | 18 hours ago

I just meant knowing what errors can be happen at compile time vs unchecked errors flying about. I personally am not a fan of the compiler not stopping me when a new error type is introduced. Correctness is probably the better word.

jonnytran | 21 hours ago

Some Rust libraries have started to implement unioning multiple error types and handling a subset of them while propagating the rest. But as far as I know, the idea hasn't caught on. Here are the crates I know of.

https://github.com/komora-io/terrors

https://github.com/mcmah309/eros

The biggest problem is that you can't abstract over them. Try to write Array#map in Java, you can't - you have to write copy-paste variants for 0, 1, 2... different types of exception until you get bored.

vips7L | 13 hours ago

Yeah! I eluded to that. Exceptions aren’t fully in the type system. I’m hopeful that they’ll eventually let exceptions be some sort of union type fully. Currently, they’re unions in throws clauses and catch clauses.

Personally for me it’s not that big of a deal. The thing I want the most is having null in the type system.

jeroenhd | 9 hours ago

> // dunno panic

This is exactly why Java is such a pain to work with.

Somehow, Java developers all decided to stop dealing with error conditions and just crash the stack whenever something weird happens.

The way Java developers seem to work these days has a lot in common with Rust beginners that just `?` or `.unwrap()` every single fallible method. Random crashes ("RuntimeException") are acceptable, so nobody even bothers doing error handling any more.

Even the base SDK doesn't really bother with handling exceptions (i.e. the story with streams + exceptions). It's an excellent language feature tainted by a combination of bad choices twenty years ago and a weird culture shift in error handling.

vips7L | 4 hours ago

There’s plenty of errors that aren’t able to be handled. But yes I agree most developers don’t understand exceptions. They seem to be scared of them tbh and there’s lots of bad advice floating around about them. Like “exceptions should be exceptional” or “don’t use exceptions for control flow”. I am beginning to see a culture shift as the old guard dies out though.

sheept | 23 hours ago

Rust unsafe functions aren't a good example of colored functions because it doesn't exhibit the main issue brought up in the article, that one color can call the other but not the other way around.

In Rust, unsafe code can call safe code, and safe code can call unsafe code. Calling unsafe code in safe code requires an explicit unsafe block, but that's fairly normal and not a hack to get around function coloring.

A better example could be Rust async, though unlike JavaScript, you have the option to block the thread on an async function in a sync function.

simonask | 18 hours ago

I don’t really see how declaring an unsafe block is materially different for the purposes of this discussion than, e.g., entering an async runtime.

It is code you need to write, tradeoffs you need to weigh, invariants you need to keep.

xigoi | 12 hours ago

In Rust, if you have a function containing an unsafe block, you do not need to use another unsafe block to call the function. Therefore, unsafe is not “contagious” like JavaScript’s async.

jeroenhd | 9 hours ago

> you do not need to use another unsafe block to call the function

And in C#, you can just type `await` and call an async function from a sync function.

Calling unsafe requires an unsafe block from safe functions. That's essentially the same thing as async/await in many languages (Rust does things differently, of course, but that's even worse in my opinion).

xigoi | 8 hours ago

> And in C#, you can just type `await` and call an async function from a sync function.

Yes, but not in JavaScript.

pornel | 14 hours ago

Rust's sync functions can block and await async functions.

Which is another problem with the article: it doesn't clearly define what counts as having the "color". The problematic dead-end situation exists in JS, but languages with cross-thread communication can work around it.

rendaw | a day ago

I really don't like this article. It has a catchy, profound-sounding title that people bandy about to argue against stuff they don't like.

All functions, even non-async functions, are colored. In any large system codebase you'll have functions that can only be called in certain situations, with the right setup, whatever, and if you're lucky this is communicated by types but regardless those restrictions can't be avoided. It's easy to call low-restriction functions from high-restriction ones and not the other way around.

Furthermore, it's not like the alternative to explicit await doesn't have issues too (that the article doesn't mention). There is inherent complexity, it's a tradeoff, you can't just syntax it away.

Every time this is posted, it’s worth reminding: async functions in JavaScript are the correct design, and the people who did it deserve praise.

moomin | 23 hours ago

Making await flatten promises was kind of questionable. But that’s my main beef.

tikotus | 22 hours ago

C# (and F# before it) got it (mostly) right. JS did a shallow copy of it, messing up some details, making it harder to use for certain things.
Async functions are an ad hoc, informally-specified, bug-ridden, slow implementation of half of monad support.
Yep. It's not an async vs not async thing. The way some people talk about it, you'd think the async keyword was at fault. It's all about whether a function is callable in some context.

Passing in the context as an argument or making it a global variable or returning a monad doesn't do anything to uncolor the function. What's the difference between `async function f()` and `function f(eventloop, callback)`? Only syntax.

Not to mention there's lots of colors unrelated to async, that most languages don't type at all. And if you use the wrong one, your program just doesn't work correctly at runtime. Thread-safe vs thread-unsafe. Blocking vs non-blocking. May throw/panic vs won't throw/panic. May fail/return null vs infallible.

deepsun | 23 hours ago

I believe a clearer example would be: `async function f(): Foo` vs `function f(): Future<Foo>`. Isn't it how it works inside anyway?

eikenberry | 22 hours ago

> Passing in the context as an argument or making it a global variable or returning a monad doesn't do anything to uncolor the function. What's the difference between `async function f()` and `function f(eventloop, callback)`? Only syntax.

"Only syntax" is assuming, mistakenly, that syntax doesn't matter.

Also there is a big semantic difference there.. that being in one case you have the flexibility of the passed in parameters taking different forms vs. the static 'async' statement.

It is not strictly an async thing, but a general rule that additional keywords are less powerful than parameters in all cases. Ask any Lisp developer what the difference is..

dnautics | 21 hours ago

> What's the difference between `async function f()` and `function f(eventloop, callback)`? Only syntax.

Negative.

what is the "async prefix" equivalent of the following?

global e: eventloop;

noasync fn parent()

  childfn(e)
end

epestr | 16 hours ago

Ante has some points on this issue: https://antelang.org/blog/why_effects. All of this is just different syntax in other languages and solved but the abstraction provided seems to be neater.

jcranmer | 23 hours ago

What I like least about this article is that it's completely soured the entire context of asynchronous programming. Invariably, any time someone discusses design of an async functionality, function coloring is brought up, with almost no analysis as to how it applies and why it's a good or bad thing. (Ironically, I probably see more in-depth analysis these days as to why this article isn't apropos than why it is when this happens.) It's just reduced to "anything that makes a separation between async and sync is function coloring and that's automatically bad." The existence of any sort of trade-off, or really, the entire meat of the article, is just completely ignored.

One thing that can be better called out is that this issue of function coloring isn't just an async problem. Exceptions cause function coloring--and not just Java's controversial checked exceptions. An infallible/fallible domain split is function coloring. Javascript's async handling is called out not because it's doing the function coloring but because--in 2015--the tools that existed for dealing with async code in JS libraries were really, really bad, largely reliant on callback hell. Promises and the async/await keyword fix most of the issues, and the ones that aren't fixed boil down to the fundamental issue that an asynchronous event-loop model and a synchronous batch model are just different programming paradigms to begin with.

this article seems like a nice view into what things were like in 2015 but we've come along way since then.

dnautics | 21 hours ago

what I like least about this article is how people seem to just substitute some other, usually theory, notion of what function coloring is to elide the argument (usually to excuse their favorite PL) without actually RTFA. The article is about ergonomics, not PL theory.

> Exceptions cause function coloring

do they? Do they?

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

nemothekid | 21 hours ago

Java's checked exceptions fit the 5 criteria:

1. It either `throws` or it doesn't

2. If the function `throws` you have to wrap it in try/catch, or make your function `throws`

3. Your function is `red` if it `throws` the same exception.

4. see (2)

5. See the FileReader class in core.

Now, C++ exceptions might not satisfy all of these, but the problems CheckedExceptions were meant to solve still exist in C++ and as a result some style guides forbid them entirely. Like async, the biggest problem with exceptions were the ergonomics.

dnautics | 21 hours ago

Ok, sorry it's been about 20 years since I last javad IIRC you didn't have to declare exceptions in your function signatures. However, wrapping in try/catch seems to violate #3. Try catch is not a heavy lift of a seam between red and blue

To be fair, #3 seems to have shades of grey. In some pls, you can call an async function from a sync one by wrapping it in a whole damn event loop system. Should that count?

Terr_ | 21 hours ago

> Ok, sorry it's been about 20 years since I last javad and you didn't have to declare exceptions in your function signatures.

You're probably remembering RuntimeExceptions, which are a subgroup [0] that are exempt from "checking" by the compiler, which means it does not require method signatures to declare "I might emit this."

[0] https://docs.oracle.com/en/java/javase/26/docs/api/java.base...

vips7L | 14 hours ago

You can declare your runtime exceptions too. The compiler won’t enforce you to catch them though.

    void fn() throws IllegalStateException

nemothekid | 19 hours ago

Your question just kicks the can down the road. The problem with the article, to me, is the author doesn't want to accept a certain amount of complexity and has erected arbitrary road blocks.

To you, a "whole damn event loop system" is too high a price to pay, but try/catch is not. The complexity of exceptions is invisible to you. However there are certain environments (e.g. FFI) where I dont want "the whole damn exception runtime".

dnautics | 17 hours ago

i mean no? the coloring problem appears to be solvable in zig. i maintain an FFI binding library for the BEAM vm, where once zig finishes its stackless coroutine support, the same zig function written once should be fully interchangeable between non-async, threaded-async, or async-with-yieldpoints-wrapped-in-the-BEAM's-scheduler.

melagonster | 14 hours ago

Maybe author do not want to hide the complexity in functions?
> Try catch is not a heavy lift of a seam between red and blue

> To be fair, #3 seems to have shades of grey. In some pls, you can call an async function from a sync one by wrapping it in a whole damn event loop system. Should that count?

I think you have to count any extra overhead where you can't just write f(), including try/catch. It's always possible to call whatever kind of function from whatever other kind of function if you put enough effort and hackery in, so if we can't use functions of kind x in functions of kind y as normal "f()" function calls then that has to be what we mean by colouring.

sheept | 21 hours ago

#3 is not satisfied, as you noted in #2. You can call `throws` methods from non-`throws` methods by wrapping the call in a try catch, and `throws` methods can call non-`throws`. There isn't an exclusivity asymmetry like there is for JavaScript async.

nemothekid | 19 hours ago

That only applies to Javascript, which, is mostly only red functions anyways (there are no blocking apis in javascript). Javascript doesn't have the coloring problem in the way Python or Rust has it.

In Python, you can wrap the call with asyncio.to_thread, in rust with tokio::spawn_blocking.

sheept | 18 hours ago

I think you got it backwards: JavaScript has the coloring problem while other languages don't.

"Red functions are more painful to call" alludes to async functions. Every await yields back to the event loop, which adds overhead. Making every function red/async adds a performance cost (and makes it harder to reason about race conditions), which is why JavaScript has a mix of blue and red functions.

Other languages can escape the "red functions can only be called by red functions" trap, like Python asyncio.run or Tokio block_on. JavaScript has no such alternative, not even in Node. Therefore, Python and Rust don't have function coloring, but JavaScript does.

vips7L | 14 hours ago

I think the big difference is that in an application that cares about throughput and being non-blocking simply blocking isn’t really possible as it affects the whole performance of the application. Wrapping a checked exception in an unchecked one doesn’t do that.

Terr_ | 21 hours ago

> Like async, the biggest problem with exceptions were the ergonomics.

I know it's not a popular take, but I prefer the idea of Checked Exceptions over unchecked ones [0], and suspect current opinions would be vastly different if Java had shipped with some sweet syntactic sugar for: "If an exception that is of kind A or B or C occurs, automatically throw another checked exception X with the original exception as a cause."

> Ex: If I'm writing a tool to try to analyze and recommend music that has to handle multiple different file types, I might catch an MP3 library's Mp3TagCorruptException and wrap it into my own FileFormatException.

This would reduce the temptation for developers to ruin the type-safety characteristics by wrapping everything in a RuntimeException just to get the ticket out the door.

[0] https://news.ycombinator.com/item?id=42946597

skybrian | 20 hours ago

The problem with Java's checked exceptions is that it has too many kinds of exceptions to choose from and they're overly specific. Compare with Go, which has a single error interface and had it from the beginning, so it's used everywhere. Returning a new kind of error is always a local change, unless it's a function that didn't previously report errors at all.

Type systems permit either standardization or fragmentation and that's an ecosystem issue. Another example is that a language without a strong consensus on which string type to use will result in a fragmented ecosystem when each library goes its own way.

Terr_ | 20 hours ago

> too many kinds of exceptions to choose from

I don't understand, why would you need to pick a checked exception? It's the dual or mirror of feeling paralyzed over a return-type because there are "too many kinds of Object to choose from."

If you're writing a CrystalBall class with a gaze_deeply() method, you'll probably return your own VisionResult (extends Object) unless it throws your TooCloudedException (extends Exception).

When someone else writes a wrapper or higher-level layer that uses your code, then it'll be up to them to convert or wrap those results and exceptions into something suitable for their level of abstraction.

> Returning a new kind of error is always a local change

One of my axioms here is that return-values and checked-exceptions are two sides of the same architectural type-system coin. While I'm not familiar with Go, that sounds like something that would be a symptom of bad architecture if it occurred for return values.

In other words, suppose all Java methods always returned Object [0]. That would also ensure that a new return type is "always a local change" to the compiler, but I think most developers would be rightly horrified if they came across code that worked that way.

[0] Let's ignore Java primitives for now.

skybrian | 17 hours ago

This isn't like returning Object. It's more like returning a String. After using a language with a common String type, who wants to go back to writing code to convert between between different kinds of strings? Having to choose among different string implementations because there's no standard usually leads to boilerplate code doing conversions at the borders.

Usually you just want to propagate or log errors, so having a generic error interface is sufficient. It's true that in Java, you can wrap exceptions, but that's extra boilerplate.

(And yes, Go does notoriously have error propagation boilerplate that they should fix, but that isn't a type system problem.)

> you'll probably return your own VisionResult (extends Object) unless it throws your TooCloudedException (extends Exception).

> When someone else writes a wrapper or higher-level layer that uses your code, then it'll be up to them to convert or wrap those results and exceptions into something suitable for their level of abstraction.

Why though? What do you gain other than longer stacktraces with all those wrappers? People always trot out some theoretical notion that a caller is going to catch that framework's different exceptions and handle them differently, but have you ever seen calling code that actually did that?

> In other words, suppose all Java methods always returned Object [0]. That would also ensure that a new return type is "always a local change" to the compiler, but I think most developers would be rightly horrified if they came across code that worked that way.

There are many different kinds of values. There really aren't that many different kinds of error - there's "transient error that you might want to retry", "programmer called the API wrong", and that's about it, most other cases (like bad user input) probably shouldn't be exceptions.

X0Refraction | 7 hours ago

> Why though? What do you gain other than longer stacktraces with all those wrappers? People always trot out some theoretical notion that a caller is going to catch that framework's different exceptions and handle them differently, but have you ever seen calling code that actually did that?

You've never seen a try that has more than 1 catch block for different exception types?

> There are many different kinds of values. There really aren't that many different kinds of error - there's "transient error that you might want to retry", "programmer called the API wrong", and that's about it, most other cases (like bad user input) probably shouldn't be exceptions.

Do you think bad user input should be a result type? Because exceptions are essentially the same thing.

You've hit on a couple of problems with exceptions in Java though. The first is I think the default for checked exceptions should be no stack trace. As the designer of the method you've left it to the caller to decide to handle it or not, if they choose to turn it into an unchecked exception then I believe that is where the stack trace should start from. Assuming there's enough context in the checked exception the designer of the method gave you everything you needed to handle it so why do we need to capture that part of the stack trace? If it ends up getting logged the source of the issue was where it was changed to an unchecked exception.

The other issue comes down to usability. Try catch blocks aren't expressions so if you want to default something in the case of a checked exception it's a lot of low information density lines. Converting to an unchecked exception is also more ceremony than it really needs to be, but there's not really a reason why it couldn't be made simpler with some syntax sugar.

ameliaquining | 15 hours ago

The problem with checked exceptions is that they don't compose with the rest of the type system. Hence the infamous problems with things like Streams. Result types have basically all the virtues of checked exceptions without the problems.

vips7L | 14 hours ago

Result types do have one problem that checked exceptions don’t. Checked exceptions automatically combine into union types in a throws or catch clause. I haven’t seen a language that lets you be generic like that.

    T fn() throws E, F, G
vs

    Result<T, E | F | G> // not even Rust lets you do this.

ameliaquining | 12 hours ago

That's more a consequence of Rust needing its tagged unions declared up front so it can lay them out consistently in memory without runtime type information. Python and TypeScript have untagged unions (that are discriminated at runtime by the RTTI attached to all objects in the underlying dynamic language); they don't happen to have an equivalent of Rust's ? operator, but if they did it'd work like you're describing.

steveklabnik | 4 hours ago

"untagged union" usually means "no discriminant" not "runtime discriminant."

(Rust has both tagged (enum) and untagged (union) unions, but untagged ones are unsafe and therefore mostly used for C interop and similar cases.)

steveklabnik | 4 hours ago

The E | F | G could be two different features, "anonymous sum types" or "union types".

TypeScript is an example of a language with union types: https://www.typescriptlang.org/docs/handbook/unions-and-inte...

gpderetta | 9 hours ago

Exceptions are a very good comparison because they also perform non-local control flow.

Checked exceptions are a form of coloring, while unchecked aren't. But Java (which has checked exceptions) has an escape into unchecked land in the form of RuntimeError, most async languages do not, short of spawning a background thread (for sync->async) or force blocking (async->sync).

Interestingly, Result<T,E> based error models are semantically (and even syntactically, mostly, except for the call-site annotation) equivalent to checked exceptions. Usually these languages have enough abstraction capabilities (HKT for example) to make coloring not an issue, or, again, an escape into unchecked land (for example panic in rust or Go, although the latter hardly counts as having Result-like error handling).

jcranmer | 21 hours ago

Let's go through the list:

> 1) Every function has a color

Every function either throws an exception to indicate failure or doesn't. There's actually several different function colors available here, based on how failure is indicated: throwing exception, aborting the process, composite return value, error code return value, global errno-like variable, error code as a parameter, ....

> 2) The way you call a function depends on its color

See above.

> 3) You can only call a red function from within another red function

Some of the failure methods, like aborting on failure, cannot be converted to another mode at all (or only with very great difficulty). Others, like exceptions and errno-based routines, come with environmental constraints that could be contained by an error conversion routine in theory but may be precluded due to how the system as a whole works (e.g., a global variable errno doesn't play well with threads). Which isn't quite the same thing, but then again, "red function" here is async function, and the call-async-from-sync variant is the easier one to pull off (you spin the event loop), and has roughly the same issues as trying to box an exception routine: it only works if the system as a whole has mechanisms to make it work.

> 4) Red functions are more painful to call

Okay, you've got me here... the exception routines are the easier ones to call, syntactically than non-exception-based ones. Internally in the optimizer, however, exceptions are definitely the worst form (even errno somehow ends up working out better, and that's also deeply problematic).

> 5) Some core library functions are red

Oh yes, standard libraries love using a mix of all of these error-handling routines. Look up C++ <filesystem> for example.

tardedmeme | 20 hours ago

Failing is a color, but throwing an exception isn't. An exception-throwing computation can easily embedded in a computation that doesn't throw - you can catch and return null, etc. But very rarely can a computation that may fail be part of a computation that may not fail.

tardedmeme | 20 hours ago

What I like most about the article is that it drove the conversation to realising that async is just a poor reimplementation of threads, and put the focus back on how to do threads faster.

dnnddidiej | 20 hours ago

Probably you are right except for some pathological scenarios. Threads and green threads and models where you have have 10000s of threads and not even hit the cardio fat burning zone.

dnnddidiej | 20 hours ago

I am so happy I have never heard anyone IRL say colored functions. It would annoy me. The concept is interesting but like all engineering it is a trade off. In Node amd Go you don't get a choice anyway. In C# you might choose based on performance thinking of thread pools etc IIRC.

When programming in Node I find in practice async and "colored functions" no issue especially with async await. Except for performance issues they come with sometimes but not at a programming level.

> When programming in Node I find in practice async and "colored functions" no issue especially with async await. Except for performance issues they come with sometimes but not at a programming level.

JS solves this problem in two ways in the ecosystem:

- basically saying "all functions must be async" in practice

- allowing you to await a non-awaitable ("await 3" is valid)

so library authors can "force" async/await, but users don't actually have to interact with it when they don't need to. But "everything" being async/await means it's all 'basically fine' anyways

There's also the fact that JS libraries tend to be "pass in a bunch of callbacks" vs, say, Python's "override this class". It makes it much easier for libraries to have everything be async and have it really not get in the way.

Python libs tend to have much larger API surfaces due to how OOP works. So async-y internals works are harder to isolate cleanly without breaking the public API. But if you make your API "async-first" then the debugging experience in Python is miserable (try pdb'ing your way through awaitables....)

Even here though there are problems. For example, I've tried in the past to replace some lib with a more performant WASM-y thing. But it couldn't be a drop in replacement because the original library was a sync-only API, and the replacement was async!

Something very silly: you write "function add(x, y) { return x+y }". A bunch of people do things like "add(add(x, y), z)" everywhere. You find out you could make "add" "better" with async/await. You now have to get all callers to rewrite.

So what everyone does is just throw _everything_ in to the async/await pile. Which... I guess is fine but I personally dislike writing "await add(await add(x,y)), z)".

(aside: Rust's postfix await at least makes this kinda refactor less annoying)

leonidasv | 14 hours ago

The Node world was built with asynchronicity in mind. First via callbacks, then Promises, then async/await (Promise-based), so it feels natural now.

But if you take Python (for example), it's a shitshow. You usually have two versions of the same API, split by function name, client, package, or namespace: `foo` and `afoo`, where the a-prefixed one is async and meant to be used inside async function call chains, and the other one is the blocking version for non-async chains (which are still very much in use). It's a pain to develop for, to maintain, to scale, everything.

gpderetta | 17 hours ago

The problem with function color exists when you can't abstract over it[1]

Some statically typed languages (I believe both Haskell, ocaml) have powerful type system that allow abstracting over function types and function colors. Color is not an issue here.

Some other statically typed languages (C#, rust, and C++ (at least with the built-in stackless coroutines)) can abstract over types but not over colors. This is a problem.

Some statically typed languages (Go) do not encode async-ness statically, so it is not an issue[2].

Some dynamically typed languages (scheme, Lua, lisp) also do not encode async-ness statically. Everything is fine.

Finally there are some dynamically typed languages (python, js) that, eskew static types but for some reason still decide to encode async-ness statically. For me this is the most bizarre decision, especially as some of the justifications for static async-ness (performance, memory usage) are less relevant.

[1] for example, a litmus test is being able to implement an higher order function that inherits its color from one of its parameters. Essentially this is the problem of generically turning an internal iterator to an external one.

[2] fundamentally in these languages continuations are first class values that can be passed around, so the asyncness is naturally not bound to the function that created a continuation.

Edit: you can in principle abstract away color in any async language by simply assuming that any function call is async and await it. Then sync functions can trivially be made async. But at this point async annotations no longer convey any useful property: the language might as well implicitly await any function and require call-site annotations for diverging control flow or reentrancy requirements. More practically as languages with async evolve and grow asyncness becomes pervasive and pushes away any sync component.

> The problem with function color exists when you can't abstract over it

Hopefully it's safe read this as there's no common static type between function and async function meaning APIs (that take functions as arguments) have to provide seperate methods (or overloading) for these different colours.

Like in typescript you can write `<T>(f: () => T) => T` because an async function statically is just the return type wrapped in a Promise, not something like `async () => T` you can still pass in an async function as an argument.

I think that's a reasonable thing to take issue with, and its _possibly_ an avoidable design problem. That said I can see it being less avoidable if the async function requires some special kind of invocation (like being associated with some kind of async runtime and its a compiled language).

When I see people bring the issue of function colouring, the focus tends to be on the fact that a function is no longer interchangeable with a sync function and now you have to handle a promise, which I personally find unconvincing if the return type really should be a promise then it shouldn't be interchangeable with a sync function.

codebje | 10 hours ago

Your first paragraph links having the colour in the type system as allowing you to write functions that take arguments of parametric colour; your last paragraph says you're unconvinced that you might also like to write functions that return results of parametric colour.

An example: a vector of things to a thing of a vector, for "thing" in (promise, option, result<E>, ...). Such a function should only really return a promise if it's given a vector of promises, and, with an interface that "thing" supports, can be written generically for all those things.

(In Rust, there are separate implementations of that for Option and for Future.)

Higher-kinded types are the (a?) design solution, but they _do_ come at a cost, and for some that cost is higher than the cost of colours.

I think you're confused, I was talking to two different points, while I'm sure I could have communicated with more precision, either missed it, it was unclear or you don't understand, either way I don't really get the gotcha tone when you could ask for a clarification:

Anyways, the two points:

- The first point was, "not having a common way to generalise over both sync, async or blue, green, brown functions, seems avoidable and bad". This is when the type system struggling to common up with a common classification for function invocation independently of colour.

- The second point was that, was "so what if there are different return / wrapping / container / monad types", which focuses on a more common interpretation of this article but a different one.

In Haskell a type in a result, State, Config, Parsec, Maybe is in it for a reason, and thankfully we can generalise over that. Higher kind types (abstracting over abstractions) is a whole other basket, as an ex haskeller I would love to see them more mainstream but admittedly I don't think language authors are convinced and there isn't much we can do about it, so we should learn to make do with what we have outside of haskell.

EGreg | 16 hours ago

Actually, it's not async programming. It's only async programming the way JS does it... which is unwinding the entire stack, and then starting another stack on the next tick.

Instead, many languages have fibers / coroutines / etc. which simply start new stacks elsewhere, and capture the context.

amiga386 | 22 hours ago

I'm fairly sure the author was making reference to the famous article What Color Are Your Bits? https://ansuz.sooke.bc.ca/entry/23 which is even more abstract, because some large numbers are coloured "legal" and the same large numbers are coloured "illegal", based on where you got the numbers from.

All functions are not coloured, don't try to wriggle out of it by generalising. This article is a specific complaint about Javascript. Javascript is a hack on top of a hack. Its async/await is crap. Javascript requires this "colouring" in a way that C#, Java, Go, Python, Ruby, C, C++, Rust, etc. don't, because they don't have to pretend they're a single-threaded event processing loop, while Javascript does.

dnautics | 21 hours ago

I haven't used python recently, but in the days of asyncio it was very much "painful" (to borrow the article's verbage) to use, precisely because of the five criteria in the article.

sheept | 21 hours ago

Painfulness isn't the main issue with colored functions, it only explains why we don't make every function red (async).

The main issue is that sync functions can't call async functions, but in Python, you can bypass that restriction with asyncio.run.

dnautics | 21 hours ago

Fta

4) Red functions are more painful to call

I guess for me if I reach back to my memory my real problem with asyncio was that it used decorators and wrapping my head around how it was a crazy abuse of generators, completely broke my internal model of how python works (and also how at the time debugging became problematic), and maybe not so much the ergonomics, so strictly speaking a different set of ergonomic problems than in the colored function article

pverheggen | 19 hours ago

Agreed. Every time this article makes the rounds, people get hung up on the syntax, and not the performance considerations discussed towards the end. Nystrom wrote a whole book on interpreters, and his main criticism here is the big mess of chained closures this makes at runtime, compared with pausing/resuming green threads.

moritzwarhier | 21 hours ago

It's an interesting repeat submission to study how HN comments change over time though.

Regarding content, I agree with you. Async/Await is an amazing paradigm in JS for simplifying callback patterns and non-blocking suspense.

In other programming languages, there exist other intriguing paradigms that are more elegant and emphasize other aspects of "async"; my prime example 2 would be Erlang, but I am not experienced in, for example, Rust or C#.

The article has the same properties that many successful people IRL have: it makes a certain ick very easy to feel and understand, but it doesn't offer much in terms of profound knowledge.

What it does offer though is a perfect spark of discussion, making people who, for example, only know the single-threaded async-await from JS, consider the sheer possibility of other approaches. I am among those people with a limited horizon, presupposing that "knowing" means deep experience to you.

I have some superficial experience with Java physical threads, also with C#, but $job uses JS/TS.

And even in JS, none of this is trivial in my mind.

Consider the deceptively simple question of a kind of "mutex" that enables an async function or method to control concurrency of its own invocation.

The answer to this simple question (queueing promises and clean rejection handling) is already far from trivial, involves the microtask queue, and shows where the mental model of JS-async-await begins to deteriorate.

slopinthebag | 20 hours ago

> It's an interesting repeat submission to study how HN comments change over time though.

We've had at least a decade of using these async/await languages and discovered function colouring isn't a problem.

soulofmischief | 18 hours ago

While non-trivial in several ways, there are standard Web APIs such as Web Workers, Web Audio API, OffscreenCanvas, SharedArrayBuffer, etc. which help to construct modern, multi-threaded applications in JavaScript. Hopefully today, any experienced JavaScript-focused web developer should be experienced with more async paradigms than just async/await.

I think these APIs address real issues, but it also makes the entire stack more complex when integrated language support might be better for some features. But, keeping things separate does mean JavaScript as a language is fairly portable and backwards-compatible, if you ignore Web API support. Still, a lot of other languages feel more batteries-included and have more elegant multi-threaded async fundamentals.

jmull | 17 hours ago

> All functions, even non-async functions, are colored.

Not by the analogy laid out in the article.

> It's easy to call low-restriction functions from high-restriction ones and not the other way around.

do you have an example in mind when you say this? I think there's some unique messes with async/await (especially when combined with OOP and extension points... either your extension points _all_ have to be async or you have to have awkward restrictions), in a way that, say, permission checks don't have IMO.

a syntax-less "await function" that climbs up the stack trace to whoever is waiting and holds onto the suspension _is_ a way out of the problem the article describes. Requires runtime support but it effectively means you can always suspend.

johnfn | 16 hours ago

Something I have been thinking about recently is this: metaphors are often a way that authors use to make an argument in a way that is more engaging than: here is fact A, here is fact B, etc. But some metaphors are so strong that they make a stronger argument than the actual facts! And when you hear an argument with such a strong metaphor, you can often end up feeling very convinced of a particular point, even though the point itself isn't worth feeling so convicted over! I feel a very strong metaphor hijacks the emotional part of your brain before the rational part of your brain can figure out if it actually makes sense or not.

In the case of this article, the metaphor is extremely strong. Colored functions! What could be dumber! You feel like you see the world clearly after reading the article, and you pity the people who can't see the clear categorization of functions like you can! But, in reality, when has this ever annoyed me? Never! I've worked with async functions in JS/TS for over a decade and there hasn't been a single time in my entire life where I've been frustrated or backed into a corner because I wanted to call an async function from a sync one. At worst I've had to tack on "async" to a couple function declarations and be on with my day.

sqquima | 14 hours ago

I do find it annoying. Let's say in JS I have `result = list.map(f)` but now `f` returns a Promise.

`result = await Promise.all(list.map(f))` is less pleasant to read. And before writing it, I have to think if I want the `f` function to execute concurrently across all entries of the list, or one at a time: `for (const elem of list) { await f2(elem) }`.

Or maybe I should use a library like `p-map` and carefully set the concurrency level. Or maybe I should create a bulk version of `f` that takes an array and is more efficient than calling `f` N times.

And don't get me started when there's `list.forEach(f)` and `f` becomes async, so now it executes concurrently for all elements, and the engineer who made the change didn't realize it.

And then there's Async Generators ...

> I have to think if I want the `f` function to execute concurrently across all entries of the list, or one at a time: `for (const elem of list) { await f2(elem) }`.

I'd consider that a positive rather than a negative. That's an important question to think about (usually) and I want the type system to help remind me.

johnfn | an hour ago

But these are all legitimate choices you have to make, each with their own tradeoffs. Fire-and-forget, all in parallel, and batch are all different - you might end up selecting any based on the characteristics of the work you have to do.

moralestapia | 16 hours ago

I agree it is a dumb article.

It made a big fuzz back then but to me it's excellent as a litmus test. Learning async grammar in Javascript takes you an afternoon, after a week it should become familiar. If someone is unable to grasp that ... well, that tells you where they stand.

wiseowise | 9 hours ago

> I agree it is a dumb article.

It is a great article. Just because you misinterpret it doesn’t make it dumb.

wesselbindt | 23 hours ago

I feel like this argument always boils down to explicit vs implicit. It tastes the same as static vs dynamic typing. Personally, I fall well into the explicit camp. I like when I can know stuff about a function without having to read its body, and the bodies of the functions it calls, and the bodies of the functions they call, and so on. And so, I like when I can see from the function signature that it returns an integer, or when I can see from the function signature that it might do IO.

This comes at a cost, namely that of reading five extra characters in a function signature, and I could kind of imagine (truly!) how that gets in the way for some people. There is a cost of writing the five characters as well (and like the author mentions, in a poorly designed codebase, this may have to go down the call stack), but code is read more often than written, so in a sense this is negligible.

Like the dynamic vs static typing debate, I feel like this ultimately boils down to context and personal taste, and some amount of intelligence as well. I'm impressed by the amount of stuff the dynamic typing / non-async crowd is able to keep in their working or long term memory while coding. I don't have that kind of mental bandwidth, sadly.

Having said all that, this argument is disingenuous in that it completely ignores the fact that the async keyword tells you something useful (rather than some made up nonsense like color), and most of the argument basically boils down to "if you ignore the benefits, this syntax has no benefits", and I really don't respect that as an argument.

marcosdumay | 21 hours ago

No. The argument boils down into the fact that if you are not making something like Rust, coloring your functions for fine-grained performance issues is bad; if you are not making something like Haskell, coloring your functions for fine details of correctness is bad; if you are not doing something like a DBMS, coloring your objects for what code is reading it is bad; and so on.

wesselbindt | 16 hours ago

Those are not arguments, those are assertions.

mgoetzke | 11 hours ago

For a lot of architectures its also irrelevant. I use sync code and I use async code in my architecture. But more like independent subsystems. They are sync internally and only few places have connections to outside systems or IO. Those can be async.

I do understand though that it can be annoying for library authors, especially those that need to interact with the FS/Network etc.

viktorcode | 22 hours ago

If I understand correctly, Go language praised in the article still has red and blue functions, only now they the colours are handled implicitly, and you as a programmer reading the code will have harder time guessing which is which on the call site.

Jtsummers | 22 hours ago

There are no function colors in Go in the way being discussed. Every function can be spawned as a go routine, every function can spawn go routines.

slopinthebag | 21 hours ago

The functions are still coloured, just implicitly. IYKYK to spawn a goroutine or not ts.

Jtsummers | 20 hours ago

> The functions are still coloured, just implicitly. IYKYK to spawn a goroutine or not ts.

In Go, you can choose to either block on a function call or to execute it as a go routine. The function has no "color" in the sense of the article.

If you want to print asynchronously, you can with a `go fmt.Println("Hello")`, or you can block on that print and remove the `go `. There is no color to any function. And the function containing that, it also has no color. It can be called synchronously or spawned as a go routine, Go makes no distinction between the kinds of functions that can be used each way.

slopinthebag | 17 hours ago

Say you have a function like validate_user(). In Go, should you block the main thread on this call, or fork and join? What if it makes a DB request and now your UI is blocked for 2 seconds or something? You need to know implicitly you need to call validate_user() in a goroutine, and then deal with forking and joining manually. If it's explicitly coloured as async, you know.

In most async/await languages you can run async functions as sync, eg. Tokio's block_on method or C#'s .Result.

amiga386 | 14 hours ago

I think you're missing something. Go's functions are all blue, not all red. As far as I know, the callers of async functions with Rust's Tokio and C#'s Tasks themselves need to be async; not the case for Go.

Concurrency is usually a mix of goroutines and channels. There is no inherent link between caller and asynchronous callee. You can use goroutines without channels, and channels without goroutines.

You can write "go" to launch any function call in its own goroutine, but you cannot get a return value from it. This isn't valid:

    user_is_valid := go validate_user(u)
The idiomatic way you can do that is to use a goroutine and a channel:

    ch := make(chan bool)
    go func() {                  // runs asynchronously
        ch <- validate_user(u)   // blocks until it can send
    }()
    user_is_valid := <-ch        // blocks until it can receive
    return user_is_valid         // ta-da, blue function returning red function result
I don't think it's a boon to have functions "coloured for you" to tell you that they might block. On the other hand, functions that would block tend to accept a Context parameter to let you control what they should do. It's a major indicator that the function's probably going to do something async, but it doesn't have to.

slopinthebag | 14 hours ago

Well, the benefit of async/await is that you can just do

   let user_is_valid = validate_user(u).await
But you can also pass around future values. It's pretty dang ergonomic, the tradeoff is that it requires more ceremony to block on async functions (and is not even possible in JS). This was considered a potential problem 10 years ago but we've discovered since than that it's not really an issue at all.

My point about the colouring is that it's actually nice to have explicit colouring, in go the asynchronicity of functions aren't encoded by the type system but in practice you still handle them differently. You can't just call one of them without passing in things like context, or handling channels without refactoring anyways.

amiga386 | 5 hours ago

I think we're just seeing different sides of the same coin. Go's idiomatic code would be:

    user_is_valid := validate_user(u)
i.e. you have no special handling needed, it fits anywhere. You don't even know async is or isn't involved, and it wouldn't matter for correctness if it did. If you want to make it async, you get to do that yourself.

However, the evolution of that design has resulted in use of shared and nested Contexts in order to coordinate state and lifespans across goroutines, which is why they expose this parameter. And if you don't care, you can always use context.Background() for the value. Not only does it provide that synchronisation, but also it made cancellation and timeouts simple and standardised (obviously, async frameworks of other languages have their own idioms for this)

hutao | 19 hours ago

Go (and other language with threads) implicitly run inside the "async IO monad." In the function color analogy, what this means is that all functions are red, and the "ordinary" function call corresponds to "await" in languages such as JavaScript or C#.

Async/await is one implementation of cooperative concurrency, where the programmer must explicitly annotate the points where a context switch may occur. However, one can imagine a program transformation that marks every function as async, and makes every function call an await. After making that transformation, the async/await annotations would no longer be necessary. The end result is pre-emptive concurrency, where the runtime may potentially interrupt the active thread at any function call.

To make another analogy, Haskell requires all IO actions to run in the explicit IO monad, while most languages (C, Java, JavaScript, etc.) do not distinguish between "pure" and "impure" functions. Therefore, C, Java, and JavaScript could all be said to implicitly run in the IO monad.

Async IO is also an instance of a monad. In JavaScript, all async functions must run inside the explicit async IO monad, while Go does not distinguish between async and sync functions. Therefore, Go implicitly runs in the async IO monad. This is similar to the aforementioned distinction between cooperative (made explicit to the programmer) and pre-emptive (handled implicitly by the runtime) concurrency.

In fact, Eugenio Moggi, the PL theorist who realized monads could describe programming languages, was not looking for a programmer-facing abstraction. Rather, he was trying to describe the "implicit" monad in a programming language's semantics (such as the IO monad in most programming languages, or the async IO monad in Go).

Jtsummers | 19 hours ago

>> Go language praised in the article still has red and blue functions

This is my principle point of disagreement with the OP comment in this thread. Your response is either not meant for me, or is meant to agree with me, but I'm really not sure but you write:

> In the function color analogy, what this means is that all functions are red

and

> while Go does not distinguish between async and sync functions

Which was my point. Go does not have the function color problem (around sync/async) because it does not color its functions that way.

hutao | 19 hours ago

I was not looking to disagree with your point, I only wanted to make additional commentary. Sorry if my comment came across the wrong way.

I do think "There are no function colors in Go in the way being discussed," versus "all functions [in Go] are red" are two slightly different ways of formulating the same set of facts, and the distinction between them is insightful, so that was what I wanted to touch upon. Namely, I wanted to point out that there is an "implicit" color within the programming language itself.

fastforwardius | 21 hours ago

Having functions literally color coded based on type would be nice improvement.

wasmperson | 21 hours ago

IMO the function coloring problem was solved with async/await. This article was posted before Javascript's async/await syntax cleaned up that ecosystem, so the author is only guessing when they say it doesn't fix the issue. It did fix the issue, and now function coloring isn't really a problem.

If async/await doesn't solve the coloring problem, then neither do threads. Why would you ever need to start a thread to invoke a function when you could just invoke the function directly? Because the function is a red function.

cogman10 | 21 hours ago

No, I think you don't understand the problem.

This article is about async/await. The function coloring problem arises when you have async functions. Regular functions can't call async functions. You have to hoist them into async functions in order to do that.

Threads do solve this problem because they are just regular functions being called by other regular functions. They don't require the entire function stack to be `async` in order to work.

wasmperson | 18 hours ago

> This article is about async/await.

It is not. It tries to address async/await part-way through, but it does so without the context of 10 years of successful async/await usage in javascript, the language it's criticizing.

> Threads do solve this problem because they are just regular functions being called by other regular functions. They don't require the entire function stack to be `async` in order to work.

This is fixating on syntax: it would be trivial for all functions to simply be `async` by default and for all calls to an `async` function to automatically `await`. This might "fix" the coloring problem as you describe it but I argue wouldn't meaningfully change anything.

slopinthebag | 20 hours ago

I've exclusively used async/await style languages for my entire life and have not once ran into this supposed problem of function colouring. Basically all IO/async work you do requires a context, does it matter if that context is a parameter or a keyword? I don't think so. The author is inventing a problem to rant about.

notnullorvoid | 20 hours ago

The one time I did run into coloring being an issue was when working with gevent/greenlets (green threads) in Python nearly a decade ago.

Implicit management of async operations is something I hope I never have to deal with again.

triMichael | 11 hours ago

The author is definitely not inventing a problem. I ran into this issue at work this month.

We were using WPF, which is C# combined with XAML. I needed to call some async code. First, I tried to call it from synchronous code. That compiled correctly and seemed okay, but then I got vague crashes, and after doing research, I found everyone was like "don't ever call an async function from a sync function" for that exact reason. So instead, I had to change whatever called that to be async, and then whatever called that to be async, and so on. The solution I ended up having to go with was literally changing the code in over a hundred places. This is legacy code that I touch as little as possible, and instead of the one line fix that it felt like it should have been, async turned it into over a hundred small changes throughout the entire project.

I'm not saying there isn't a better approach. I'm pretty new to async/await code as I've been doing asynchronous code through threads my whole programming career. I don't think WPF is a good technology, so maybe some newer tech has solved it better. But what I can say is that this problem was not invented, it is a real problem and it caused a simple change to become a complicated one.

overgard | 19 hours ago

I think a few things are simultaneously true here:

1. async/await is a huge improvement over callbacks.

2. doing asynchronous programming through callbacks has always been a messy hack, primarily coming from languages that couldn't/wouldn't do real concurrency in their runtimes and async/await just papers over it without fixing the fundamental problems

3. threads are a lot more elegant from a language design standpoint and a lot more powerful, but..

4. .. I would much rather fuss with what color my function needs to be than deal with the average concurrency bug that results from threads.

gpderetta | 2 hours ago

definitely point 1.

Also: 5. Async/await is a necessarily evil that allows for extremely high concurrency (10s of millions of concurrent tasks) where stackful coroutines and threads wouldn't practically scale.

darepublic | 19 hours ago

I believe I remember reading this back at the time of release. Incredible how far we've come from there
Have we? JS has its async/await syntax, and you can even handle exceptions with less pain (just uh try not to put multiple awaits in a try block). But there are still a gazillion async libraries in npm indicating things missing from the language or older libraries, and then the rest of the JS/TS ecosystem that from my perspective has only doubled down on some of its worst aspects. It looks more like regression when developing similar to how the ClojureScript guys have enjoyed things since at least 2014 seems further away now than it did then, or how it can be controversial when people like dhh push a "no build" approach.

Yokohiii | 17 hours ago

Apparently this comment section...has color.
will always upvote this post when I see it

virtual threads (stackful coroutines) in Java is most of the reason why I’m defaulting to it nowadays instead of C#, Rust, etc

lukaslalinsky | 12 hours ago

I've spent the last year working on an async runtime for Zig and I really grew fond of stackful coroutines. Your just program your code as if everything was blocking. The main benefit is that you can use whatever library, it doesn't have to be async aware. Heck, I could even use C libraries, and they would work correctly with my coroutines. I really don't understand why GC-based languages decided to go with stackless coroutines, given that they could do what Go is doing.

sufficientsoup | 11 hours ago

What are your thoughts on the Io situation? Has your work been with the Io plans in mind from the beginning?

lukaslalinsky | 5 hours ago

My work was kind of similar to the `std.Io` plans, so when I learned about it, I started aligning the project to that interface. Now it's a full fledged implementation of the interface. It's actually the first async implementation of the interface, the one in stdlib is very far from finished.

There are many shortcomings in the API, especially missing timeouts everywhere. It was clearly designed by people working on the compiler and other local tools, and not much network services, but I think it's a great improvement in the Zig ecosystem and hopefully people can now write reusable libraries.

matheusmoreira | 11 hours ago

> I really don't understand why GC-based languages decided to go with stackless coroutines

From what I've read it's due to a general idea that stacks use a lot of memory, which limits how many of them can be spawned. It's only good if it scales to a million concurrent users. A million 16 KiB stacks is over 16 GiB.

lukaslalinsky | 5 hours ago

But if you have stack growth, the way Go does it, then the stack you allocate is actual memory you use. You are just trading heap allocations for stack growth. I started to see the stack as a super fast allocator that is always available.

matheusmoreira | 4 hours ago

Indeed. I think stacks are pretty great myself. Even the memory usage example I gave, which is the typical argument towards stackless, evaporates when the stacks are small. I benchmarked typical stack frames in my language at around 100-300 bytes. Chatting with Erlang/BEAM folks also revealed their stacks were in the same order of magnitude.

I think the popularity of event loops stems from the fact Node does it. Pretty much cooperative multitasking by another name. Functions are coroutines, return is yield and the event loop is the scheduler.

ramon156 | 10 hours ago

Eh not a fan of this writing style. It's too push and pull.

Timwi | 7 hours ago

Edited because I didn't realize how old the article is.

Almost everything the article says about C# is wrong:

> Sync functions are just called, async ones need an await.

No they don't. An async method can be called like any other method and you'll just get the Task<T> object.

> You can’t unwrap it unless you make your function async and await it.

False. You can just access .Result and it'll execute synchronously.

> Before they added async-await and all of the Task<T> stuff, you just used regular sync API calls.

You can still call the sync APIs, and I'm not getting any impression that those APIs are unmaintained or that they're avoiding adding new ones where it makes sense, even if in many cases it's literally just a matter of calling the async method and accessing .Result!

When they describe how the compiler transforms async code into closures, they seem to get this right, but for some reason they treat it as a problem and I can't figure out why. The compiler transformation is exactly the right tool for the job. They even mention the great benefit of it not needing special runtime support. I'm very confused as to the direction of this argument.

By the way, while I was reading the first part, before the “big reveal”, my two top guesses for what it's about were C++ const correctness, and Java checked exceptions. I'd argue that more of their arguments apply to those than to async/await.

vorticalbox | 7 hours ago

async/await didn't hit javascript until 2017. in 2015 you picked between

function foo(cb) { cb(42, null) }

or

function foo() { return new Promise((resolve, reject) =>{ resolve(42) }) }

the second allowed for .then and .catch

foo.then((value) => console.log(value)).catch((error) => // handler error)

which isn't much better but did get us chaining

foo().then((v) => bar(v)).then((v) => baz(v)).catch()