Lecture 22: Continuations

SML code examples for this lecture

Continuations are a way of thinking about program control flow in terms of explicitly passing around objects that represent computation to be done.  Unlike function objects, which also represent computation to be done, continuations represent dynamic or run-time computations.  We have already seen this kind of programming construct with exceptions, where raising an exception causes a current computation to be terminated, and another computation to be started, passing some values from the current computation to the new one.  The new computation is not determined lexically (by the surrounding program text), but rather is determined by the run-time path of execution of the computation. That is, we do not know what handler, if any, will handle an exception that has been raised simply by looking at the program text.  We need to simulate the execution of the program to see if there are is dynamically enclosing context that handles the raised exception.  This non-local sort of exit is very powerful, but also can be a bit hard to reason about.  Continuations are an even richer way of representing such non-local control flow, by explicitly passing around an argument that represents a computation.  One can use continuations both to implement exceptions, and also to implement threads.

Continuations are currently primarily of theoretical interest, unlike exceptions and threads which are present in most high-level languages and are widely used in writing large systems. 

One can think of a continuation as something that is waiting for a value, in order to perform calculations with it.  For instance, if we look at a simple arithmetic expression such as (* (+ 1 2) (+ 3 4)), when (+ 1 2)returns the value 3 there is a computation waiting for that result, namely * of that and the result of (+ 3 4)).  At any time in the execution of a sequential program there is a current continuation, namely the rest of the program that remains to be executed.  For instance, after adding 1 and 2 above, the current continuation is to multiply that value by the result of adding 3 and 4. One can think of continuations as the "missing part" of the environment model.  The environment model specifies only how to look up bindings of names, and not control flow.  The current continuation is the remaining computation as each expression is evaluated in the environment model.

SML has a continuation structure called Cont in the SMLofNJ module.  We will consider just two of the operations in that structure, callcc and throw.

signature CONT = 
  sig
    type 'a cont
    (* callcc f applies function f to the current continuation.
     * If f invokes this continuation with argument x it is as
     * if (callcc f) had returned x as a result *)
    val callcc: ('a cont -> 'a) -> 'a
    (* Invoke continuation k with argument a *)
    val throw: 'a cont -> 'a -> 'b
    ...
  end

Let's look at a simple example of using continuations:

open SMLofNJ.Cont;

3 + callcc (fn k => 2 + 1);
3 + callcc (fn k => 2 + throw k 1);

The first of these expressions evaluates to 6, because the computation 2+1 is done, that value is returned by callcc and 3 is added to the result.

The second of these expressions evaluate to 4, because the computation 2 + "something" is done, but that computation rather than returning a value invokes the continuation k with the value 1.  3 is then added to that value (1).  This is a "nonlocal exit" similar to raising an exception, but there is no exception type defined and no handler.  The "return" is to the specified continuation, which in this case is the rest of the computation after 3+ (the current continuation at the time of callcc).

Let's look at a slightly more involved example, but similar in form.

open SMLofNJ.Cont;

fun multiply1 l =
  callcc (fn ret =>
          let 
            fun mult [] = 1
              | mult (0::_) = throw ret 0
              | mult (n::l') = n * mult l'
          in 
            mult l
          end);

fun multiply2 l =
  callcc (fn ret =>
          let 
            fun mult [] = 1
              | mult (0::_) = 0
              | mult (n::l') = n * mult l'
          in 
            mult l
          end);

fun multiply3 l =
  let 
    fun mult [] = 1
      | mult (0::_) = 0
      | mult (n::l') = n * mult l'
  in 
    mult l
  end 

multiply1 [1,2,3,4,5];
multiply1 [1,2,3,4,0,5];
multiply2 [1,2,3,4,5];
multiply2 [1,2,3,4,0,5];
multiply3 [1,2,3,4,5];
multiply3 [1,2,3,4,0,5];

On the list [1,2,3,4,5] each of these functions returns 120 and on the list [1,2,3,4,0,5] each function returns 0.  However there is a critical difference in how multiply1 works compared with the other two.  When multiply1 encounters a 0 value, the current pending computation to multiply all the earlier element in the list is abandoned without doing that work, whereas in the other two functions a multiplication by 0 is done for each previous element in the list in order to eventually return 0.  Continuations provide us with a way of jumping to some other computation. 

Continuations can be used in place of raising exceptions, as in the following example:

open SMLofNJ.Cont;

let
  fun g(n: real) (errors: int option cont) : int option =
    if n < 0.0 then throw errors NONE
    else SOME(Real.trunc(Math.sqrt(n)))
  fun f (x:int) (y:int) (errors: int option cont): int option =
    if y = 0 then throw errors NONE
    else SOME(x div y+valOf(g 10.0 errors))
in
  case callcc(f 13 3) of
    NONE => "runtime error"
  | SOME(z) => "Answer is "^Int.toString(z)
end

Now lets look at a more involved use of continuations, to implement a simple threads package (adapted from A. Appel and J. Reppy - Reppy is the designer of CML).  Unlike real threads, which are preemptively scheduled so that they can be interrupted at any time in the computation, we will have a simple thread package that requires threads to yield to other threads.  However, like real threads it is still unclear what order things will run in, as threads yield after unknown time periods thereby queuing their "remaining work" for later processing.

We will use a simple mutable queue implementation with the following signature:

signature QUEUE =
  sig 
    type 'a queue
    exception Dequeue
    val new : unit -> 'a queue
    val enqueue : 'a queue * 'a -> unit
    val dequeue : 'a queue -> 'a (* raises Dequeue *)
    val clear : 'a queue -> unit
  end 

The signature for our simple threads package will be:


signature THREADS =
  sig 
    exception NoRunnableThreads
    val spawn : (unit -> unit) -> unit
    val yield : unit -> unit
    val exit : unit -> 'a
  end;

We can implement a thread as a continuation, keeping a queue of all the threads that have been spawned and are not currently running.

open SMLofNJ.Cont;

structure T :> THREADS =
  struct
    exception NoRunnableThreads
    type thread = unit cont

    val readyQueue : thread Q.queue = Q.new ()

    fun dispatch () =
      let 
        val t = Q.dequeue readyQueue
                handle Q.Dequeue => raise NoRunnableThreads
      in 
        throw t ()
      end 

    fun enq t = Q.enqueue (readyQueue, t)

    fun exit () = dispatch ()

    fun spawn f = callcc (fn parent => (enq parent; f (); exit ()))

    fun yield () = callcc (fn parent => (enq parent; dispatch ()))
  end;

Here is a simple example of using this threads package:


fun prog1() =
  let 
    val counter = ref 100
    fun spew(s) = if !counter <= 0 then T.exit()
                  else (TextIO.print(s);
                        counter := !counter-1;
                        T.yield();
                        spew(s);
                        ())
  in 
    (T.spawn(fn () => spew("hello!\n"));
     T.spawn(fn () => spew("goodbye!\n"));
     TextIO.print "MAIN THREAD DONE\n";
     T.exit())
  end 
handle T.NoRunnableThreads => TextIO.print "\nDone\n";

Here is a slightly more involved example of using the threads package for a pair of producer and consumer threads:


val buffer : int Q.queue = Q.new ();
val done : bool ref = ref false;
fun deq () = SOME (Q.dequeue buffer) handle Q.Dequeue => NONE;
fun enq (n) = Q.enqueue (buffer, n);

fun producer (n, max) =
  if n > max then (done := true; T.exit ())
  else (enq n; T.yield (); producer (n+1, max));

fun consumer () =
  (case deq()
     of NONE => if !done then T.exit()
                else (T.yield (); consumer ())
      | SOME(n) => (TextIO.print (Int.toString n); TextIO.print " ";
                    T.yield (); consumer ()));

fun run () =
  (Q.clear(buffer); done := false;
   T.spawn (consumer); producer (0,20); ())
  handle T.NoRunnableThreads => TextIO.print "\nDone\n";

run();