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"
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.
> 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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
> 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.
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.
I'm almost certainly missing something, but I'd love to be able to call await within a sync function. If I'm waiting on the async function, it feels like there's no need for it to "colour" my caller function like it does.
29 comments
[ 3.7 ms ] story [ 37.8 ms ] threadThat 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 :-)
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.
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/
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
It can in C# (just call .Result). I'm not sure why other async/await languages, like JS, don't just add that too.
Thanks for my next horror shortfilm plot. Twist: he's the protagonist
* Haskell: pure function and non-pure (IO monads) looks different. * Rust: unsafe functions (or block) requires special markers.
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.
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.
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.
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.
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.
virtual threads (stackful coroutines) in Java is most of the reason why I’m defaulting to it nowadays instead of C#, Rust, etc
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.
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.