OCaml Module 12 – Effects, concurrency and parallelism (OCaml 5)
Concurrent I/O with Eio and Lwt
Run concurrent I/O in OCaml 5 with Eio_main.run, switches and fibers; bound a request with a timeout, cap buffered input, and read the Lwt promise style.
What you will learn
- Run Eio programs with Eio_main.run, Eio.Switch.run and Eio.Fiber.both
- Compare serial waits and Fiber.both using a mock clock
- Limit an operation with Eio.Time.with_timeout and a Buf_read line cap
- Read Lwt promise code with let*, Lwt.both and Lwt_main.run
Before you start
What you’ll learn · Before you start
What you will learn
- Run Eio programs with Eio_main.run, Eio.Switch.run and Eio.Fiber.both
- Compare serial waits and Fiber.both using a mock clock
- Limit an operation with Eio.Time.with_timeout and a Buf_read line cap
- Read Lwt promise code with let*, Lwt.both and Lwt_main.run
Before you start
On this page
A program that waits on two slow operations one after the other takes the sum of the two waits; a program that
waits on them together takes the longer one. On a mock clock, the recorded run below took 3.5 seconds for two
sleeps one after the other and 2.0 seconds for the same two sleeps under Fiber.both.
structured concurrency: A way of organising concurrent fibers so that a parent scope owns every child it starts: the scope waits for every child to finish or be cancelled before it returns. In Eio, Eio.Switch.run opens such a scope. is how Eio, the direct-style I/O library for OCaml 5, lets a program own that overlap without threads or callbacks: every fiber: A lightweight thread of execution inside one domain: only one fiber of a domain runs at a time, until it waits for input, output or a timer, and then the next ready fiber runs. runs inside a scope that waits for it. You will run two waits together, attach fibers to a switch that waits for them, and read the results in direct style. A budget with cancellation: Stopping a fiber or a promise before it finishes, for example because a sibling failed or a time budget ran out; in Eio, Fiber.both and a switch cancel their other fibers when one of them raises. and a cap on buffered input build on that, and Lwt’s monadic concurrency: A style in which a function that waits returns a wrapped value such as 'a Lwt.t, and callers chain further work with bind (let*). Every waiting call shows in the types, and every caller of one returns a promise too. is set beside it.
Fibers wait together, and a switch waits for its fibers
Concurrent I/O with Eio starts at Eio_main.run, which picks an event loop: The scheduler at the heart of a concurrent-I/O library: it keeps the fibers or promises that are ready, runs one until it has to wait, and resumes it when the operating system reports that its input, output or timer is ready. Eio and Lwt each have one. back end
for the platform (eio_posix was installed with it on macOS at version 1.6) and hands your function one env with a
clock, a filesystem, a network and so on.
A fiber is a light thread of execution that Eio switches away from whenever it waits. Eio.Fiber.both f g runs
f and g as two fibers and returns when both have finished; if one raises, it cancels the other.
Eio.Fiber.pair has the same shape and returns the two results as a pair.
The session below runs two “slow” operations one after the other, then with Fiber.both. It uses
Eio_mock.Backend.run_full in place of Eio_main.run: its clock moves only when every fiber is asleep, so the
printed times are the same on every run.
(* Two "slow" operations on a mock clock: one after the other, then with
Eio.Fiber.both, then Eio.Fiber.pair, then two fibers forked onto a switch.
Eio_mock.Backend.run_full stands in for Eio_main.run: it hands over an env
whose clock advances only when every fiber is asleep, so the printed times
are the same on every run. *)
let fetch label clock delay =
let t0 = Eio.Time.now clock in
Eio.Time.sleep clock delay;
let t1 = Eio.Time.now clock in
Printf.printf "%s: finished at t=%.1fs (waited %.1fs)\n%!" label t1 (t1 -. t0)
let one_after_the_other clock =
let t0 = Eio.Time.now clock in
fetch "serial a" clock 1.5;
fetch "serial b" clock 2.0;
let t1 = Eio.Time.now clock in
Printf.printf "serial total: %.1fs\n%!" (t1 -. t0)
(* Fiber.both needs no switch: it returns when both fibers have finished. *)
let together clock =
let t0 = Eio.Time.now clock in
Eio.Fiber.both
(fun () -> fetch "concurrent a" clock 1.5)
(fun () -> fetch "concurrent b" clock 2.0);
let t1 = Eio.Time.now clock in
Printf.printf "concurrent total: %.1fs\n%!" (t1 -. t0)
let pair_result clock =
let a, b =
Eio.Fiber.pair
(fun () -> Eio.Time.sleep clock 0.5; "answer A")
(fun () -> Eio.Time.sleep clock 0.7; 42)
in
Printf.printf "pair: %s and %d\n%!" a b
(* Fiber.fork returns at once; the switch the fibers are attached to waits. *)
let forked clock =
let t0 = Eio.Time.now clock in
Eio.Switch.run (fun sw ->
Eio.Fiber.fork ~sw (fun () -> fetch "forked a" clock 1.5);
Eio.Fiber.fork ~sw (fun () -> fetch "forked b" clock 2.0);
print_endline "both forked; Switch.run now waits for them");
let t1 = Eio.Time.now clock in
Printf.printf "switch total: %.1fs\n%!" (t1 -. t0)
let () =
Eio_mock.Backend.run_full @@ fun env ->
let clock = env#clock in
one_after_the_other clock;
print_endline "---";
together clock;
print_endline "---";
pair_result clock;
print_endline "---";
forked clock """Two slow operations on a mock clock: serial waits add, Fiber.both and
Fiber.pair overlap them, and two fibers forked onto one switch keep
Switch.run waiting until both have finished. The mock clock advances only
when every fiber is asleep, so the printed times are the same on every run.
"""
from session import files, run, write
files("fibers.ml")
write("dune-project", "(lang dune 3.0)\n")
write("dune", "(executable (name fibers) (libraries eio.mock))\n")
run("""
dune exec ./fibers.exe
""") Output
$ dune exec ./fibers.exe +mock time is now 1.5 serial a: finished at t=1.5s (waited 1.5s) +mock time is now 3.5 serial b: finished at t=3.5s (waited 2.0s) serial total: 3.5s --- +mock time is now 5 concurrent a: finished at t=5.0s (waited 1.5s) +mock time is now 5.5 concurrent b: finished at t=5.5s (waited 2.0s) concurrent total: 2.0s --- +mock time is now 6 +mock time is now 6.2 pair: answer A and 42 --- both forked; Switch.run now waits for them +mock time is now 7.7 forked a: finished at t=7.7s (waited 1.5s) +mock time is now 8.2 forked b: finished at t=8.2s (waited 2.0s) switch total: 2.0s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fibers_session.py
Serial fetches of 1.5 s and 2.0 s finished at t=1.5 s and t=3.5 s, a total of 3.5 s. Under Fiber.both both
fibers went to sleep at t=3.5 s; the backend printed +mock time is now 5 when the first wait ended and now 5.5
when the second did, a total of 2.0 s, the longer of the two waits. Step through that second run:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Fiber a starts and sleeps. Fiber.both runs fiber a at once. It reads the clock, which the serial run left at 3.5, and calls Eio.Time.sleep for 1.5 s, so it waits until 5.0.
- Fiber b starts and sleeps. Fiber b runs next and calls Eio.Time.sleep for 2.0 s, so it waits until 5.5. Both fibers are asleep at the same time.
- The clock moves to 5. No fiber is ready, so the mock backend moves the clock to the first due timer and prints +mock time is now 5. Fiber a wakes, prints that it finished at t=5.0s after waiting 1.5s, and returns.
- The clock moves to 5.5. Only fiber b is left. The backend prints now 5.5, and b prints that it finished at t=5.5s after waiting 2.0s.
- Fiber.both returns. Both fibers have finished, so Fiber.both returns and the program prints concurrent total: 2.0s, against serial total: 3.5s for the same two waits one after the other.
Fiber.pair paired a 0.5 s fiber returning "answer A" with a 0.7 s fiber returning 42 and returned both. The
last run opened Eio.Switch.run and attached two fibers to its switch with Eio.Fiber.fork ~sw. fork returns at
once, so both forked; Switch.run now waits for them printed first, and Switch.run returned only when both
fibers had finished, again after 2.0 s. A switch waits for every fiber attached to it, releases the resources
attached to it, and cancels its other fibers when one of them raises.
Key idea
Fiber.both and Fiber.pair return when both of their fibers have finished, and Eio.Switch.run returns when
every fiber forked onto its switch has: the longer wait, not the sum, decides the time.
Why direct-style reads like ordinary code
Eio builds its scheduler on top of the OCaml 5 effect handlers of the two earlier lessons of this module. sleep,
accept, read and write perform effects that the backend catches, so Buf_read.line r is an ordinary call that
returns a string. The type of a function that waits is the type of its answer, as three waiting calls show:
| Waits for | Eio | Lwt |
|---|---|---|
| a line | Buf_read.line r : string |
Lwt_io.read_line ic : string Lwt.t |
| 0.05 s | Eio.Time.sleep clock 0.05 : unit |
Lwt_unix.sleep 0.05 : unit Lwt.t |
| two waits | Fiber.both f g : unit |
Lwt.both p q : ('a * 'b) Lwt.t |
Concurrent I/O with Eio therefore reads like the sequential code it replaces.
Three tools build on fibers. Eio.Time.with_timeout gives an operation a budget and cancels it when the budget
runs out, returning Error with a `Timeout tag. Buf_read.of_flow ~max_size:N caps the bytes a reader
buffers, so one client cannot decide how much memory the server uses. Lwt is the promise library a Dream server or
an existing cohttp-lwt client is written with, so reading its let* and Lwt_main.run code is part of working in
OCaml today.
Checkpoint
Check: Eio_main, Fiber.both and switches · Why Eio reads like ordinary direct-style code
Practise fibers, switches and direct style
Every question below is about the fibers above or the types of direct style: predict the wait or the result before you choose.
Practice that adapts to you
Practise this lesson
5 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Cancellation, timeouts and an input limit a service lives behind Pro: opens with a Pro, Premium or Ultimate pass
A loopback echo server with a 1,024-byte line cap is written in the same direct style as the mock-clock examples, because Eio hides the scheduler. A handler stops a bad request in three ways: a budget on the clock, a cap on the buffer, and the cancellation of the other fibers when one of them fails.
In this part:
- A budget on the clock with with-timeout
- Bounding input length is input validation
- A loopback echo server, bound to 127.0.0.1 only
- A slow reader slows the writer
1 diagram · 6 runnable examples (ML, Python) · about 10 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Reading Lwt: promises, let-bind and where you still meet it Pro: opens with a Pro, Premium or Ultimate pass
Many OCaml services written before OCaml 5 use Lwt, so a reader meets let*, Lwt.return and Lwt_main.run every
time a Dream handler or a cohttp-lwt client turns up in a codebase. The same logic as the Eio examples, written
in Lwt’s style, sets the two libraries side by side on facts the recorded runs show.
In this part:
- A promise is a value that will arrive
- Monadic concurrency colours every function that waits
- Failures in Lwt flow through the promise, not through try
- Promises you forget are still running
- Choosing between Eio and Lwt for a new or existing service
- Lwt_eio runs Lwt and Eio code in one domain
- Key takeaways from the two parts
2 runnable examples (ML, Python) · about 11 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
Warm-up for freshers and 2–5 years: what is an Eio fiber, and how does Eio.Fiber.both differ from spawning two
threads?
An Eio fiber is a lightweight unit of execution that Eio’s event loop schedules on top of OCaml 5 effect handlers:
calling Eio.Time.sleep, Buf_read.line or Eio.Net.connect performs an effect that suspends the fiber until the
backend has an answer, and other fibers run meanwhile. Eio.Fiber.both f g runs two fibers and returns when both
have returned, so the wall time is the slower of the two waits, not the sum, and if one raises it cancels the
other. Two systhreads of one domain also take turns, since only one of them at a time runs OCaml code, but each is
an operating-system thread with its own stack. Fibers are switched by Eio, inside one thread, at the points where
they wait.
Three harder questions follow: a service whose latency spikes come from one slow upstream, a line-based server that gets a request with no newline for a megabyte, and a team choosing between Eio and Lwt for a new HTTP service. Pick your first move before opening each:
-
Mid to senior
A service that calls one slow upstream has p99 latency 3 s when the upstream slows down, and p50 under 50 ms when it is healthy. The team writes the call under Eio. How would you keep p99 near the SLO without changing the upstream?
-
Mid to senior
A line-based Eio server reads one request a connection with Eio.Buf_read.line, no cap. A client opens a connection and sends 1 MB of bytes with no newline. The process memory climbs. What would you change, and why?
-
Senior to staff
A team is writing a new HTTP service in OCaml. One engineer wants Eio for direct style; another wants Lwt, because the shared logging library and the client for the next service are both cohttp-lwt. What would you propose?
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How long do two slow waits take one after the other, and together?
One after the other, the sum of the two; together, the longer of the two.
-
What do Eio.Fiber.both and Fiber.pair do?
They run two fibers and return when both have finished, so the wall time is the slower wait, not the sum; if one raises, the other is cancelled.
-
What does Eio.Switch.run guarantee about the fibers forked onto its switch?
It returns only when every fiber forked with Fiber.fork ~sw has finished, releases what is attached to the switch, and cancels the other fibers when one of them raises.
-
In direct style, what does a function that waits return, compared with the Lwt version?
Its answer's own type, string for Buf_read.line, where the Lwt version returns string Lwt.t.
-
How do Eio fibers differ from two systhreads of one domain?
Fibers are switched by Eio, inside one thread, at the points where they wait; each systhread is an operating-system thread with its own stack.
Key takeaways
Eio_main.runhands a program itsenv;Eio.Fiber.bothandFiber.pairrun two fibers and return when both have finished, so the wall time is the slower wait, not the sum.Eio.Switch.runwaits for every fiber forked onto its switch withFiber.fork ~sw, releases what is attached to it, and cancels its other fibers when one of them raises.- In direct style a function that waits returns its answer’s own type,
stringforBuf_read.line, where the Lwt version returnsstring Lwt.t. Eio.Time.with_timeoutgives an operation a budget,Buf_read.of_flow ~max_size:Ncaps buffered input, and Lwt’slet*style is what a Dream server or acohttp-lwtclient is written in.
Terms in this lesson
- cancellation
- Stopping a fiber or a promise before it finishes, for example because a sibling failed or a time budget ran out; in Eio, Fiber.both and a switch cancel their other fibers when one of them raises.
- event loop
- The scheduler at the heart of a concurrent-I/O library: it keeps the fibers or promises that are ready, runs one until it has to wait, and resumes it when the operating system reports that its input, output or timer is ready. Eio and Lwt each have one.
- fiber
- A lightweight thread of execution inside one domain: only one fiber of a domain runs at a time, until it waits for input, output or a timer, and then the next ready fiber runs.
- input limit
- A cap on how many bytes a reader will buffer from untrusted input before raising an error, chosen by the service designer and matched to the longest legitimate value; bounding input length is part of input validation.
- monadic concurrency
- A style in which a function that waits returns a wrapped value such as 'a Lwt.t, and callers chain further work with bind (let*). Every waiting call shows in the types, and every caller of one returns a promise too.
- structured concurrency
- A way of organising concurrent fibers so that a parent scope owns every child it starts: the scope waits for every child to finish or be cancelled before it returns. In Eio, Eio.Switch.run opens such a scope.
References
- Eio 1.6 API documentation (ocaml.org) (Inria and the OCaml developers)
- ocaml-multicore/eio on GitHub (The Eio contributors)
- Lwt documentation on ocaml.org (The Lwt maintainers)
- ocsigen/lwt on GitHub (The Lwt contributors)
- Is OCaml web yet? (OCaml.org)
- OWASP Input Validation Cheat Sheet (OWASP Foundation)
- ocaml-multicore/lwt_eio on GitHub (The Eio contributors)
- RFC 9112: HTTP/1.1, section 9.6 Tear-down (IETF)
- The OCaml Manual: Expressions (Inria and the OCaml developers)
Report a problem with this lesson
Kept only in this browser. Your Learn progress