Escaping Effects

2022-10-27


The upcoming release of the OCaml compiler (OCaml 5) has support for multicore parallelism and effects. This short post looks at one issue when trying to use effects and composing handlers vertically. For an introduction to effects, have a read of KC's excellent chapter in the manual.

After writing this, I think it essentially describes the problem of Handling Bidirectional Control Flow.

Effect Handlers

When working with effects in OCaml, you must "install an effect handler". This is somewhat analgous to wrapping an exception raising function with a try...with.... If you don't, you will get an unhandled effect exception.

open Effect.Deep
type _ Effect.t += GetName : string Effect.t

let get_name () = Effect.perform GetName

let main () = Printf.printf "Hello %s" (get_name ())

So far, we've made an effect that goes looking for a name. If we try to run the program.

# main ();;
Exception: Stdlib.Effect.Unhandled(GetName)

We get an exception because we didn't install a handler! Let's try that again.

# let alice_handler main =
  try_with main () {
    effc = fun (type a) (e : a Effect.t) -> match e with
      | GetName -> Some (fun (k : (a, unit) continuation) -> continue k "Alice")
      | _ -> None
  };;
val alice_handler : (unit -> unit) -> unit = <fun>
# alice_handler main;;
Hello Alice
- : unit = ()

Great!

Escaping Effects

Eio is a library that provides direct-style, asynchronous IO operations using effects. The handler in this case would be the Eio_main.run function.

# Eio_main.run;;
- : (< clock : Eio.Time.clock; cwd : Eio.Fs.dir Eio.Path.t;
       domain_mgr : Eio.Domain_manager.t; net : Eio.Net.t;
       stdin : Eio.Flow.source; stdout : Eio.Flow.sink > ->
     'a) ->
    'a
= <fun>

The Eio README gives a very thorough introduction to the library and I highly recommend giving it a read.

So what do we mean by vertically composing effect handlers. It's similar to function scope, in the sense that if we have two or more different handlers (i.e. not sharing the same effects) one will be nested inside the main function of the other. If we were to vertically compose Eio_main.run with alice_handler we could do it one of two ways.

Eio_main.run @@ fun _env -> ... alice_handler @@ fun () -> 

Or

alice_handler @@ fun () -> ... Eio_main.run @@ fun _env -> ...

And for the most part this might seem fine. For example:

# let main ~clock =
  Eio.traceln "This time is %f" (Eio.Time.now clock);
  Eio.traceln "Hello %s" (get_name ());;
val main : clock:#Eio.Time.clock -> unit = <fun>
# Eio_main.run @@ fun env ->
  let clock = Eio.Stdenv.clock env in
  alice_handler @@ fun () ->
  main ~clock ;;
+This time is 1623940778.270336
+Hello Alice
- : unit = ()

Seems fine, but things can break quite easily.

# let two_fiber ~clock =
  Eio.Fiber.both
    (fun () -> Eio.traceln "First this...")
    (fun () -> main ~clock);;
val two_fiber : clock:#Eio.Time.clock -> unit = <fun>
# Eio_main.run @@ fun env ->
  let clock = Eio.Stdenv.clock env in
  alice_handler @@ fun () ->
  two_fiber ~clock ;;
+First this...
+This time is 1623940778.270336
Exception: Stdlib.Effect.Unhandled(GetName)

This time we have an unhandled effect even though both handlers are there, what went wrong? Well, we can mimic the same kind of thing with nested exception handlers.

# try
    try raise Not_found with (Invalid_argument _) -> print_endline "invalid_arg"
  with
    Not_found -> print_endline "not_found"; invalid_arg "Woops";;
not_found
Exception: Invalid_argument "Woops".

By running get_name inside the second function passed to Fiber.both we end up jumping outside of alice_handler and running directly inside Eio's scheduler. At this point we then perform the GetName effect and continue "upwards" were there are no more handlers and hence we get the unhandled effect exception.