Pangram verdict · v3.3
We believe that this document is fully human-written
AI likelihood · overall
HumanArticle text · 1,558 words · 5 segments analyzed
OS threads are expensive: an operating system thread typically reserves a megabyte of stack space and takes roughly a millisecond to create. Context switches happen in kernel space and burn CPU cycles. A server handling thousands of concurrent connections and dedicating one thread per connection means thousands of threads each consuming memory and competing for scheduling. The system spends time managing threads that could be better spent doing useful work.This is the C10K problem, named by Dan Kegel in 1999. If you were building a web server, a chat system, or anything with a large number of simultaneous connections, you needed a way to handle concurrency without a thread per connection.The answer came in waves, each solving the previous wave’s worst problem while introducing new ones. Previously we’ve looked at channels in Go and actors in Erlang. Now we turn to async, which is everywhere these days.CallbacksThe first wave was straightforward: don’t block the thread. Instead of waiting for an i/o operation to complete, register a function to be called when it finishes and move on to the next piece of work. Event loops (select, poll, epoll, kqueue) multiplexed thousands of connections onto a handful of threads, and callbacks were the programmer’s interface to this machinery.Node.js built an entire ecosystem on this model, handling thousands of concurrent connections on a single thread. Nginx’s event-driven architecture was a major reason it displaced Apache for high-concurrency workloads.This nicely solved the performance problem, but at a cost: callbacks invert control flow. Instead of writing “do A, then B, then C” as three sequential statements, you write “do A, and when it’s done call this function, which does B, and when that’s done call this other function, which does C.” The programmer’s intent becomes scattered across nested closures. JavaScript developers named this “callback hell” and built an entire website to commiserate.Callbacks have deeper problems than aesthetics, such as fracturing error handling. Each callback needs its own error path. Errors can’t propagate naturally up the call stack because there is no call stack (callbacks run in a different context from where they are registered). Handling partial failure in a chain of callbacks means threading error state through every function in the chain.Plus, callbacks have no notion of cancellation.
If you start an asynchronous operation and then decide you don’t need the result, there’s no general way to stop it. The callback will fire eventually, and your code needs to handle the case where it no longer cares about the result.Callbacks solved the resource problem (too many threads) by creating an ergonomics problem (code that’s hard to write, read, and get right).Promises and FuturesThe next wave started with a good idea: what if, instead of passing a callback for later invocation, an asynchronous operation immediately returned an object representing its eventual result?This is a promise (JavaScript) or future (Java, Rust, etc). The concept dates to Baker and Hewitt in 1977, but it took the C10K pressure of the 2010s to push it into mainstream programming. JavaScript standardized native Promises in ES2015 following the community-driven Promises/A+ spec, and Java 8 introduced CompletableFuture.Promises are more ergonomic than callbacks. First, promises are composable: promise.then(f).then(g) reads as a pipeline instead of a nested pyramid. Error handling also consolidates: a .catch() at the end of a chain handles failures from any step. And promises are values that you can store, pass around, and return from functions. A first-class handle to an eventual value moves the conversation away from raw threads and toward data dependencies. The idea that “this value depends on a computation that hasn’t finished yet” is a useful thing to be able to express.Here’s JavaScript reading a user profile and then fetching their recent orders, first with callbacks, then with promises:// Callbacks: nested, error handling at every level getUser(userId, (err, user) => { if (err) return handleError(err); getOrders(user.id, (err, orders) => { if (err) return handleError(err); render(user, orders); }); });
// Promises: chained, error handling
consolidated getUser(userId) .then(user => getOrders(user.id).then(orders => [user, orders])) .then(([user, orders]) => render(user, orders)) .catch(handleError);The promise-based version is not a huge improvement on this small example, but the difference grows with complexity: five steps deep in callbacks is nearly unreadable, while five .then() calls chained together are at least linear.But promises introduced their own problems:Promises are one-shot. A promise resolves exactly once. This makes them unsuitable for modeling streams, events, repeated messages, or any ongoing communication. A WebSocket that receives a stream of messages doesn’t map onto “a value that will exist later.” This forces a split: promises for request-response patterns, and something else (event emitters, observables, callbacks again) for everything else.Composition is clunky. The example above hints at it: getting both user and orders into the final .then() requires nesting or awkward gymnastics with Promise.all. Two independent async operations are easy (Promise.all([a, b])), but anything more complex (conditional branching, loops over async operations, early exit) requires increasingly elaborate combinator patterns. These patterns work but they’re a functional programming idiom grafted onto an imperative language and they don’t feel natural.Errors vanish silently. JavaScript promises that reject without a .catch() handler originally just swallowed the error. The value was lost causing failures to be invisible.
This was bad enough that Node.js eventually changed unhandled rejections from a warning to a process crash, and browsers added unhandledrejection events. A feature designed to improve error handling managed to create an entirely new class of silent failures that didn’t exist with callbacks.The type split. Every function now returns either a value or a promise of a value. So callers need to know which one they’re getting and libraries need to decide which one to provide. A function that was synchronous becomes asynchronous when you add a database call to it, and now every caller needs to handle a promise instead of a value. This is a mild form of the coloring problem that the next wave would make even worse.Async/AwaitPromise chains still looked nothing like the sequential code developers wrote for everything else. Async/await, pioneered by C# in 2012 and adopted by JavaScript (ES2017), Python (3.5), Rust (1.39), Kotlin, Swift, and Dart, delivered exactly that:// Promise chains function loadDashboard(userId) { return getUser(userId) .then(user => getOrders(user.id) .then(orders => [user, orders])) .then(([user, orders]) => render(user, orders)); }
// Async/await async function loadDashboard(userId) { const user = await getUser(userId); const orders = await getOrders(user.id); return render(user, orders); }The async/await version reads like sequential code. Variables bind naturally. You can use try/catch instead of .catch(). Loops work with await inside them. It’s an ergonomic win for linear sequences of asynchronous operations.The industry adopted it fast, with JavaScript frameworks going all-in, Python’s asyncio becoming the standard approach for concurrent i/o, and Rust stabilizing async/await as the path to high-performance networking. Within a few years, async/await was the default way to write concurrent i/o code in most mainstream languages.Paying the Function Coloring TaxIn 2015, right as async/await was gaining steam, Bob Nystrom published “What Color is Your Function?”, a thought experiment about a language where every function is either “red” or “blue.” Red functions can call blue functions, but blue functions can’t call red functions without special ceremony.
Every function must choose a color, and if you call a red function from a blue one, the blue one must become red, spreading virally throughout the codebase.This was an analogy to async/await: async functions are red, sync functions are blue. An async function can call a sync function without issue, but calling an async function from a sync function requires blocking the thread or restructuring the code. Every function in your program must choose a color, and that choice propagates through every caller.Nystrom’s post stuck because it put a name to something developers had been experiencing without a vocabulary for it. Function coloring reshapes entire codebases and ecosystems.The Rust async ecosystem fragmented around competing runtimes (Tokio, async-std, smol) that provide incompatible implementations of fundamental types like TCP streams and timers. A library written for Tokio can’t easily be used with async-std. The popular HTTP client reqwest simply requires Tokio, and if your project uses a different runtime, that’s your problem. Now library authors either pick Tokio (locking out alternatives) or attempt runtime-agnostic abstractions (adding complexity and sometimes performance overhead).Tokio’s dominance is function coloring at ecosystem scale. The tax shows up at other scales too:At the function level, adding a single i/o call to a previously synchronous function changes its signature, its return type, and its calling convention. Every caller must be updated, and their callers must be updated. The change ripples through the call graph until it hits a framework entry point or a main function. A one-line database lookup can require modifying dozens of files.At the library level, authors face a choice of writing a sync library and exclude async users, or writing an async library and force sync users to add runtime dependencies (or maintain both). Many choose “both,” doubling the API surface, the test matrix, and the maintenance burden. In Python, the requests library (sync) and aiohttp (async) are separate projects by separate authors doing the same thing. httpx eventually appeared to offer both interfaces from one package, which is an improvement only needed because of the split.At the ecosystem level, the Rust example above is the norm, not the exception. Every library that touches i/o must choose a color, and that choice limits which other libraries it can work with.