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 )
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,