Implementing async
-await
syntax using delimited continuations
When we are writing Haskell code and we need access to weird control operators
(such as call/cc
), we usually use the continuation monad. Because of Haskell's
do
notation, having to write monadic code to get control operators is not so
bad. However, it turns out that if you already have access to some of those
weird control operators, you can get back the syntactic sugar.
I won't go into what the continuation monad is (or why one would want to use it), but I will talk about the general principle of monads. Here is the definition of a monad in haskell:
class Monad f where
return :: x -> f x
bind :: f x -> (x -> f y) -> f y
Monads are useful to model effects. Such an effect can be nondeterminism (the
List
monad), partiality (the Maybe
monad),
statefulness (the State
monad), or even delayed computation (the
Cont
monad). In the above definition, the return
function would wrap a pure value without effects whereas the bind
function would thread effectful computations.
1. async
and await
in Javascript
One very popular monad (it is very much used outside the functional programming
community) is the concept of promise. A promise is a value that you do not yet
have, but that you want to manipulate. For instance, the javascript
fetch
function returns promises which you manipulate through the
.then
method:
const resp_promise = fetch("http://example.org/");
resp_promise.then(resp => {
// Something ...
// ...
})
As any web developper knows, using .then
for managing sequential
promises make for very ugly code:
const resp_promise = fetch("http://example.org/");
resp_promise.then(resp => {
// Something ...
// ...
something.then(value => {
// ...
something_else.then(x => {
//...
})
})
})
Javascript's solution to this problem is async
and await
. Using await
on a
promise will make the program look as if it is waiting for the result and async
marks a function in which you would like to use await
. It turns out that this
piece of syntactic sugar is the same as the more general do
notation in Haskell.
Because the await
keyword is familiar to many people, I will reuse it as a bind
operator in the rest of the article. Because the await
keyword essentially puts
the delimited continuation from a point to the end of the async
function into
the .then
, we can recreate the same behaviour by manually capturing this
said delimited continuation.
2. The concept of continuation
Before we dive into the technical details of implementing async
/ await
syntax,
let me first explain what continuations are. The concept of a continuation
captures the idea of "the rest of the program" from a certain point. Let's say
an interpreter is evaluating the following expression and has just finished
calculating the b*b - 4 * a * c
sub expression.
(-b + sqrt(b*b - 4 * a * c))/(2*a)
The state of the interpreter holds the computed value (the results of evaluating
b*b - 4 * a * c
) and what to do with it after. That information is the
continuation. Thinking about continuations is useful for writing compilers, but
it is also useful in other contexts. For instance, there is a certain program
transformation (called Continuation-Passing-Style conversion) where every
return
keyword is eliminated and where every function call takes a new
parameter, its continuation. For instance, let's CPS-convert the fib
function.
function fib(n) {
if (n <= 1) {
return n
} else {
return fib(n - 1) + fib(n-2);
}
}
console.log(fib(5))
5
It becomes the following:
function fib_cps(n, ret) {
if (n <= 1) {
ret(n);
} else {
fib_cps(n - 1, r1 => {
fib_cps(n - 2, r2 => {
ret(r1 + r2);
})
})
}
}
fib_cps(5, res => {
console.log(res);
})
5
Notice that instead of using return
, we call a function that is
explicitely passed as a parameter to fib_cps
. Moreover, each time we
call a function, we must act as if it will never return (it will not) and pass a
handler (a continuation) that contains the rest of the computation. Notice that
in the above code, after having computed the results for and
, the results is passed to the continuation of the main call which is
ret
.
While this presentation is quite annoying to write, it lets us add exceptions, generators and other useful fancy control operators into languages that only support passing functions around.
3. Examples
3.1. Implementing exceptions
let exception_handler = null;
// When implementing try/catch, we want to:
//
// 1. save the current exception handler function
// 2. replace the exception handler function by the one given by the user.
// 3. Execute the thunk with the appropriate continuation
// 4. If throw_exception was called, resume the computation through the `ret` continuation.
function with_exception_handler(thunk, handler, ret) {
// save the current exception handler
const old_exception_handler = exception_handler;
exception_handler = (exn) => {
// Reinstall the old exception handler
exception_handler = old_exception_handler;
handler(exn, ret);
};
thunk((thunk_retval) => {
// Reinstall the old exception handler
exception_handler = old_exception_handler;
// and continue the execution through the main continuation
ret(thunk_retval);
})
}
// Notice that contrary to every function in CPS, this one doesn't
// have a "continuation" parameter.
function throw_exception(value) {
// Inside, we just call the exception handler.
exception_handler(value);
}
function safe_div(num, denum, ret) {
if (denum == 0) {
throw_exception("division by zero")
} else {
ret(num/denum)
}
}
with_exception_handler(
(ret) => {
safe_div(0.0, 2.0, (r1) => {
safe_div(1.0, r1, (r2) => {
ret(r2);
})
})
},
(exn, ret) => {
console.log("got an exception:", exn);
// We return null to signal it didn't work.
ret(null)
},
(result) => {
console.log("got result:", result);
}
)
got an exception: division by zero got result: null
3.2. Implementing call/cc
function callcc(handler, ret) {
handler(ret, ret)
}
Implementing call/cc
in CPS is very easy, because CPS exposes every
continuation in an explicit variable. Understanding this control operator,
though, will be useful, because it is often the operator used by languages to
reify continuations and expose them to the user without forcing them to write
everything in CPS!
4. call/cc
in scheme
The way call/cc
works is that it gives as an argument to the thunk
the function capturing the continuation exiting the block. Let's see how it
works in scheme.
(define value
(call/cc (lambda (ret)
(display "one\n")
(display "two\n")
(ret 12345)
(display "tree\n")
(display "four\n"))))
(format #t "value is ~a\n" value)
one two value is 12345
Using this primitive, it is possible to do pretty much everything that was doable in CPS, but without having to actually write CPS code (and endure a thousand levels of nesting).
4.1. Note
It might be very confusing at first, (because up until now, our use of continuations have respected scope), but you can reinstall a continuation after you have executed it. This lets you (for instance) do backtracking and other things.
(define kont
(call/cc (lambda (k) k)))
(display "exited call/cc block\n")
(if (procedure? kont)
(begin
(display "hello\n")
(kont 123))
(format #t "the value: ~a\n" kont))
exited call/cc block hello exited call/cc block the value: 123
4.2. Problems with call/cc
The call/cc
operator, however has flaws. Systems written using this
operator don't compose very well and over the years, proposals have been made
for continuations that don't capture the entirety of program flow. Those
continuations are called delimited.
5. Delimited continuations
Delimited continuations are continuations that end with a value, their
reification in functions are more natural because they are reified into
functions that return something. Because those continuations don't capture the
entire execution of the program, they play well with each other and with other
constructs. Let's see them in action through the call-with-prompt
and abort-to-prompt
scheme operators.
6. call-with-prompt
and abort-to-prompt
In Guile Scheme, delimited continuations are created through two procedures.
call-with-prompt
- You use it together with a special value (a tag) to delimit the end of the continuation you want to take.
abort-to-prompt
- You use it together with the tag to define the location at which continuation will start.
(define tag (make-prompt-tag))
(call-with-prompt tag
(lambda ()
(let ((x 12345))
;; ...
(let ((y (+ x (abort-to-prompt tag))))
;; ...
y)))
(lambda (kont)
;; ...
))
The first argument of call-with-prompt
(the tag) is necessary for
the abort-to-prompt
call to be able to specify which continuation to
open in situations in which multiple call-with-prompt
are nested.
The second argument is a zero argument function (thunk) which will delimit the
end of the captured continuation. When the captured delimited continuation is
called, execution will flow from the abort-to-prompt
call to the end
of this thunk. The third argument is the handler (kind of like try
/ catch
) that
will be called with the created continuation when abort-to-prompt
is
called. To better understand how it works, here is an example:
(define tag (make-prompt-tag))
(define cont
(call-with-prompt tag
(lambda ()
(display "hello!\n")
(let ((x (abort-to-prompt tag "some-value")))
(+ 5 x)))
(lambda (k val)
(format #t "got value: ~s\n" val)
k)))
(display (cont 1)) (newline)
(display (cont 3)) (newline)
Here is the output of the code:
hello! got value: "some-value" 6 8
At first glance, we can see that, contrary to continuations captured by
call/cc
(which act kind of like value-carrying goto
s), these
delimited continuations return something. Another difference is that in
call/cc
, the k
parameter captures the continuation starting from the
call/cc
call itself, whereas in call-with-prompt
it
captures the continuation starting from the innermost
abort-to-prompt
call inside the thunk.
7. async
/ await
syntax for any monad
Now that we understand better the concept of continuation, let's use it to
analyze what the await
keyword does.
One familiar with promises will know that writing this
async function do_something() {
// Some code before the await
const x = await returns_promise();
// Some code after the await
return y;
}
is equivalent to writing that:
function do_something() {
// Some code before the await
returns_promise().then(x => {
// Some code after the await
return y;
})
}
The part that is put in the .then
(the callback) is actually the
continuation starting from the await
and ending when the async
function ends. If we have some kind of .then
operator (called >>=
or
bind
in Haskell), each time we await
an effectful value, we can capture
the current delimited continuation (from the await
to the async
)
and use it as a handler for the value through our .then
function.
Let's use this technique to implement nondeterministic computation.
8. The List
monad
One way of modeling nondeterministic computation is through lists. Each time we
want to say "this value contains a superposition of many Int
s", we
will say [Int]
. When we want to model a nondeterministic
computation on Int
s (let's say, into strings), we use
Int -> [String]
. we combine those two values through first applying
the function to each possible value (getting a [[String]]
), then by
flattening the lists together into a [String]
. When implemented in
Scheme, it looks like this:
(define (.then l func)
(apply append (map func l)))
(define (pure x)
(list x))
Actually implementing the syntax is slightly more tricky.
(define prompt-tag (make-prompt-tag))
;; Awaiting a value (choosing a value among nondeterministic choices)
;; is simple, just abort to the nearest handler and give the list.
(define (await mval)
(abort-to-prompt prompt-tag
mval))
;; When the continuation is to be threaded using nondeterministic
;; value (a list of things), we use .then on the continuation while
;; making sure we re-delimit the end of the continuation using another
;; async block.
(define (async thunk)
(call-with-prompt prompt-tag
thunk
(lambda (cont value)
(.then value (lambda (v)
(async
(lambda ()
(cont v))))))))
Finally we can test our code on a toy example. Here, a sequential nondeterminist choice of number, letter and fruit should yield every combination of number, letter and fruit.
(use-modules (ice-9 pretty-print))
(pretty-print
(async
(lambda ()
(let ((num (await '(1 2 3)))
(letter (await '(a b c)))
(fruit (await '("apple" "orange" "banana"))))
(pure (list num letter fruit))))))
((1 a "apple")
(1 a "orange")
(1 a "banana")
(1 b "apple")
(1 b "orange")
(1 b "banana")
(1 c "apple")
(1 c "orange")
(1 c "banana")
(2 a "apple")
(2 a "orange")
(2 a "banana")
(2 b "apple")
(2 b "orange")
(2 b "banana")
(2 c "apple")
(2 c "orange")
(2 c "banana")
(3 a "apple")
(3 a "orange")
(3 a "banana")
(3 b "apple")
(3 b "orange")
(3 b "banana")
(3 c "apple")
(3 c "orange")
(3 c "banana"))
It works! Note that for the types to work, the output of every async
thunk must be wrapped into a monadic value. You do not have to do this when
using Javascript promises simply because it is done automatically. Note that you
can use the exact same code for every monad, just change the definition of
.then
and pure
. You can use this code to ease the
implementation of monadic parser combinators, promises (which is just, as we
have seen, continuation passing style) and other effects.