Async Functions Have No Color

This article dives into the concept of colored functions and why asynchronous functions don't have to segment the universe.
AsyncConcurrencyThreading
2024-03-22 ~7 minutes

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:

  1. Every function has a color and is either red or blue.

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

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:

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

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, Promises and Futures 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

  1. 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. ā†©

  2. 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. ā†©

Author's photo

Chicken

I'm a private bird, a chicken who likes grains and sometimes spicy šŸŒ¶ļø

Other articles: