7.6. Implementing Callbacks#
When a callback is registered with a pending promise using bind, the promise
must remember what to do after it resolves. In Lwt, the resolution loop runs
callbacks as promises are fulfilled. There is no guarantee about the execution
order of independent callbacks on a promise. If the order matters, the programmer
needs to use composition operators such as bind to express that dependency.
If the promise is rejected, its bind callbacks do not run; instead, the
rejection propagates to the promises returned by bind.
Note
Lwt also supports registering functions that are run after a promise is rejected.
Lwt.catch and try%lwt are used for this purpose. They are counterparts
to Lwt.bind and let%lwt.
Once again, it’s important to keep track of where the concurrency comes from: the OS can carry out asynchronous I/O while the OCaml program does other work. The callbacks themselves run sequentially. The resolution loop does not interrupt a running callback, so a callback that goes into an infinite loop prevents other callbacks from running. That makes Lwt a cooperative concurrency mechanism.
To understand how callbacks work, let’s extend our Promise module with bind.
We will use two new ideas:
A handler remembers work to do when a promise resolves. It runs the callback or propagates a rejection.
A proxy lets two promises share an outcome. It connects the promise returned by a callback to the promise already returned by
bind.
Our implementation runs handlers directly when a promise is resolved. It does not implement an I/O system or a main loop. As before, the promise data structure itself provides no concurrency.
7.6.1. Remembering Work with Handlers#
The only new operation in our signature is bind:
module type PROMISE = sig
...
(** [bind p f] registers callback [f] with promise [p].
When the promise is fulfilled, the callback will be run
on the promise's contents. If the promise is never
fulfilled, the callback will never run. *)
val bind : 'a promise -> ('a -> 'b promise) -> 'b promise
end
The public state type stays the same:
type 'a state = Pending | Fulfilled of 'a | Rejected of exn
A callback receives a fulfilled value and returns a promise. A handler has a
different job: it receives a resolved outcome and performs the work needed to
resolve or link another promise. We represent an outcome with the standard
result type:
type 'a handler = ('a, exn) result -> unit
Ok v means fulfillment with value v, and Error e means rejection with
exception e. There is no constructor for a pending outcome, so the type itself
prevents us from passing one to a handler.
To store handlers, we first replace our reference with a record whose mutable contents can hold a list:
type 'a contents =
| Pending of 'a handler list
| Fulfilled of 'a
| Rejected of exn
type 'a promise = { mutable contents : 'a contents }
type 'a resolver = 'a promise
Only Pending has a handler list. Once the contents become Fulfilled or
Rejected, there is no place to store waiting handlers. Again, the type enforces
part of our representation invariant. Internally, a resolver is still the same
record as its promise; the signature keeps those roles distinct for clients.
7.6.2. Bind and Its Handler#
There are three promises to keep track of in let p3 = bind p1 f, assuming that p1 is pending:
p1is the input promise. Its fulfilled value will be the argument tof.p2is the promise thatfeventually returns. It might already exist beforefruns, and it might still be pending whenfreturns it.p3is the new pending promise thatbindreturns immediately. Its eventual outcome must be the same asp2’s.
The pending case of bind creates p3 and its resolver, makes a handler that
captures both f and that resolver, and stores the handler on p1:
| Pending ->
let p3, p3_resolver = make () in
let h = make_handler f p3_resolver in
enqueue h p1;
p3
The handler will run when p1 resolves. It does not run merely because bind
registered it, and bind does not wait for it before returning p3.
Here is the helper that makes that handler. For now, assume that link p2 r
arranges for the promise associated with r to share p2’s outcome. We will
implement link below.
let make_handler (f : 'a -> 'b promise) (r : 'b resolver)
: 'a handler = function
| Error exc -> reject r exc
| Ok v1 ->
let p2 = try f v1 with e -> fail e in
link p2 r
If p1 was rejected, the handler rejects p3 with the same exception without
running f. If p1 was fulfilled, the handler runs f on its value. If f
raises an exception, fail creates an already-rejected promise, so linking
propagates that rejection too:
let fail exc = { contents = Rejected exc }
Notice the scope of the try: it covers only f v1. The call to link is
outside it. An exception raised by the callback becomes a rejected promise;
an error in the promises implementation itself must not be mistaken for an
exception from that callback.
7.6.3. Running Handlers#
To resolve a pending promise, we need to save its handlers, replace its contents with the resolved outcome, and then run the saved handlers with that outcome. Changing the contents first ensures that handlers observe a resolved promise, and also removes the stored list before any handler runs.
Here is resolve in its final form. The helper underlying, defined below,
follows any proxy links to the record that owns the contents. Until we introduce
proxies, you can read underlying r as just r.
let resolve (r : 'a resolver) (outcome : ('a, exn) result) =
let r = underlying r in
let contents : 'a contents =
match outcome with
| Ok v -> Fulfilled v
| Error e -> Rejected e
in
match r.contents with
| Pending hs ->
r.contents <- contents;
List.iter (fun h -> h outcome) (List.rev hs)
| Fulfilled _ | Rejected _ ->
invalid_arg "Already resolved"
| Proxy _ -> assert false
let reject r x = resolve r (Error x)
let fulfill r x = resolve r (Ok x)
let return x = { contents = Fulfilled x }
resolve takes over the job of the earlier write_once helper: it rejects a
second attempt to resolve a promise. The final case is impossible because
underlying never returns a proxy.
We will prepend handlers when registering them, so reversing the list before
running it preserves registration order within that list. Linking will combine
lists from two promises. Our interface makes no guarantee about the relative
order of handlers from those lists; programs must express required sequencing dependencies explicitly with bind.
7.6.5. Linking Promises#
The operation link p2 p3 makes p3 share p2’s outcome. It requires the
underlying promise of p3 to be pending. First, it follows links from both
promises. If the two owners are already the same, it does nothing. Otherwise,
it either merges two pending promises or propagates an already-resolved
outcome:
let link (p2 : 'a promise) (p3 : 'a promise) : unit =
let p2 = underlying p2 in
let p3 = underlying p3 in
if p2 == p3 then ()
else
match (p2.contents, p3.contents) with
| Pending hs2, Pending hs3 ->
p3.contents <- Pending (hs2 @ hs3);
p2.contents <- Proxy p3
| Fulfilled v, Pending _ -> fulfill p3 v
| Rejected e, Pending _ -> reject p3 e
| _, (Fulfilled _ | Rejected _) ->
invalid_arg "Cannot link into a resolved promise"
| Proxy _, _ | _, Proxy _ -> assert false
The identity test uses physical equality, ==, because we want to know whether
these are the same record. Structural equality, =, would try to compare
contents, which can include functions. Making an owner a proxy for itself would
create a cycle; the identity test prevents that, including when distinct proxy
records already lead to the same owner.
When both promises are pending, we preserve both lists of handlers in p3
before replacing p2’s contents with a proxy. The list concatenation preserves
order within each original list. Because resolve reverses the combined list,
this particular implementation runs the old p3 handlers before the old p2
handlers.
Here is a trace using only the public interface:
let p1, r1 = make ()
let p2, r2 = make ()
let p3 = bind p1 (fun () -> p2)
Initially, all three promises are pending, and p1 stores the handler that
captures the callback and p3’s resolver. Calling fulfill r1 () runs that
handler. The callback returns the still-pending p2, so link makes p2 a
proxy for p3. Both state p2 and state p3 still report Pending.
Next, calling fulfill r2 42 follows the proxy from p2 to p3, stores
Fulfilled 42 there, and runs the handlers from both promises. Now both
state p2 and state p3 report Fulfilled 42. Calling fulfill r2 again
would raise Invalid_argument, because the underlying promise is already
resolved. Rejection follows the same path, with an exception instead of a value.
7.6.6. Completing Bind#
Now we can put the three cases of bind together:
let bind (p1 : 'a promise) (f : 'a -> 'b promise) : 'b promise =
match state p1 with
| Fulfilled v1 -> (try f v1 with exc -> fail exc)
| Rejected exc -> fail exc
| Pending ->
let p3, p3_resolver = make () in
let h = make_handler f p3_resolver in
enqueue h p1;
p3
Using state p1 ensures that bind also works when its input is a proxy. If the
input is already fulfilled, bind runs f immediately and returns the promise
that f returns. If f raises, bind returns a new rejected promise. If the
input is already rejected, bind propagates that rejection without running f.
Only the pending case needs a new pending promise and a stored handler.
We have developed a small promise implementation with handlers to remember work and proxies to share outcomes. It illustrates the data structures behind callback sequencing, while leaving out the scheduler, cancellation, and other facilities of a production library. The next section collects the complete code and demonstrates both immediate callback results and pending results that need proxy linking.
Warning
Lwt makes a different choice than we did here when the input promise is already fulfilled and the callback raises an exception. In that case, Lwt has bind raise the exception itself, instead of returning a rejected promise that contains the exception. This is known to violate the specification of bind but is not going to be fixed for backward compatibility reasons.