Discover more from Deeply Nested
The Rise of Coroutines
This week's topic is technical, but we won't look at any code. Instead, we'll simply note an important trend: the rapid adoption of coroutines into mainstream programming languages. This post gives a brief explanation of what coroutines are and why they matter, but maybe more interesting than the technology itself is the rare chance to witness a widespread, fundamental change in the way computers are programmed. Something like this doesn’t happen often, so it’s fun to watch.
Major programming language specifications are being updated at an accelerating pace to accommodate coroutines. Here are the years when some popular languages adopted the relevant keywords “yield” and “await:”
So, what are they? As you probably know, software is built of functions representing sequences of instructions for a computer to execute. Coroutines are functions that can pause themselves, then be resumed later. A coroutine can start running, then stop for a while to let the computer do something else, then continue as if nothing happened. They’re not a new idea, but until recently, they were academic.
There are two main reasons a function might want to pause itself:
The function doesn't know in advance how much output it should produce. A coroutine can produce some output, then pause, and be resumed if the caller needs more output. For example, if you have a function that yields all the natural numbers (1, 2, 3, 4, etc.), you cannot wait for it to produce the full result. That would literally take forever. Instead, you can ask a coroutine for the first number, then resume it to get the next number, and so on, until you've got however many numbers you need. Coroutines that work this way are called generator functions, and they pause themselves using the “yield” keyword. Without generator functions, we’d be stuck writing iterators by hand.
The function is blocked by a slow operation such as network access, and doesn’t want to hold up the rest of the program. For example, whenever you lift your cell phone, it might automatically call functions to check the weather, a few stock prices, and your email inbox. Naively, it could first check the weather (which may take a few seconds), then the stock prices (taking more time), and finally your email. A better way is to perform all these network calls concurrently: First, spawn a coroutine that requests weather info from some web service, pausing to await the response. While the weather coroutine is paused, start another coroutine to request stock prices. While the stock coroutine is waiting, call a third coroutine to fetch email. That gets you each result as soon as possible (since no operation is blocked by any other), and leaves the main program free to paint you a progress bar. Coroutines like these are called async functions. They pause themselves using the “await” keyword.
It’s that second reason that has lit such a fire under industry. Our increasing reliance on networks for mobile applications, cloud services, and distributed computing is driving demand for fast, easy concurrency.
Coroutines are often touted as an alternative to threads, and they do have a couple of compelling advantages. Because coroutines are specifically designed to stop and start cooperatively, rather than being preempted by the operating system, they can be more efficient than threads, especially for heavy workloads like web servers. From a developer perspective, coroutines are easier to compose.
Some folks feel that threads are “good enough,” but good enough misses the point. Raw threads are harder to coordinate than async functions. And of course, we can run parallel coroutines (like any other functions) on multiple native threads—say, Web Workers—to make use of all these multicore processors. Decoupling concurrency from parallelism buys us a useful degree of freedom.
Before we go, here are a few dishonorable mentions, conspicuous by their absence from the timeline chart above:
Go supports neither yield nor await. Goroutines are kinda sorta coroutines, but using them effectively requires message channels, semaphores, and all the other paraphernalia of threads. They still scale better than native threads, but that's about their only advantage.
Java also lacks yield and await. Java is not very good.