Continuations are a famously mind-bending idea, and this article doesn’t totally explain what they are or what they’re good for. If you aren’t familiar with continuations, you might catch on from the examples, or you might want to consult another source first (1
,2
,3
,4
,5
,6
).

Small examples

I’ll get to the implementation later, but right now let’s see what thesefork
-based continuations can do. The interface looks like this.

is a wrapper that can hold function-like values, such as function objects or C-style function pointers. Socall_cc
will accept any function-like value that takes an argument of typecont
and returns a value of typeT
. This wrapper is the first of severalC++11
features we’ll use.

call_cc
stands for “call with current continuation”, and that’s exactly what it does.call_cc(f)
will callf
, and return whateverf
returns. The interesting part is that it passes tof
an instance of ourcont
class, which represents all the stuff that’s going to happen in the program afterf
returns. Thatcont
object overloadsoperator()
and so can be called like a function. If it’s called with some argumentx
, the program behaves as thoughf
had returnedx
.

The types reflect this usage. The type parameterT
incont
is the return type of the function passed tocall_cc
. It’s also the type of values accepted bycont::operator()
.

We don’t see the ”k returns
” message. Instead, callingk(1)
bails out off
early, and forces it to return 1. This would happen even if we passedk
to some deeply nested function call, and invoked it there.

This nonlocal return is kind of like throwing an exception, and is not that surprising. More exciting things happen if a continuation outlives the function call it came from.

g
is called once, and returns twice! When called,g
saves the current continuation in a global variable. Afterg
returns,main
calls that continuation, andg
returns again with a different value.

What value shouldglobal_k
have beforeg
is called? There’s no such thing as a “default” or “uninitialized”cont
. We solve this problem by wrapping it withboost::optional

. We use the resulting object much like a pointer, checking for “null” and then dereferencing. The difference is thatboost::optional
manages storage for the underlying value, if any.

Why isn’t this code an infinite loop? Because
invoking acont
also resets global state

to the values it had when the continuation was captured. The second timeg
returns,global_k
has been reset to the “null”optional
value. This is
unlike Scheme’scall/cc
and most other continuation systems

. It turns out to be a serious limitation, though it’s sometimes convenient. The reason for this behavior is that invoking a continuation is implemented as a transfer of control to another process. More on that later.

Backtracking

We can use continuations to implement backtracking, as found inlogic programming
languages. Here is a suitable interface.

bool guess();void fail();

We will useguess
as though it has a magical ability to predict the future. We assume it will only returntrue
if doing so results in a program that never callsfail
. Here is the implementation.

guess
invokescall_cc
on alambda expression
, which saves the current continuation and returnstrue
. A subsequent call tofail
will invoke this continuation, retrying execution in a world whereguess
had returnedfalse
instead. In Scheme et al, we would store a whole stack of continuations. But invoking ourcont
resets global state, including thecheckpoint
variable itself, so we only need to explicitly track the most recent continuation.

Whether code or prose, the algorithm is pretty simple. Start at the upper-left corner. As long as we haven’t reached the lower-right corner, guess a direction to move. Fail if we go off the edge, run into a wall, or find ourselves on a square we already visited.

Once again, we’re making good use of the fact that our continuations reset global state. That’s why we see'X'
marks not on the failed detours, but only on a successful path through the maze. Here’s what it looks like.

To capture a continuation, we fork the process. The resulting processes share apipe
which was created before the fork. The parent process will callf
immediately, passing acont
object that holds onto the write end of this pipe. If that continuation is invoked with some argumentx
, the parent process will sendx
down the pipe and then exit. The child process wakes up from itsread
call, and returnsx
fromcall_cc
.

There are a few more implementation details.

If the parent process exits, it will close the write end of the pipe, and the child’sread
will return 0, i.e. end-of-file. This prevents a buildup of unused continuation processes. But what if the parent deletes the last copy of somecont
, yet keeps running? We’d like to kill the corresponding child process immediately.

This sounds like a use for a reference-counted smart pointer, but we want to hide this detail from the user. So we split off a private implementation class,cont::impl
, with a destructor that callsclose
. The user-facing classcont
holds astd::shared_ptr

to acont::impl
. Andcont::operator()
simply callscont::impl::invoke
through this pointer.

It would be nice to tell the compiler thatcont::operator()
won’t return, to avoid warnings like “control reaches end of non-void function”. GCC provides thenoreturn
attribute

for this purpose.

We want thecont
constructor to be private, so we had to makecall_cc
a static member function of that class. But the examples above use a free functioncall_cc
. It’s easiest to implement the latter as a 1-line function that calls the former. The alternative is to make it afriend
function ofcont
, which requires some forward declarations and other noise.

There are a number of limitations too.

As noted, the forked child process doesn’t see changes to the parent’s global state. This precludes some interesting uses of continuations, like implementingcoroutines
. In fact, I had trouble coming up with any application other than backtracking. You could work around this limitation withshared memory
, but it seemed like too much hassle.

Each captured continuation can only be invoked once. This is easiest to observe if the code using continuations also invokesfork
directly. It could possibly be fixed with additionalfork
ing insidecall_cc
.

Calling a continuation sends the argument through a pipe using a naive byte-for-byte copy. So the argument needs to bePlain Old Data
, and had better not contain pointers to anything not shared by the two processes. This means we can’t send continuations through other continuations, sad to say.

I left out the error handling you would expect in serious code, because this is anything but.

Likewise, I’m assuming that a singlewrite
andread
will suffice to send the value. Robust code will need to loop until completion, handleEINTR
, etc. Or use some higher-level IPC mechanism.

At some size, stack-allocating the receive buffer will become a problem.

It’s slow. Well, actually, I’m impressed with the speed offork
on Linux. My machine solves both backtracking problems in about a second,fork
ing about 2000 processes along the way. You can speed it up more withstatic linking
. But it’s still far more overhead than the alternatives.