Several years in the making, OCaml 5 introduces runtime support for shared memory parallelism and effect handlers, which are the basis for exception handling, concurrency, async I/O, and more.
Algebraic effect handlers are a first-class abstraction aimed to represent and manipulate control flow in a program. In its most immediate form, effect handlers provide a restartable exception mechanism that can be used to recover from errors. Thanks to their flexibility, they also provide the basis for other abstractions such as generators, async I/O, concurrency, and more.
Similar to exceptions, effects are (typed) values that you instantiate through their constructors. Unlike exceptions, they can be performed and return a value. This is how you would declare a Conversion_failure
algebraic effect that takes a string as a parameter and returns an integer upon execution:
type _ Effect.t += Conversion_failure : string -> int Effect.t
Effects are associated with effect handlers, which are records with three fields: a retc
function that takes the result of a completed computation; an exnc
function that is called when an exception is thrown; and an effc
generic function that handles the effect. For example, for the previously declared Conversion_failure
effect, we could write the following handler that intercepts the exception, prints some debugging information, then let the program continue (alternatively, it could discontinue it):
match_with sum_up r
{ effc = (fun (type c) (eff: c Effect.t) ->
match eff with
| Conversion_failure s -> Some (fun (k: (c,_) continuation) ->
Printf.fprintf stderr "Conversion failure \"%s\"\n%!" s;
continue k 0)
| _ -> None
)
}
As mentioned, handling exceptions is only one possible use of effects, but since effects can be stored as values in a program to be used at some later moment, they also enable the implementation of generators, async/await, coroutines, and so on. For example, this is a simplified view of how effects can be used to create coroutines:
type _ Effect.t += Async : (unit -> 'a) -> unit Effect.t
| Yield : unit Effect.t
let rec run : 'a. (unit -> 'a) -> unit =
fun main ->
match_with main ()
{ retc = (fun _ -> dequeue ());
exnc = (fun e -> raise e);
effc = (fun (type b) (eff: b Effect.t) ->
match eff with
| Async f -> Some (fun (k: (b, _) continuation) ->
enqueue (continue k);
run f
)
| Yield -> Some (fun k ->
enqueue (continue k);
dequeue ()
)
| _ -> None
)}
When a task is completed, retc
is executed, which dequeues it and thus leaves the next task ready for execution. If an Async f
effect is returned, the task is enqueued and f
is performed. Finally, for a Yield
effect, the next task is enqueued and another is dequeued from the scheduler.
While algebraic effects are useful for concurrency, domains
are at the heart of Multicore OCaml, an OCaml extension with native support for parallelism across multiple cores using shared-memory. A domain is in fact the basic unit of parallelism, providing two fundamental primitives, spawn
and join
:
let square n = n * n
let x = 5
let y = 10
let _ =
let d = Domain.spawn (fun _ -> square x) in
let sy = square y in
let sx = Domain.join d in
Printf.printf "x = %d, y = %d\n" sx sy
Multicore OCaml builds more advanced abstractions on top of spawn
and join
, such as tasks and channels, implemented through the Domainslib library.
Tasks are essentially a way to parallelize work by only spawning one single domain. Since spawning and joining a domain is an expensive operation, using tasks is a more effective way to scale computation across multiple cores, where you can execute multiple tasks in parallel with async
and then wait for their completion using await
.
Channels, on the other hand, are a mechanism to allow domains to communicate with one another, as their name implies. Channels are blocking, meaning that if a domain attempts to receive a message from a channel but the message is not ready, the domain may block. Likewise, a send operation may also cause a domain to block until another domain will receive that message if the channel has reached the maximum number of messages it can hold.
Currently, one limitation of the OCaml 5 compiler is it only supports x86-64 and arm64 architectures on Linux, BSD, macOS, and Windows/migw64. The OCaml team is working to restore support for the rest of traditionally supported architectures during 2023.
Support for concurrency and parallelism are not the only new features in OCaml 5, which also includes improvements to the runtime system, the standard library, and several optimizations. Do not miss the official release notes for the full details.