A simple scheduler
In our previous chapter, we discussed the idea of backgrounding our echo
function to continue accepting incoming connections. Let's stick with the idea
of being able to background functions:
let fn () = print_endline "World"
let () =
do_this_in_background fn;
print_endline "Hello"
Here, do_this_in_background
would be a function that takes a task and adds it
to a kind of hidden to-do list. Instead of being literally synchronous and
waiting to display "World" before displaying "Hello", we could imagine directly
displaying "Hello" and letting something execute our fn
function to display
"World".
This "something" is what we call a scheduler. It holds our list of tasks to
do and attempts to execute them all in a specific order. In our example, we
would notify our scheduler that a task fn
needs to be done, display "Hello",
and then wait for our scheduler to complete all tasks (including displaying
"World").
In our explanation, we subtly introduced a new concept: waiting. In reality, what we want is to wait for all our tasks to finish. To do this, we could have a "witness" for our tasks so that we can wait for these tasks through their witnesses:
let fn () = print_endline "World"
let () =
let witness = do_this_in_background fn in
print_endline "Hello";
await witness
We're starting to get closer to what every scheduler aims to provide and what asynchronous programming is all about. Let's keep this example in mind and move on to another concept necessary for implementing our scheduler.
Effects
Since OCaml 5, it has been possible to utilise effects. An effect allows you to pause the execution of a function and enter a handler, which, depending on the effect, would execute a specific operation to resume the paused function with the result of that operation.
Here, we're delving into the flow of execution in your program. If we revisit our definition of synchronicity, we understand that our system processes the program line by line. However, effects (as well as exceptions) break this flow; they're known for breaking the linear progression of execution. Consider the example of an exception:
exception My_exception
let () =
try print_endline "Hello";
raise My_exception;
print_endline "World"
with My_exception -> print_endline "My_exception"
In this scenario, our code will print "Hello" and then trigger an exception.
Consequently, the subsequent line won't execute. Instead, this exception will be
"caught" by our handler with ...
. This mechanism attempts to identify the
raised exception and, based on that, execute certain code — in our example,
printing "My_exception".
Raising exceptions or triggering an effect can be likened to a jump in our code. The key distinction between an exception and an effect lies in the ability, for the latter, to return to the point where the effect was initiated.
open Effect.Deep
type _ Effect.t += My_effect : unit Effect.t
let run fn v =
let rec retc x = x
and exnc = raise
and effc
: type c. c Effect.t -> ((c, 'a) continuation -> 'b) option
= function
| My_effect ->
print_endline "My_effect";
Some (fun k -> continue k ())
| _ -> None
and handler = { retc; exnc; effc; } in
match_with fn v handler
let my_program () =
print_endline "Hello";
Effect.perform My_effect;
print_endline "World"
let () = run my_program ()
The mechanics are a bit more intricate, but the principle remains consistent.
Upon executing the above code, we'll observe that we indeed enter our handler
(and display "My_effect" akin to using a with ...
block for exceptions), but
we return to the precise point where the effect was initiated and then proceed
to display "World".
When considering our core challenge of implementing a scheduler, the utility of
effects becomes apparent in obtaining the value k
as a representation of our
suspended function at a specific point — where we triggered our effect. For
managing tasks, with each task as a function, this k
allows us to suspend and
resume functions, maintaining them in a suspended state in the background.
Indeed, for our scheduler, maintaining this suspension is crucial. Rather than simply performing an operation and continuing with the result, we aim to:
- keep the suspension in background
- allow other tasks to execute.
- resume the suspension after giving the opportunity for other tasks to execute.
A task
Now, we need to define what a task is. Earlier, we mentioned this value k
,
which would represent a suspended state of our function. We could define what a
task is (an OCaml function) and in what state this task is:
- an initial state
- a suspended state that can be resumed
- a termination state
open Effect.Shallow
type 'a state =
| Initial of (unit -> 'a)
| Suspended : ('c, 'a) continuation * 'c Effect.t -> 'a state
| Resolved of 'a
Just like with exceptions, we need to "attach" a handler to catch effects and
obtain that famous k
. We can envision a generic handler that generates this
state from the effects produced by a function:
let handler =
let open Effect.Shallow in
let retc v = Resolved v in
let exnc = raise in
let effc
: type c. c Effect.t -> ((c, 'a) continuation -> 'b) option
= fun eff -> Some (fun k -> Suspended (k, eff)) in
{ retc; exnc; effc }
Finally, we can define a task simply as our state.
type task = Task : 'a state -> task
At this stage, we have tasks, and it is now essential to define the operations that we can perform with them. If we revisit our code from the very beginning, which encapsulates the idea that we want to put tasks in the background, we essentially perform two operations:
- we notify the scheduler of a new task (with our
do_this_in_background
). - we wait for our task with a witness of it.
A promise
This witness holds a term commonly found in asynchronous programming: a
promise. Indeed, this witness is a promise that our task will be executed
(but is not executed yet). Waiting for a task via its promise simply corresponds
to obtaining the result of our task. In our example, this result is () : unit
because we are merely displaying text, but we could easily imagine a hefty
computation (such as finding a prime number) that we would want to run in the
background.
In short, the promise would allow us to obtain this result. We could define it as a mutable value that changes as soon as our task is completed:
type 'a promise = 'a option ref
Now we can define our effects that will interact with our scheduler:
type _ Effect.t += Spawn : (unit -> 'a) -> 'a promise Effect.t
type _ Effect.t += Await : 'a promise -> 'a Effect.t
Our scheduler
Our types are defined, and we know how to obtain them. Now, all we need to do is
implement our scheduler. As mentioned, the scheduler simply maintains a list of
tasks to be done. Therefore, it's a task list
that we'll be manipulating. The
action of Spawn
will enlarge this list, while Await
will observe the state
of our promise, which will change as soon as its associated task is completed.
Waiting can result in two different situations:
- the case where, indeed, the promise has been resolved. In this case, we simply transmit its result.
- the case where it is not yet resolved. In this particular situation, we give the opportunity for other tasks to execute (which can help in resolving our initial task). This is referred to as yielding.
let perform
: type c. task list ref -> c Effect.t -> [ `Continue of c | `Yield ]
= fun todo -> function
| Spawn fn ->
let value = ref None in
let task = Initial (fun () -> value := Some (fn ())) in
todo := !todo @ [ Task task ] ;
`Continue value
| Await value ->
begin match !value with
| Some value -> `Continue value
| None -> `Yield end
| _ -> invalid_arg "Invalid effect"
Finally, it's just a matter of iterating over this list to gradually complete all our tasks. This iteration involves observing the state of each of our tasks.
- For the initial state, we simply launch the task and see what we obtain through the handler we defined earlier (i.e., whether the task is resolved or suspended).
- For the resolved state, there's nothing to do; our task has finished.
- Lastly, for the suspended state, we need to determine what operation our
effect produces (using
perform
). The case of yielding is interesting because it involves keeping our suspension in our to-do list and attempting to execute our other tasks first.
let step todo = function
| Initial fn ->
Effect.Shallow.(continue_with (fiber fn) () handler)
| Resolved v -> Resolved v
| Suspended (k, effect) ->
match perform todo effect with
| `Continue v -> Effect.Shallow.(continue_with k v handler)
| `Yield -> Suspended (k, effect)
let run fn =
let result = ref None in
let rec go = function
| [] -> Option.get !result
| Task task :: rest ->
let todo = ref rest in
match step todo task with
| Resolved _ -> go !todo
| (Initial _ | Suspended _) as task -> go (!todo @ [ Task task ]) in
let task = Initial (fun () -> result := Some (fn ())) in
go [ Task task ]
Let's play!
Have you kept in mind our initial code and our primary goal? It was about putting a task in the background and executing it afterward. Returning to our basic problem, we wanted to manage our clients as background tasks while effectively handling the reception of new connections. Let's revisit the original code:
let fn () = print_endline "World"
let () =
let witness = do_this_in_background fn in
print_endline "Hello";
await witness
With our scheduler, this code would become:
let spawn fn = Effect.perform (Spawn fn)
let await prm = Effect.perform (Await prm)
let fn () = print_endline "World"
let () = run @@ fun () ->
let prm = spawn fn in
print_endline "Hello";
await prm
If we compile all of this and run the code, we get:
$ ocamlopt main.ml
$ ./a.out
Hello
World
Et voilà! Our task displaying "World" was successfully put into the background, and we indeed displayed "Hello" first. We now have the basics of our scheduler. You now understand the core concepts of all schedulers (whether in OCaml or JavaScript). Several (perhaps suboptimal) choices were made, but the most important thing is to grasp the concept of asynchronous programming through a concrete example.
Now, it's time to address our initial problem: managing our clients while also
accepting new connections. At this stage, you might think that simply "spawning"
our echo
function will make it work in the background. However, even though
we've addressed the issue of synchronicity by offering an asynchronous library,
we deliberately overlooked mentioning blocking functions! That's what we'll
explore in our next chapter.