At work, we are heavy users of the OCaml Lwt library for promised based concurrent programming. Lwt is popular, actively developed, has exceptional performance, and can run on different platforms!
In this post, however, I would like to discuss some of its limitations when it comes to asynchronous programming. The typical use case would be writing a service that aggregates a bunch of data from other services and/or databases.
The purpose is not to criticize the design of Lwt, it is merely to point out that there are alternative abstractions that may work better for some scenarios. Although I’ll be focusing on Lwt, this is really part of a broader discussion contrasting eagerly evaluated promises with lazy futures.
For an introduction to the Lwt library, see its excellent documentation or check out these blog posts.
What is run and when?
The first challenge when looking at a program written in Lwt is figuring out what things are executed and why. Consider this example:
open Lwt.Syntax
(* val program1 : int Lwt.t *)
let program1 =
let* () = Lwt_io.printl "Kicking off 1" in
let* () = Lwt_io.printl "Running 1" in
let* () = Lwt_unix.sleep 1. in
Lwt.return 1
(* val program2 : int Lwt.t *)
let program2 =
let* () = Lwt_io.printl "Kicking off 2" in
let* () = Lwt_unix.sleep 1. in
let* () = Lwt_io.printl "Running 2" in
Lwt.return 2
Here we have two top-level definitions, program1
and program2
but no
Lwt_main.run
instruction. What does the program output? As Lwt promises are
eagerly evaluated, both program1
and program2
start executing but halts on
their first sleep command. Since there is no scheduler to resume their
operations they are never completed. The output is:
Kicking off 1
Running 1
Kicking off 2
What if we run program1
? There is a function Lwt_main.run
that given
an Lwt value returns a value:
val run : 'a Lwt.t -> 'a
Ignoring the return value, we can use it like so:
let _ = Lwt_main.run program1
As expected, program1
now completes but perhaps surprisingly, the remaining
actions of program2
are also picked up, why the output is:
Kicking off 1
Running 1
Kicking off 2
Running 2
I should say that this is made transparent by the documentation and perfectly in line with the design. There is a global context that knows about all Lwt promises and executes them explicitly or implicitly.
Implicit parallelism
When writing a typical orchestration program that calls various services we need to have control over which operations to run in sequence and what should be performed concurrently. For some programs written in Lwt, this is not always obvious. First, looking at the following snippet:
(* val call_robert : unit -> string Lwt.t *)
let call_robert () =
let* () = Lwt_io.printl "Calling Robert" in
let* () = Lwt_unix.sleep 1. in
let* () = Lwt_io.printl "Robert picked up." in
Lwt.return "Hello"
(* val call_maria : unit -> string Lwt.t *)
let call_maria () =
let* () = Lwt_io.printl "Calling Maria" in
let* () = Lwt_unix.sleep 3. in
let* () = Lwt_io.printl "Maria picked up" in
Lwt.return "Hola"
(* val program : unit Lwt.t *)
let program =
let* _ = call_robert () in
let* _ = call_maria () in
Lwt.return ()
let _ = Lwt_main.run program
By reading the code, it should be clear that call_robert ()
and call_maria ()
are executed in sequence. The output is:
Calling Robert
Robert picked up.
Calling Maria
Maria picked up
What happens if we instead rewrite the program to the version below?
(* val robert : string Lwt.t *)
let robert = call_robert ()
(* val maria : string Lwt.t *)
let maria = call_maria ()
(* val program : unit Lwt.t *)
let program =
let* _ = robert in
let* _ = maria in
Lwt.return ()
From the definition of program
it may still seem like two sequential
operations. However, since both promises robert
and maria
are defined
outside the scope of the monadic composition, they start executing in the
background concurrently; Confirmed by the output:
Calling Maria
Calling Robert
Robert picked up.
Maria picked up
Referential transparency
The underlying principle that makes some programs written in Lwt difficult to reason about is that they are not referentially transparent. It means that you cannot safely refactor code by lifting out some common sub-expressions and giving them names. Let’s look at another concrete example:
(* val increment_count : unit -> int Lwt.t *)
let increment_count =
let n = ref 0 in
fun () ->
let* () = Lwt_unix.sleep 1. in
incr n;
Lwt.return (!n)
(* val program : unit -> unit Lwt.t *)
let program () =
let* c1 = increment_count () in
let* () = Lwt_io.printf "Count is %d\n" c1 in
let* c2 = increment_count () in
let* () = Lwt_io.printf "Count is %d\n" c2 in
Lwt.return ()
let _ = Lwt_main.run @@ program ()
Here the important aspect of increment_count
is that it performs some
observable side-effect – think calling a database for a record update. As
expected, when run, this program yields:
Count 1
Count 2
Since there are two identical calls to increment_count
, and because in
functional programming we’re used to relying on referential transparency, it
is tempting to refactor the code, as in:
(* val program : unit -> unit Lwt.t *)
let program () =
let incr = increment_count () in
let* c1 = incr in
let* () = Lwt_io.printf "Count %d\n" c1 in
let* c2 = incr in
let* () = Lwt_io.printf "Count %d\n" c2 in
Lwt.return ()
let _ = Lwt_main.run @@ program ()
That is not a safe refactoring as it changes the behavior of the program – now we only increment the count once and the output is:
Count 1
Count 1
Impossible combinators
Because Lwt promises are evaluated eagerly there are useful functions that simply cannot be given meaningful implementations. For instance, imagine a combinator that would attach timing information to the execution of a given promise:
val timed : 'a Lwt.t -> ('a * float) Lwt.t
Or, a function for batching parallel requests:
val parallel : batch_size:int -> ('a Lwt.t) list -> ('a list) Lwt.t
And a corresponding version for sequencing a bunch of promises:
val sequence : ('a Lwt.t) list -> ('a list) Lwt.t
The reason they are not feasible is that any promise passed to one of these
functions is already running in the wild and any attempt at scheduling it or
timing it is futile. A workaround is to replace promises of type 'a Lwt.t
with values of type unit -> 'a Lwt.t
but it does make the API less elegant
and composable.
Alternative designs
If you agree with my reasoning above and think it’s worth considering a different semantics, it does not necessarily require throwing out the baby with the bathwater. We may still be able to rely on the Lwt machinery for scheduling and maintaining compatibility with existing Lwt based libraries. What I propose is introducing an abstraction based on unevaluated actions rather then promises.
The mental model for a promise is that of a mutable reference cell that may or may not contain a value, and if not, may eventually be filled with a value or an exception.
An action, on the other hand, would represent a computation that when run may produce a value. Promises are inherently imperative while actions are functional (referentially transparent).
As a simple starting point, we could define an (an abstract) action type as:
type 'a t = unit -> 'a Lwt.t
This allows for the same monadic interface we’re used to from Lwt but with stronger guarantees that code that looks like its executed sequentially, really is. In our example from above:
(* val robert : string t *)
let robert = call_robert ()
(* val maria : string t *)
let maria = call_maria ()
(* val program : unit t *)
let program =
let* _ = robert in
let* _ = maria in
Lwt.return ()
It no longer matters that robert
and maria
are bound in an outer scope. They
represent instructions for how to calculate some values, not the values themselves.
Nothing is executed until explicitly requested, which would be achieved by
invoking a corresponding run function:
let run action = Lwt_main.run (action ())
To express parallel operations we can simply define the let* .. and*
syntax
similar to what exists in Lwt, and use it as in:
(* val program : unit t *)
let program =
let* _ = robert
and* _ = maria in
Lwt.return ()
Or, we could expose some parallel combinator. The point is that we now require the parallel operations to be declared explicitly!
Thanks to referential transparency, we’d also recover the missing count:
(* val program : unit t *)
let program =
let incr = increment_count () in
let* c1 = incr in
let* () = Lwt_io.printf "Count %d\n" c1 in
let* c2 = incr in
let* () = Lwt_io.printf "Count %d\n" c2 in
Lwt.return ()
Additionally, we are allowed to implement the impossible combinators from above:
val timed : 'a t -> ('a * float) t
val parallel : batch_size:int -> ('a t) list -> ('a list) t
val sequence : ('a t) list -> ('a list) t
This is by no means a novel idea and the lazy semantics of an action based API is similar to what’s provided by F#’s Async, the async library in Haskell or Async/await in Rust among many others.
Finally, if we feel like it, we could generalize the type further and also introduce:
- Error handling (similar in spirit to
Lwt_result
) - An environment for passing down configuration options
Something along the lines of:
type ('r, 'a , 'e) t = 'r -> ('a, 'e) result Lwt.t
This would land us closer to the design of the ZIO library in Scala.