blainehansen

Red & blue functions are actually a good thing

By avoiding effect aware functions a language hobbles engineers and makes programs sloppier than they could be.

published: April 16, 2021 - last updated: April 26, 2021

Many web programmers have read this post about red/blue functions (opens new window) and their downsides, especially in the context of asynchrony. Many people have joined in, usually to extol Go or Elixir or Erlang. Despite the meme spreading far and wide, many languages use the async/await syntax in their language to handle asynchrony, introducing red/blue functions into the language. Rust is one notable and recent example.

My position when discussing this has long been that red/blue functions are a good design choice that should be used in basically all statically typed languages (I also happen to feel that basically all languages should be statically typed (opens new window), but that's a topic for another day). A mature and serious language such as Rust choosing to adopt them made me feel vindicated my position was generally accepted, and I didn't worry much about it.

However I keep running into posts reviving the same talking points, and I finally couldn't stand by any longer when I encountered this post stating that Rust is colored, "and that's not a big deal" (opens new window).

I don't think this position is nearly strong enough! Not only are red/blue functions tolerable and a good design choice, but the only benefit gained from avoiding them is convenience, and the consequences outweigh that benefit. By hiding important information about the real nature of the program, many unnecessary potential pitfalls are opened up. Certainly some languages can still make that choice, but I think they do their users a disservice. I personally will only use such languages for unimportant programs, or not use them at all.

§ Inconvenient knowledge is better than convenient ignorance

Colored functions reveal important realities of a program. Colored functions are essentially a type-system manifestation of program effects, all of which can have dramatic consequences on performance (unorganized io calls can be a latency disaster), security (io can touch the filesystem or the network and open security gaps), global state consistency (async functions often mutate global state, and the filesystem isn't the only example), and correctness/reliability (thrown exceptions are a program effect too, and a Result function is another kind of color). Colored functions don't "poison" your program, they inform you of the reality that your program itself has been poisoned by these effects.

Sanely designed type systems always have escape hatches to make a program more convenient when it makes sense, such as block_on (opens new window) or unwrap (opens new window) in Rust. These functions essentially throw away the program effect information, but do so obviously and consciously. Programmers can choose to do this when they feel it justified, while keeping the option to change their mind later. When the program starts performing pathologically or becoming unreliable, it remains clear where to start looking.

The most obvious and trivial example of effect aware functions encouraging a better program is when several asynchronous actions can be dispatched at the same time. Let's say we have some silly function where asynchrony is handled in the "go-routine" fashion:

fn bundle_letters() -> Letters {
	// each of these functions performs an async action
	// internally creating a green-thread
	// and coordinating with it using channels or mutexes
	let a = fetch_a();
	let b = fetch_b();
	let c = fetch_c();
	Letters { a, b, c }
}

Note that without adding more code it is absolutely impossible to make those fetch calls truly concurrent. Since languages like Go can't guarantee a green-thread won't introduce synchronization bugs, the programmer has to synchronize manually with channels or mutexes. Functions you can "just call" like those above must necessarily suspend if they are going to remain safe. Without adding a WaitGroup and creating new green-threads for each fetch, then the above code is equivalent to this:

async fn bundle_letters() -> Letters {
	let a = fetch_a().await;
	let b = fetch_b().await;
	let c = fetch_c().await;
	Letters { a, b, c }
}

Importantly, the language itself does nothing to inform us this is the case. We merely miss out on the opportunity for real concurrency if we aren't sufficiently familiar with these specific fetch functions.

But with the naive await code glaring at us, we only have to understand the language to understand we're leaving performance on the table. The refactor is obvious:

async fn bundle_letters() -> Letters {
	let (a, b, c) = futures::join!(
		fetch_a(), fetch_b(), fetch_c(),
	).await;
	Letters { a, b, c }
}

(PS, in typescript a fully typesafe function equivalent to futures::join can be made with a generic tuple (opens new window))

Elixir seems to put us in a similar situation. In order to prevent blocking and capture any concurrency of a sub-process you have to use Task.await (opens new window), but nothing in the (dynamic) type system requires any function using Task.await to be marked. This again invites the same kind of inefficient sequential waiting when other programmers use our await calling function without being aware. The whole point of effect aware functions is their type-level transitivity, requiring any caller of a function to make some aware decision about how to handle the underlying effect.

Any code that "feels synchronous" necessarily makes you pay for that feeling by stealing true concurrency away from you. It may seem more convenient at first, but the second you need more control you have to use much clunkier abstractions.

The only way to gain both the apparent simplicity of the go-routine style of asynchrony along with the efficiency of aligned await points is to build a very sophisticated optimizing compiler that can track the liveness of asynchronously fetched values and reorder or even insert operations. Even in this situation the programmer is still potentially oblivious to the underlying asynchrony.

Rust has proven that with clever compiler design a Future based system can give us both clean syntax and incredible performance. Rust async isn't completely finished, but the price of real innovation is temporary pain. Once the innovation has been done we'll have all the benefits with very few real tradeoffs.

§ Discipline is freedom

Programs can essentially always be structured as a functional core with impure io wrappers (opens new window). Doing so is better in many more ways than merely allowing code reuse across sync/async functions. Allowing programmers to write blended sync/async code that's all the "same color" inevitably invites poor structure.

Awareness of effect boundaries between functions allows you to improve the clarity of your design, and punishes you for a sloppy one. This is almost always a good thing, and engineers who routinely have to deliver robust, performant, correct programs should prefer their language be a careful partner who holds them accountable for their design decisions.

Please feel free to create and use languages with invisible program effects or asynchrony, but for me personally the costs far outweigh the benefits.

Want to hear from me in the future?

Usual fine print, I won't spam you or sell your info.
Thank you! Come again.