Async Functions Have No Color
This post is a very late response to this popular article from 2015 about function coloring or more accurately: callback-based asynchrony splitting functions into isolated buckets. Let me start by saying the article is genuinely a treat and provides the reader with a lot to unpack š. Sadly, itās also hard to see the forest for the trees.
Nine years late to the party and nine years none the wiser, let me try and help better understand the notion of function coloring and authorās intent by yielding another but shorter wall of text.
The essence of coloring is that your functions are divided into multiple
buckets, where functions from one bucket cannot call functions from another.
Having seen the asynchronous programming landscape flourish and refine itself
over time, I agree with the author on how callback-style asynchronous
approaches part the seas.
That said, async/await
-based approaches have elegantly and holistically
addressed the issues, therefore scraping any paint of previously colored
functions.
Coloring was never about slightly different function signatures or ABIs but
interoperability. Iād argue that any remaining impedance mismatch is not due to
the nature of async/await
but more fundamental, i.e. youāre having to deal
with eventual values. Yeah, you can just wait for it but if youāre on a
single-threaded event-loop, good luck š.
The Original Proposition
The original article starts out axiomatically introducing a fictitious language declaring a list of rules for functions including coloring. Let me quickly quote them rules for further reference:
-
Every function has a color and is either red or blue.
-
Functions are being colored based on how they are invoked and how they provide a result:
blue_function doSomethingAzure() { /* ... */} // blue call syntax doSomethingAzure()blue; red_function doSomethingCarnelian() { /* ... */} // red call syntax doSomethingCarnelian()red;
-
You can only call a red function from within another red function.
-
Red functions are more painful to call.
-
Some core library functions are red.
Shortly after the article reveals that in this made up language blue and red represent synchronous and asynchronous functions, respectively. Where synchronous functions are functions that return their result to the caller right away. Asynchronous functions, on the other hand, return to the caller before a result is available in order to allow work to continue while the callee is waiting for I/O.
The author also states later that in their opinion, languages that have OS threads, green-threads, or co-routines donāt suffer from function coloring because functions doing I/O operations can simply block or yield, while other threads or co-routines, respectively, continue to make progress.
This is were we can finally clarify the authors intent: They take issue with callback-style asynchronous functions in a concurrent single-threaded execution contexts, like the early days of Node.js. Unlike for parallel execution models, using single-threaded event-loops one cannot simply block on a pending result w/o stopping the world. Instead, a continuation has to be posted that the event-loop can call back into as soon as the result becomes available. The most naive implementation of this model prompts the developer to provide an aptly named callback. In JavaScript this could look like:
import fs from "node:fs";
function doAsyncIO() {
const callback = (err, data) => {
if (err) return;
console.log(data.toString("utf8"));
};
fs.readFile("/etc/hostname", callback);
}
. Using anonymous functions and closures, this can quickly lead to deep nesting which is often referred to as callback waterfalls or callback hell as mentioned by the article. To be clear, functions returning asynchronously is not tied to any particular execution model. You can have asynchronous functions in a threaded context. However, they are required to achieve concurrency on a single-threaded event-loop.
These days many languages have opted to provide some flavor of async/await
primitives to allow for synchronous-looking asynchronous code thus mitigating
many of the callback-style issues:
import fs from "node:fs/promises";
async function doAsyncIo() {
const bytes = await fs.readFile("/etc/hostname");
console.log(bytes.toString("utf8"));
}
is functionally equivalent to the callback example above but w/o the explicit
callback.
The compiler/interpreter uses the await
as a marker to internally break the
function into a series of continuations, i.e. the JavaScript runtime executes
the console.log()
in in a separate continuation after bytes
become
available.
Some folks refer to async/await
as syntactic sugar, however the āinliningā
does fix compositional issues such as try/catch
error handling.
The original article concedes that introducing async/await
would fix most of
our languageās coloring issues except for #3:
āYou canāt call an async function from a synchronous one because you wonāt be
able to determine the result until the async one completes laterā.
That sounds great and all but #3 is clearly the most most heinous, most
saturated of issues cleanly splitting the universe of functions in two.
Iāll argue that the framing of #3 is misleading and underselling async/await
.
Hear me outā¦ š
Stripping the Paint šØ
Letās rip off the band aid and address our elephant numero 3:
- Asynchronous functions can of course be called from synchronous functions.
With the right abstractions the only difference is that theyāll return an
eventual result (e.g. futures, promises, ā¦) rather than an immediate result.
If you only care about a functionās side-effects you donāt even have to worry
about that š¤· 1.
Otherwise, futures can be passed around in a synchronous context just like
any other value.
Only explicitly
await
ing the result requires an asynchronous colored context. In theory one could synchronously āawaitā an eventual result by eagerly polling but thatās just a terrible idea. Meaning, calling an asynchronous function doesnāt have to taint the caller 2. - Independently, runtimes could easily offer a
blocking-await
halting the event-loopās execution until a result is available. They simply choose not to because blocking the entire loop on arbitrary I/0 and waiting indeterminately breaks the concurrency model and generally is a bad idea š“. The feasibility of this claim is backed by popular runtimes, such as Node.js or Dart, actually breaking this model by offering escape hatches at least for hopefully fast and more predictable file I/O š¤. Keep in mind that Node.jsā introduction of asynchronous I/O to the mainstream was one of the main drivers propelling it performance-wise light years ahead of the competition, namely Python and Ruby.
import fs from "node:fs";
import fsPromise from "node:fs/promises";
function syncFunReturningPromise(block) {
const fname = "/etc/hostname";
return block ? fs.readFileSync(fname) : fsPromise.readFile(fname);
}
async function printPromiseOr(promise) {
console.log((await promise).toString("utf-8"));
}
function syncMain() {
const promise = syncFunReturningPromise(/*block=*/ false);
const unawaited = printPromiseOr(promise);
}
syncMain();
The original article is already very detailed, however I canāt help but wish it had taken the time to peel compositional/authoring and execution models concerns apart. Arguing that a languageās concurrency model is superior and especially simpler because it offers (green-)threads definitely caught me by surprise. Thereās a time and a place for different execution models. Single-threaded event-loops are a very simple yet powerful concurrency model avoiding many of the sharper edges of true parallelism. In fact, many modern languages (Rust, Swift, Kotlin, ā¦) lean into eventual results like futures and let you pick from a variety of single and multi-threaded executors. Ironically, Iāve seen the original article being quoted (e.g. here) in the Go community to assert execution model superiority. While thereās nothing wrong with green-threads, simply not every I/O problem needs to be threaded through the same cooperative parallel needle.
Personally, I do like it when functions explicitly return futures over blocking internally just to look like returning immediately, which used to be the norm. Futures can help to avoid hard-coding a specific execution model and allow to push the responsibility for picking a suitable one up the stack following the dependency inversion principle. You want to run your code on an executor with one or N parallel threads? - locking aside, not a problem. Blocking on the other hand will always require a dedicated thread for other concurrent work to make progress.
Parting Words
I agree with the
original
article that callback-style asynchronous code w/o a flavor of futures or
async/await
segments the function space and breaks language composition.
Thereās no result, not even an eventual one, to pass around and callback
waterfalls are real nail biters.
Iām also sympathetic to not unnecessarily segmenting the universe of functions. Effect systems are an example for such segmentation and have long been a point of contention, with Javaās checked exceptions probably being the most prominent case.
However, I do not agree that asynchronous functions have to constitute
coloring: neither does asynchronous code need to be viral nor does it need to
cleanly split the universe of functions.
In popular single-threaded but concurrent runtimes like Node.js, Deno, or Dart,
any function can still call any function.
Eventual results, Promise
s and Future
s respectively, are values that can
be passed around freely over long distances including synchronous code.
Only efficiently awaiting the result requires the awaiting code to be
asynchronous as well.
Coloring or not, this awaiting futures is very similar to unpacking
result monads
when passing errors by value, i.e. a caller either has to propagate the error
by returning a Result<T>
themselves or explicitly ignore it.
Yet, Result<T>
itself can be treated as a value.
Besides, function coloring has always been about interoperability and composability rather than merely making signatures look alike. Having a function signature explicitly expose a real distinction in behavior and execution requirements is a good thing and not just a hiccup of nature.
Iām really enjoying that more and more languages are leaning further into asynchronous primitives such as futures or channels, and provide a rich variety of execution models. It gives me the ability to address simple I/O problems with simple solutions, while also giving me the freedom to shoot myself in the foot.
Incidentally, I believe that Zigās take on
colorblind async/await
is solving a non-issue by making async function signatures look synchronous.
Unless your code behaves correctly under any execution model, thereās a risk of
digging new pits to fall in by hiding the important distinction that is
eventuality.
This level of magic feels very out of character for a language that has done so
well keeping everything else simple and explicit.
As long as your compiler has to break up async
functions into a series of
continuations, any synchronicity is a lie š.
Footnotes
-
This is somewhat assuming eager execution, i.e. the eventual result represents an already started execution. If the eventual result is lazy, i.e. encapsulates some deferred computation, it also needs to be passed to an āexecutorā to start running, e.g. Rustās futures. ā©
-
An example is building Flutterās Widget UI tree. Building is synchronous to avoid IO on the critical path and achieve more consistent frame rates. However, pressing a button may trigger a network request. The handler for the button press is synchronous as well and therefore cannot await the response. Instead, another synchronous re-render is triggered based on the response as soon as it has been processed. ā©