29 — Continuations
Tuesday, 21 April 2020
At any point during a program execution, we can look at the history of the
process, its current state, and the rest of the computation—
Some programming languages provide programmatic access to continuations. Scheme programs, for example, may turn it into a function; so does Racket as a descendant of Scheme. Dialects of ML (SML/NJ, OCaml) supply a continuation object on demand, to which programs can throw values; Smalltalk and Squeak call it a stack object (I think).
Unsurprisingly, the power of manipulating continuations as ordinary value is extreme (though the cost isn’t low). With the availability of continuation objects, software developers can create any kind of control pattern that the language designer deemed unworthy pf a special construct: Python-style generators, backtracking, intelligent search, context switching, relating event handlers to procedural libraries, time-preempted encapsulation, process management, and more.
You may think that these topics sound rather esoteric. But it turns out that
ordinary consumers of software started operating this way in the late 1990s,
with the advent of the web. People would open several browser windows for the
same site, say for booking flights, to compare them. They would switch back and
forth to explore details. And then they would decide to purchase one—
The goal of this lecture is to provide a glimpse at the expressive power of
continuation objects. We will start with simple examples, progress to
Python-style generators as an intermediate point, and finish up with the core of
a process management component in the operating system. Indeed, this idea of
managing computational processes in the operating system—
Sharing Resources, Sharing Time
No matter how “wealthy” our computers become, some computational resource (time, energy, storage memory, printers, network access, etc) will always be scarce. It is the task of the operating system to play arbiter—
market in economic terms— to match demand for a resource with supply.
The Big Picture
The dispatcher is the first small piece of software that exists. It uses its time slice to observe an associated queue. When a process shows up in the queue, the dispatcher sets up this first process to run. A process may spawn other processes, an act that means that a new program is added to the end of the queue. If the currently running process exhausts its allocated slice of time on the CPU before it completes its computation, the dispatcher’s alarm goes off and the rest of the current process’s computation is turned into a new process, which is added to the end of the queue.
The first five rows of the following table illustrate how this phase of process-dispatching works:
1 |
|
| the dispatcher exists, Process 0 sows up in the queue | |
2 |
|
| the dispatcher sets the clock and permits Process 0 to run | |
3 |
|
| Process 0 spawns Process 1, which gets added to the end of the queue | |
4 |
|
| Process 0 does not complete in the allotted time, the dispatcher swaps it out, adding the rest of the computation to the end of the queue | |
5 |
|
| the spawned Process 1 is given time on the CPU/core | |
6 |
|
| eventually many processes are in the queue with one running at any moment |
Processes cannot only spawn another process, they may also suspend themselves because there is no work to do; they can request that other processes suspend themselves or even kill themselves.Do read up on why the Java group deprecated the Thread.stop procedure.
At first glance, an operating system uses a generator-style yield construct to switch back and forth between its driver and the processes. Except that time seems to play a role, and time does not play a role in the generators you know.
Using Racket
This lecture uses plain Racket instead of the model languages of the past. Thus,
instead of |
| we write |
"grab" |
| |
"@" |
| a variable definition |
"=" |
| |
"!" |
| a variable reference |
"if-0" |
| if plus predicates |
Small Examples
Figure 113 collects some “finger exercises” that we considered in the context of Toy, with "grab" instead of let/cc.
Lectures/29/example-plain.rkt
#lang racket ;; - - - - - - - - - - - - - - - - - - - - - ;; what is the rest of the computation: (+ 1 (let/cc done (* 2 (done 42)))) ;; here is one way to represent it: #; (λ (x) (stop (+ 1 x))) ;; The stop part reminds us that the reified ;; continuation is a bit more than a function: ;; it eliminate the rest of the computation ;; at its own call site. ;; - - - - - - - - - - - - - - - - - - - - - (/ (let/cc exit1 (* 2 (let/cc done (+ 1 (exit1 42))))) 2) ;; - - - - - - - - - - - - - - - - - - - - - (/ (let/cc exit1 (* 2 (let/cc done (+ (done 1) (exit1 42))))) 2) ;; - - - - - - - - - - - - - - - - - - - - - (let ([janus (λ (x) x)]) (set! janus (let/cc flip-janus (if (and (number? janus) (zero? janus)) 0 flip-janus))) (if (and (number? janus) (zero? janus)) 42 (* 666 [janus 0])))
What is the rest of the computation for each let/cc expression?
What is the rest of the computation that every call of a continuation eliminates?
; flip-janus can be represented as: (λ (result-of-letcc-done) (stop (set! cell result-of-letcc-done) (if (and (number? cell) (zero? cell)) 42 [janus 0])))
Generators
Racket has a generator library, and it is possible to make an identical implementation and override define. Doing so might obscure the pedagogical point.
For those unfamiliar with these generators, they are roughly like a function. When called a first time, they initialize their control state. Instead of computing one final value, the generator then launches a computation that can be suspended at any point and send a value to the caller. When the generator is called again, it resumes the computation at the point where it was last suspended and continues as before. In principle, generators never terminate. If they do, our implementation raises an exception.
the generator’s name, count-down,
a second identifier, suspend-yield, with which the generator produces intermediate values, and
the underlying function, which describes the computational process.
Lectures/29/gen-define.rkt
#lang racket (provide count-down using-exn) ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - (require "gen-implementation.rkt") ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - ;; gen yields n, n-1, n-2, ... every time it is called ;; until it falls off the end (define yield displayln) (define count-down-f (λ [x] [local ([define pythn (λ (x) (cond [(zero? x) using-exn] [else (yield x) (pythn (- x 1))]))]) (pythn x)])) (define/generator count-down suspend-yield (λ [x] [local ([define pythn (λ (x) (cond [(zero? x) using-exn] [else (suspend-yield x) (pythn (- x 1))]))]) (pythn x)])) (define using-exn 111)
The purpose of the generator in figure 114 is to produce the numbers n, n-1, ... when given n.
Figure 115 shows how to use this generator. There is an initial call, plus two more with whimsically chosen arguments, because the arguments to the call do not matter.
Lectures/29/gen-using.rkt
#lang racket (require "gen-define.rkt") ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - (define [using n] (let* ([x (count-down n)] [y (count-down 33)] [z (count-down 33)]) (+ z y x))) (module+ test (require rackunit) (check-equal? (using 3) 6)) Figure 115: Using the Generator of (figure-ref fig:define-generator)
Stop! Since the initial argument is 3, what should be the result of using? What if we call using with 4?
Lectures/29/gen-implementation.rkt
#lang racket ;; - - - - - - - - - - - - - - - - - - - - - - - - - - (provide #; (define/generator g:id name-of-yield:id body:expr) ;; SYNTAX ;; implements a Python-style generator that is like a ;; funciton but `yields` intermediate values define/generator) (define-syntax-rule (define/generator gen yield pythn) (define gen (local ((define (yield y) (let/cc c (define go resume) (set! resume (enter c)) (go y))) (define [[enter c] z] (let/cc k (set! resume k) (c z))) (define resume (enter (λ (x) (the-end (pythn x)))))) (λ (x) [resume x])))) (define (the-end x) (error 'gen (~a THE-END x))) (define THE-END "gen fell off the end ") (define gen (local ((define [[enter c] z] (let/cc k (set! resume k) (c z))) (define (yield y) (let/cc c (define go resume) (set! resume (enter c)) (go y))) (define resume (enter (λ (x) (the-end ('pythn x)))))) (λ (x) [resume x])))
Finally, figure 116 implements generators in terms of continuations. The define-syntax-rule construct translates the first line, a pattern, into the template expression below; in particular, Racket’s syntax system recognizes the pattern in every module that imports this syntax extension and replaces it with the template after replacing the pattern variables in the template with their matches.
The program context and the generator act as partners in a conversation. At any point in time only one partner computes and then yields the right to the other one.
when the generator computes, our implementation must know the continuation of the client program, that is, the continuation with respect to the last call site of the generator
when the client program computes, our implementation must know the continuation of the generator, that is, from which place it will resume its computational process.
The local function definitions serve the purpose of managing the continuations and the back and forth between the two parties. (1) The resume variable stores the continuation of the inactive partner. Its initial value is basically the given function (pythn). With the-end we guarantee that it doesn’t fall off the end.
The enter and yield functions are responsible for keeping
track of which continuation to store in resume. The first,
enter, consumes a function of one argument and returns a function of
one argument. The result grabs the current continuation of the client program,
stores it in resume, and calls the given function. The second,
yield, consumes a value, grabs the continuation of the generator,
retrieves the current value of resume, turns the continuation into the
next resume point, and then sends the given value back to the
caller—
A Kernel
If you like to read more about the connection between programming languages and operating systems, let me recommend readings on the Lisp machine, the Smalltalk programming system, and modern resurrections such as The Revenge of the Son of the Lisp Machine and Microsoft’s Singularity Project.
An operating system is a collection of things that don’t fit into a language. There shouldn’t be one. – Dan Ingall, Byte Magazine 1991
Dan Ingall was a member of the team that produced SmallTalk, the very first object-oriented programming language. (Okay, I don’t like Simula67.)
The construction of a process manager assumes the existence of a
timer, shown in figure 117. A timer is like a programmable stop
watch. We can set it to run for n msec. When it reaches 0, it
calls the timer interrupt handler, a function that may inspect and manipulate
the state of the currently running process. Finally, a timer can be stopped, in
which case no action is taken.—
(provide ; [ {-> Empty} -> Void ] ; (set-timer-interrupt-handler ih) runs ih when the timer expires set-timer-interrupt-handler ; [ N -> Void ] ; (start-timer n) set a timer to expire in n msec start-timer ; [ -> Void ] stop-timer)
The process manager is presented in figure 118. The module exports two functions: boot and spawn. The boot function consumes a code function. It uses spawn to turn the code function into a process; this act also adds the process to the process *queue. The heart of boot is a driver that inspects the process queue, pulls out the first process, and gives this process a slice of processing time. If the queue is ever empty, driver shuts down.
Lectures/29/driver.rkt
#lang racket/gui (provide #; { [-> Void] -> Void } ;; consumes the code (function) for making an initial process boot #; { [-> Void] -> Void } ;; grants access to processes to spawn more processes spawn) ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - (require "clock.rkt") (require "queue.rkt") (require "process.rkt") ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - (define TICK 5) (define *queue (new queue%)) (define (boot make-process-0) (define p0 (spawn make-process-0)) (let driver () (cond [(send *queue deq!) => (λ (p) ;; p becomes the current process (let/cc back-to-driver (set-timer-interrupt-handler (make-timer-interrupt-handler p back-to-driver)) (start-timer TICK) (send p run back-to-driver)) (driver))] [else '[all done]]))) #; { [-> Void] -> Void } (define (spawn function-of-process) (define p (new process% [code (λ () (function-of-process) (stop-timer))])) (send *queue enq! p) p) #; { Process Continuation -> Empty } (define (make-timer-interrupt-handler p back-to-driver) (λ () (let/cc rest-of-process (send p swap (lambda () (rest-of-process 'go))) (send *queue enq! p) (back-to-driver))))
It does not rely on the running process to yield control; instead it sets up the timer and uses the timer-interrupt handler to hand control back to the driver.
Specifically the handler capture the current continuation of the running process, which represents its (control state plus environment and thus access to storage). Then it remembers this continuations in the process itself.
Instead of two partners, the driver scenario deals with many. The driver itself is a party and its continuation is available both to the handler and the run method of the process. Thus no matter whether the process has to be forced out or shuts down, the locus of control can return to driver. The process data representations store the continuations of their computational processes.
Figure 119 demonstrates how to use boot with P0, which in turn starts P1. Both processes here are based on the same code, but abstraction permits separation of action.
Lectures/29/booting.rkt
#lang racket (require "driver.rkt") (define [(make-process action outputs)] (action) (let loop ([n outputs]) (when (cons? n) (displayln `[~~~~~~ ,(first n)]) (loop (rest n)))) (displayln `[-- ,(string-join (map ~a outputs) ", ") --])) (define P0 (make-process (λ () (spawn P1)) (build-list 10 -))) (define P1 (make-process void (build-list 10 +))) (boot P0)
A process data representation is an object with two methods. The run method accepts a return continuation, which is called in case the underlying code runs to completion. The swap method updates the control state of the process, that is, what is left to do when it gets another chance to run. See figure 120 for details.
Lectures/29/process.rkt
#lang racket (provide #; { Class [init (-> Void)] (show (->m JSexpr)) (run (->m Continuation)) (swap (->m Continuation)) } process%) ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - (define process% (let* ([*pid 0] [pid+ (λ () (begin0 *pid (set! *pid (+ *pid 1))))]) (class object% [init code] (super-new) (field [thunk (lambda () (code) (return))]) (field [return #false]) (field [pid (pid+)]) (define/public (show) `[process ,pid]) (define/public (run back) (set! return back) [thunk]) (define/public (swap t) (set! thunk t)))))
Auxiliaries
The queue implementation is entirely standard.
Lectures/29/queue.rkt
#lang racket (provide #;{ (All (X) (Class [enq! (->m X Void)] (deq! (->m (U X False))))) } queue%) ;; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - (define queue% (class object% (super-new) (define *q '[]) #; {-> JSexpr} (define/public (show) `[queue ,(map (λ (i) (send i show)) *q)]) #; {X/show -> Void} (define/public (enq! p) (set! *q (append *q (list p)))) #; {-> (X/show u False)} (define/public (deq!) (and (cons? *q) (begin0 (first *q) (set! *q (rest *q)))))))