Lectures 18-19: More Async

So far, we have seen how to describe concurrent computations using the simple primitives of bind (often written >>=) and return. The return function takes a value and returns a deferred computation that is determined immediately, while d >>= f waits for d to become determined, and then passes the resulting value to f, which also computes a deferred value. In general, there may be many deferred computations executing concurrently, and we will need to coordinate and synchronize their behavior.

These notes build on material and code examples originally developed in Real World OCaml Chapter 18.

Simple Synchronization

A simple way to coordinate two deferred computations is to wait for both of them to become determined. The function both does this:

val both : 'a Deferred.t -> 'b Deferred.t -> ('a * 'b) Deferred.t
It is easy to implement both as:
let both (d1:'a Deferred.t) (d2:'b Deferred.t) : ('a * 'b) Deferred.t = 
  d1 >>= (fun v1 -> 
  d2 >>= (fun v2 -> 
  return (v1,v2)))
or
let both (d1:'a Deferred.t) (d2:'b Deferred.t) : ('a * 'b) Deferred.t = 
  d2 >>= (fun v2 -> 
  d1 >>= (fun v1 -> 
  return (v1,v2)))

More generally, we may want to wait for a list of deferred values to become determined:

val all : ('a Deferred.t) list -> ('a list) Deferred.t
One can think of all as a kind of barrier which ensures that all of the deferred values in the list have been fully computed before proceeding. The resulting values are available in the final list. Here is one possible implementation of all:
let rec all (l:('a Deferred.t) list) : ('a list) Deferred.t = 
  List.fold_right
    (fun x acc -> 
      x >>= (fun h -> 
      acc >>= (fun t -> 
      return h::t)))
    l (return [])

There is a dual to all called any that waits for some value in a list of deferreds to become determined:

val any : ('a Deferred.t) list -> 'a Deferred.t
If all behaves like a barrier, then any is like a function that chooses just one of the deferred computations in the list. Note that the Async scheduler makes no guarantees that the deferred computation selected will be the first to become determined.

Timeouts

One use for any is to create functions that implement timeout functionality. In certain situations, it is desirable to execute a computation with a fixed time budget. This is easy with a helper function after that builds deferred computations which becomes determined at a fixed time in the future and the any function just discussed. The after function has the following signature:

val after : Core.Span.t -> unit Deferred.t
The type Core.Span.t represents a span of time. The function sec computes a span in seconds:
val sec : float -> Core.Span.t
Hence, the expression
after (sec 5.3)
is a deferred computation that will become determined after approximately 5.3 seconds (subject to the imprecision induced by the scheduler). Using this, we can write a higher-order function that executes another function f subject to a time budget:
let timeout (thunk:unit -> 'a Deferred.t) (n:float) 
  : ('a option) Deferred.t
  = Deferred.any 
    [ after (sec n) >>| (fun () -> None) ; 
      thunk () >>= (fun x -> Some x) ]
The expression timeout thunk n attempts to execute the thunk and wraps the result in the Some constructor for the option datatype. However, if the time budget is exceeded, then the unit value computed by after become determined and the whole computation returns None instead. We use similar functionlity in our automated testing of your problem sets.

Ivars

Many patterns of coordination and synchronization can be implemented using simple functions like both, all, and any. But in other situations, more intricate behavior is needed. An ivar can be used to implement fine-grained coordination and synchronization between deferred values. Intuitively, an ivar is like a reference that can be assigned to or filled exactly once. In this way, an ivar is similar to a deferred computation, which is intially undetermined and then becomes determined exactly once.

The ivars interface is captured by the following code:

module IVar : sig
  type 'a t
  val create : unit -> 'a t
  val fill : 'a t -> 'a -> unit
  val fill_if_empty : 'a t -> 'a -> unit
  val is_empty : 'a t -> bool
  val is_full : 'a t -> bool
  val read : 'a t -> 'a Deferred.t
  ...
end = 
struct 
  ... 
end
Consult the Ivar documentation for further details.

The key thing to note about the Ivar module is the read function, which constructs a deferred value that becomes determined when the ivar is filled. We will use this functionality in the next example.

Delaying Scheduler

To illustrate the use of ivars, we will implement a simple scheduler that delays jobs by a fixed amount of time. We will need the function upon,

upon : 'a Deferred.t -> ('a -> unit) -> unit
which waits for a value in the first argument to become determined, and then executes the code in the (possibly side-effecting) second argument.

The interface for the delaying scheduler is as follows:

module type DELAYER = sig
  type t
  val create : float -> t
  val schedule : t -> (unit -> 'a Deferred.t) -> 'a Deferred.t
end
The create function initializes a scheduler that will delay jobs by a fixed amount of time. The schedule function schedules a job provided by a thunk that produces a deferred value. The overall result of schedule is determined when the job has been run and its value has become determined.

The implementation for the delaying scheduler is as follows:

module Delayer : DELAYER = struct
  type t = { delay: Time.Span.t;
             jobs: (unit -> unit) Queue.t }

  let create delay =
    { sec delay; 
      jobs = Queue.create () }

  let schedule t thunk =
    let ivar = Ivar.create () in
    Queue.enqueue t.jobs (fun () ->
      upon (thunk ()) (fun x -> Ivar.fill ivar x));
    upon (after t.delay) (fun () ->
      (Queue.dequeue_exn t.jobs) ());
    Ivar.read ivar
end
Note that the job is enqueued immediately and dequeued after the delay has elapsed. Also note how ivar is filled and used to communicate the final result.

Choice

The any function provides a way to choose just one of several deferred computations. But if those deferred computations have side effects, such as opening files or socket connections, printing to the console, etc. then the effect may not be what is desired. This can lead to problems in general if these side effects are not correctly handled. However, OCaml does not provide general mechanisms to cleanly revert or shutdown a computation that has been discarded.

To address this problem, Async provides the notion of a choice:

type Deferred.choice 
To construct a value of type choice, one provides a deferred computation as well a function:
val choice : 'a Deferred.t -> ('a -> 'b) -> 'b Deferred.choice
Intuitively a choice can be thought of as representing a computation where the function is executed if the deferred computation becomes determined and the choice is chosen. By placing all side-effecting pieces of the computation in the function, programmers can obtain finer-grained control if and when computations are discarded.

The function choose selects a list of choices:

val choose : ('a Deferred.choice) list -> 'a Deferred.t
Just like any, the implementation provides no guarantees about which element will be chosen, or when it will be determined. However, importantly it does guarantee that only one of the functions for the choice elements is executed. This can be used to write clean shutdown code in programs that combine deferred computation and side effects. See Real World OCaml Chapter 18 for a fully worked out example using choice to build a web search client.