Spawning a fiber will disable effect handlers installed after main loop

Well, “talk is cheap, show me the code”, so I finally took the time to mock my idea. I do realize this is quite the wall of code, but I don’t mind commenting / clarifying it if there is some interest.

Here is a cheap, sort-of minimal illustration of how eio works right now AFAICT.

module Eio = struct
  type fiber = {
    fiber : (unit, unit) Effect.Shallow.continuation;
    mutable over : bool;
  }

  type t = { mutable fibers : fiber list }
  type _ Effect.t += Fork : fiber -> unit Effect.t | Yield : unit Effect.t

  let run f =
    let t = { fibers = [ { fiber = Effect.Shallow.fiber f; over = false } ] } in
    let rec step t =
      match t.fibers with
      | [] -> ()
      | fiber :: fibers ->
          let () =
            t.fibers <- fibers;
            Effect.Shallow.continue_with fiber.fiber ()
              {
                retc = (fun () -> fiber.over <- true);
                exnc = raise;
                effc =
                  (fun (type effect) (effect : effect Effect.t) ->
                    match effect with
                    | Fork fiber ->
                        Some
                          (fun (k : (effect, _) Effect.Shallow.continuation) ->
                            t.fibers <-
                              ({ fiber with fiber = k } :: t.fibers) @ [ fiber ])
                    | Yield ->
                        Some
                          (fun (k : (unit, unit) Effect.Shallow.continuation) ->
                            t.fibers <- t.fibers @ [ { fiber with fiber = k } ])
                    | _ -> None);
              }
          in
          step t
    in
    step t

  let yield () = Effect.perform Yield

  module Switch = struct
    type t = { mutable fibers : fiber list }

    let run f =
      let switch = { fibers = [] } in
      let res = f switch in
      let () =
        let rec join () =
          match List.find_opt (fun { over; _ } -> not over) switch.fibers with
          | None -> ()
          | Some _ -> yield ()
        in
        join ()
      in
      res
  end

  let fork ~sw f =
    let fiber = { fiber = Effect.Shallow.fiber f; over = false } in
    let () = sw.Switch.fibers <- fiber :: sw.Switch.fibers in
    Effect.perform (Fork fiber)
end

Quite a few lines, but the gist of it is that forking will bubble a lambda to the toplevel scheduler, which holds all the running fibers. Yielding bubbles control back to the scheduler, which round-robins to the next fiber until all are over. Scope end just yields in a loop until all fibers are done (atrocious I know, this is just for test purpose).

Testing it with a minimal example:

let () =
  Eio.run @@ fun () ->
  let test name =
    Format.eprintf "%s start@." name;
    Eio.yield ();
    Ping.ping ();
    Format.eprintf "%s finish@." name
  in
  let () =
    Ping.with_pong @@ fun () ->
    Eio.Switch.run @@ fun sw ->
    let () = Eio.fork ~sw (fun () -> test "forked") in
    test "main"
  in
  Format.eprintf "EOP@."
main start
forked start
main finish
forked finish
EOP

Everything is handled, fibers are interleaved, all good.

Let us now introduce another, dummy effect for demonstration puprose:

module Ping = struct
  type _ Effect.t += Ping : unit Effect.t

  let ping () = Effect.perform Ping

  let with_pong f =
    Effect.Deep.try_with f ()
      {
        effc =
          (fun (type effect) (effect : effect Effect.t) ->
            match effect with
            | Ping ->
                Some
                  (fun (k : (effect, _) Effect.Deep.continuation) ->
                    Format.eprintf "pong@.";
                    Effect.Deep.continue k ())
            | _ -> None);
      }
end

If we try using it inside our test, we hit the snag:


let () =
  Eio.run @@ fun () ->
  let test name =
    Format.eprintf "%s start@." name;
    Eio.yield ();
    Ping.ping ();
    Format.eprintf "%s finish@." name
  in
  let () =
    Ping.with_pong @@ fun () ->
    Eio.Switch.run @@ fun sw ->
    let () = Eio.fork ~sw (fun () -> test "forked") in
    test "main"
  in
  Format.eprintf "EOP@."
main start
forked start
pong
main finish
Exception: Stdlib.Effect.Unhandled(Ping.Ping)

This happen because the forked fiber is actually bubbled out of the with_pong handler to be started by the scheduler, so it’s initial run and all its continuation do not have the handler installed. This is quite unfortunate, because it means effects do not compose transparently with eio. A possible workaround would be for libraries using effect to detect eio and manually reinstall effects on forked as illustrated in my previous post, which makes it transparent for the end user, but is still a hassle for libraries maintainer and relies on eio’s Private modules.

Now my instinct was that if we let scopes handle the forking, we can preserve effect handlers; and actually if you take my initial implementation above and simplify Scope.run to just handle forks with the root scheduling function, it just works.

  module Switch = struct
    type t = unit

    let run f = (* This is the scheduler main run function *) run f
  end

(I swear I didn’t cheat to make it look simple, I was just as baffled by the simplicity of the solution :slight_smile: )

main start
forked start
pong
main finish
pong
forked finish
EOP

The fibers are forked at the scope level, and effect handlers are preserved. The scheduling is quite lacking, as scheduling a scope will not yield until all its fibers are done, starving the rest of the program. But that’s easily fixable, scope just need to forward yields up to the root controller.

Here is a gist of the whole thing: A possible alternative scheduling pattern for Eio that preserves effect handler semantics. · GitHub

Now, again, I might have missed something, or maybe this is prohibitively slow, etc. Curious about @talex5 opinion on this.

Cheers,

2 Likes