Why Coroutines
Remember how, when Swift introduced us to the concept of Optional
, all of a sudden explanations around the theory and implementation of monads became relevant1 and interesting?2 Thanks to the await/async proposal currently being discussed in Swift evolution, the same is about to happen to another rather computer-sciencey concept: coroutines.
We’re still a long way off from any concrete implementation of coroutines in Swift, but that doesn’t mean we can’t explore some of the conceptual underpinnings around how and why we might like to use them.
But to do that we need to begin at the beginning.
Starting with Subroutines
What’s a subroutine? Technically it’s a sequence of program instructions that perform a specific task, packaged as a unit. But we know it better as simple, everyday function.3
We tend to take functions for granted, and many of their characteristics get overlooked as just “the way things work”. For example, the execution of instructions in a function always starts at the top, never in the middle. And when we leave a function (via explicit or implicit return
) we tear it down and can never get back into it:
func makeSplines() -> [Spline] {
var splines = [Spline(1)] // We always start here
return splines // Once we leave here…
splines.append(Spline(2)) // …this never happens.
}
Again, this is so familiar to us it hardly seems worth mentioning. This is just the way functions work. But it’s not always desirable. Consider the following:
func reticulate(splines: [Spline]) -> [Spline] {
let encabulated = encabulate(splines)
let frobbed = frob(encabulated)
return frobbed
}
Because subroutines always start at the top and can only exit once, any call to reticulate
has to wait for encabulate
and frob
to complete before moving on. If these subroutines block us for a long time, we might wish we could return from reticulate
early to do some work. But as we saw above, once we exit we can never get back — everything after the return gets thrown away.
Handling Completion Handlers
The customary way of working around this in Swift is with completion handlers:
func reticulate(splines: [Spline],
completion: ([Spline]) -> Void) {
encabulate(splines) { encabulated in
frob(encabulated) { frobbed in
completion(frobbed)
}
}
return
}
But let’s examine what we’ve actually done here. We’ve moved the entire body of reticulate
into closures passed to encabulate
and frob
. Then we moved all our return values into these closures' parameters. This frees up reticulate
to exit immediately because it has no body left to execute and no values left to return.
But remember, subroutines are thrown out the moment they exit. If that’s true and reticulate
returns before encabulate
or frob
are done with their work, how does any of this still exist when we try to run their completion handlers?
The answer lies in our use of closures. From the section on closures in The Swift Programming Language:
A closure can capture constants and variables from the surrounding context in which it is defined. The closure can then refer to and modify the values of those constants and variables from within its body, even if the original scope that defined the constants and variables no longer exists.
We can see this even more clearly if we do a little work in reticulate
before calling encabulate
:
func reticulate(splines: [Spline],
completion: ([Spline]) -> Void) {
let sorted = splines.sorted
let reversed = sorted.reversed()
encabulate(splines) { encabulated in
completion(encabulated + sorted + reversed)
}
return
}
Here we see that not only does our completion closure still exist, it’s able to make use of values defined in reticulate
long after it’s exited.
In fact, from a certain point of view, when we define this closure we’re saving the state and position of execution within reticulate
. And when we run the closure, we’re sort of resuming execution of reticulate
right where it left off.
When a closure is used to preserve the execution environment of a routine like this, it’s called a continuation. And passing continuations into other routines to be called in lieu of returning is known as continuation passing style (CPS).
Wrangling Continuations with Coroutines
All of which is a pretty cool trick! But it’s also messy:
func reticulate(splines: [Spline],
completion: ([Spline]) -> Void) {
// ❶ Execution starts here.
// ❷ But immediately jumps over this...
encabulate(splines) { encabulated in
// ❹ sometime later this is called...
frob(encabulated) { frobbed in
// ❻ finally we call the completion
// which acts like a return even though
// it's deeply nested.
completion(frobbed)
}
// ❺ ...but returns immediately
}
// ❸ ...and returns down here
return
}
Between the rat’s nest of closures and the execution path that bounces up and down like an EKG, this CPS syntax is far from ideal. All we really want is a way to say “suspend self, pass control, be ready to resume.” But the concept is so antithetical to the core definition of a subroutine (start at top, exit once forever) that it’s hard to express.
Enter coroutines.4
Coroutines are different from subroutines. Actually, they’re a more general form of subroutines that don’t follow the “has to start at top” and “can only exit once” rules. Instead, coroutines can exit whenever they call other coroutines. And the next time they’re called, instead of starting from the top again, they pick up right where they left off.
This makes them naturally suited to expressing the “suspend self, pass control, be ready to resume” concept subroutines have such trouble with. To a coroutine, that’s just a simple call. They don’t need to pass around all that continuation baggage.5
Once we have the ability to define coroutines, we can rewrite our example (using the proposed Swift syntax) as simply:
func reticulate(splines: [Spline])
async -> [Splines] {
let encabulated = await encabulate(splines)
let frobbed = await frob(encabulated)
return frobbed
}
The async
in the func
declaration marks this as a coroutine. The await
operator marks locations the coroutine can suspend itself (and, later, resume from).
Compare this to our original, blocking piece of code:
func reticulate(splines: [Spline]) -> [Spline] {
let encabulated = encabulate(splines)
let frobbed = frob(encabulated)
return frobbed
}
We can see that simply switching from a subroutine to a coroutine gives us our desired non-blocking behavior with near identical expressiveness and zero boilerplate.
The Future (and Futures?)
And that’s just the tip of the coroutine iceberg. They’re the foundation of handy abstractions like Actors and Futures. They’re an incredible tool for parsing and lexing.
And, if we think about it, coroutines are essentially tiny little state machines. What can’t we do with tiny little state machines?!
Exploring all these will have to wait for future posts, though. Probably after we get a Swift implementation to play around with. Let’s keep our fingers crossed for v5!
1: For geeky values of “relevant”.↩︎
2: For highly geeky values of “interesting”.↩︎
3: Or, in cases where the subroutine has access to the state of an object, method.t↩︎
4: FINALLY! ↩︎
5: At least conceptually. Coroutines have to stash the state of their execution environment somewhere. And in theory Swift’s coroutine implementation could just be sugar for rewriting
let foo = await bar()
//rest of the body
into
bar(continuation: { foo in
//rest of the body
})
But as an abstraction, coroutines lets us think about suspending and resuming rather than passing continuations. And they do this regardless of how they “actually” work under the hood.↩︎