Reinvert control with delimited continuations

July 25, 2015July 26, 2015

While discussing alternatives to javascript in the browser, a friend lamented that even if we were to replace javascript with a better language, we would still not escape "callback hell" if we were forced to keep the event loop model. The problem is that the event loop inverts control.

But given a language with delimited continuation operators, it turns out that you can reinvert control. This article uses the shift and reset operators in racket to demonstrate a technique possible in languages such as scheme, haskell and scala.

Event loops and asynchronous operations

#lang racket

(struct async-operation (compute finish))

(define pending (make-channel))
(define finished (make-channel))

(define (pending-loop)
  (match-let (((async-operation compute finish) (channel-get pending)))
    (thread (lambda ()
              (let ((result (compute)))
                (channel-put finished (lambda () (finish result)))))))
(void (thread pending-loop))

(define (event-loop)
  (let loop ()
    ((channel-get finished))

(define (pending-add aop) (channel-put pending aop))

(define (async-op args succeed fail)
      (lambda ()
        (displayln (format "async operation started with: ~v" args))
        (sleep 2)
        (displayln "async operation finished")
        (random 2))
      (lambda (result) (if (= 0 result) (succeed) (fail))))))

(define (set-timeout latency callback)
      (lambda ()
        (displayln (format "sleeping for ~a" latency))
        (sleep latency))
      (lambda (_) (callback)))))

This module simulates an event loop by spawning worker threads to process asynchronous operations.

Invoke asynchronous ops while passing callbacks

#lang racket

(define (with-callbacks)
  (displayln "perform an async operation")
    (list 'arg1 'arg2)
    (lambda ()
      (displayln "handle success and perform another operation")
        (list 'arg3 'arg4)
        (lambda () (displayln "handle success again"))
        (lambda () (displayln "handle failure of second operation"))))
    (lambda () (displayln "handle failure of first operation"))))

(displayln "with-callbacks")

To invoke asynchronous operations, we pass two first-class functions representing how to proceed when the operation either succeeds or fails. The callbacks can be seen as manually-lifted first-class continuations. Taken to the extreme, it can be more difficult to read/write programs in such a style.

Reinverting control

#lang racket
  (all-from-out "event-loop.rkt")

(define (async-op-direct . args)
  (shift k (async-op args
                     (lambda () (k #t))
                     (lambda () (k #f)))))

(define (set-timeout-direct latency)
  (shift k (set-timeout latency (lambda () (k (void))))))

We can use the shift operator to grab the continuation to which we want to pass the result of an asynchronous operation when invoked in direct-style. Asynchronous operations transformed in this way can be provided by libraries without any cooperation from the event-loop implementation, demonstrating the technique's general applicability.

Direct-style invocation

#lang racket

(define (direct)
    (displayln "perform an async operation")
    (if (async-op-direct 'arg1 'arg2)
        (displayln "handle success and perform another operation")
        (if (async-op-direct 'arg3 'arg4)
          (displayln "handle success again")
          (displayln "handle failure of second operation")))
      (displayln "handle failure of first operation"))))

(displayln "direct")

Finally, we use a reset block to indicate the extent of sequentially-executed code associated with a series of async-op-direct invocations.

#lang racket

(define-syntax-rule (wait-until body ...)
  (let loop ()
    (set-timeout-direct 1)
    (displayln "are we there yet?")
    (if (begin body ...) (displayln "we're there!")
      (begin (displayln "no, not there yet") (loop)))))

(define (direct-concurrency)
  (define count 10)
  (define results (box '()))
  (define (add-result result)
    (set-box! results (cons result (unbox results))))
    (displayln "perform async operations concurrently")
    (for ((index (range count)))
      (reset (add-result (async-op-direct index))))
    (wait-until (= count (length (unbox results))))
    (displayln results)))

(displayln "direct-concurrency")

Invocations can be performed concurrently by wrapping them in separate reset blocks. These blocks may be embedded in other blocks to control the timing of invocations, satisfying dependencies on earlier asynchronous results.