ocaml • effects
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.
When working with effects in OCaml, you must "install an effect handler". This is
somewhat analogous 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!
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.