Dive C4.09.3
The single writer
Many callers, one mailbox. That is the whole reason the queue is safe under concurrency.
This is the chapter's payoff. A queue is hammered by many callers at once — each minting a job or
claiming the next one. If they all wrote to the registry directly, two could interleave: both could read the same
oldest job and claim it, or two mints could collide on a row. The MailboxProcessor
removes the possibility. Every mint and every claim is a message into one mailbox, handled by
one loop, one at a time. The transitions serialize: two callers cannot race a
claim or mint a colliding id. This dive states why, in the echo_fs broker's own words,
and shows the test that proves it.
The race a shared writer allows
Picture the registry as a mutable cell that any caller may read and write. Two workers each pull the next job. Worker A reads the oldest pending id; before it removes it, worker B reads the same oldest id. Each proceeds as the holder; each runs it. The job is claimed twice. The mirror failure is on the mint side: two callers compute a row for a new id and both write, and one write clobbers the other. Neither bug is in the logic of a claim or a mint — each is correct alone. The bug is that two of them interleaved.
Locks are the usual answer, but a lock is a thing to remember on every path, and a forgotten one is the same race again. The functional answer is to remove the shared write altogether: route every change through a single owner, so there is never more than one writer to interleave.
One mailbox imposes an order
A MailboxProcessor has exactly one inbox and one loop. Many callers may post at once,
and the runtime is free to interleave their posting; but the loop pulls messages one at a time, so the
transitions run strictly in sequence. Whatever order the mailbox accepts the messages in becomes a
total order, and the registry advances through it deterministically. A claim runs against the state the
previous claim left; a mint runs against a registry that already holds every prior row. There is no window in
which two transitions overlap, because the loop never starts the next message until the current one has
produced its new state.
The echo_fs broker states this in its own module comment, naming the same guarantee
the Elixir bus relies on:
// echo_fs/EchoMQ/Broker.fs — the module's own words /// The single-writer broker: a `MailboxProcessor` that owns one queue's state /// and serializes every transition through its mailbox. This is F#'s actor — the /// GenServer analog — and it is what makes minting and claiming safe under /// concurrency: there is only ever one writer, so two callers cannot race a /// claim or mint a colliding id. The Elixir bus gets this from the single /// `Portal.Engine` writer; the F# port gets it from the mailbox.
GenServer process; F# gets them from one MailboxProcessor loop.
In both, the safety comes from there being a single owner of the write — not from a lock around a shared one.Four callers, two outcomes
Four callers each post a mint for a distinct branded id, all at once. Choose the model and step it. The shared writer interleaves two unguarded read-modify-write pairs, so a write is lost and the registry ends short. The single writer funnels all four through one mailbox, handles them in order, and keeps every id. The readout names what survived; the count at the end is the proof.
Concurrent mints · shared writer vs single writer
Same four mints, two endings. Direct writes can interleave and drop one; one mailbox imposes an order, so all four land. The safety is structural — it does not depend on remembering a lock.
The test that proves it
The guarantee is not a claim in a comment — it is checked. The echo_fs
BrokerTests suite posts two hundred distinct ids to one broker at the same time, with
Async.Parallel, and then asks the broker how many are pending. If any two had raced —
a lost write, a colliding id — the count would fall short. It is exactly two hundred.
// echo_fs/EchoMQ.Tests/BrokerTests.fs [<Fact>] let ``parallel posts are serialized by the mailbox — no lost or colliding ids`` () = let b = Broker.Broker() let ids = [ for n in 1L..200L -> mint n ] ids |> List.map (fun id -> b.EnqueueAsync(id, "p")) |> Async.Parallel |> Async.RunSynchronously |> ignore Assert.Equal<int>(200, b.PendingSize())
The same
shape, run in dotnet fsi against a reduced broker, gives the same result — all two
hundred survive the parallel posting:
let b = Broker() [ for n in 1..200 -> sprintf "JOB%011d" n ] |> List.map (fun id -> b.EnqueueAsync(id, "p")) |> Async.Parallel |> Async.RunSynchronously |> ignore b.PendingSize() // val it: int = 200
Elixir — one GenServer serializes the writes
# Elixir — 200 concurrent calls into one GenServer; none are lost 1..200 |> Task.async_stream(fn id -> GenServer.call(broker, {:enqueue, id}) end) |> Enum.to_list() # pending = 200 — the single Portal.Engine writer serialized them
F# — one MailboxProcessor does the same
// F# — 200 concurrent posts into one mailbox; none are lost // pending = 200 — the single MailboxProcessor loop serialized them
EchoMQ in F#
Status — echo_fs is an in-memory model. The EchoMQ protocol — the Lua transitions executing on a Valkey wire — is not implemented (there is no Lua and no Valkey client in the code); that is the coming-soon transport (EchoMQ.Connector). This block shows how the model uses the structure, not a running bus.
This is the chapter's running example at its conclusion: the registry of C4.09.1, owned by the broker of C4.09.2, made safe by the single-writer property — and the bus proves it holds.
runs today echo_fs/EchoMQ/Broker.fs · EchoMQ.Tests/BrokerTests.fs
EchoMQ.Broker is built and tested, and the single-writer guarantee is one of its
tests: "parallel posts are serialized by the mailbox — no lost or colliding ids". Because the broker is the
only writer over EchoMQ.Store.Rows, the bus needs no lock around the registry — minting
and claiming serialize through the mailbox by construction. This is the property that lets a queue stay correct
under load: the part the chapter was built to reach.
coming soon The serialization holds in memory today. When the Valkey transport
EchoMQ.Connector lands, the same single-writer discipline carries to the wire — the
production bus moves the atomicity into Valkey-side scripts, but the broker's contract, that one writer owns
the transitions, is unchanged.
The chapter, closed
Twelve modules built the registry from the ground up: lists and trees, sorting and maps, the HAMT and the
CHAMP, the branded id and its persistence. This module folded them into a working store and gave it a single
writer — and that writer, the MailboxProcessor, is what makes the queue safe without a
lock. The store can later become a branded CHAMP and gain a Valkey wire; the actor over it does not change.
That is the shape of a queue: a persistent registry, owned by one loop.
C4.09 · the parts
A branded-keyed persistent registry, an F# MailboxProcessor, and a single-writer discipline.
The result
A queue that stays correct under concurrent mints and claims — no lost work, no colliding ids, no lock.
References
Sources
- FSharp.Core API reference —
MailboxProcessor,Async.Parallel, andAsync.RunSynchronously. - Microsoft — F# documentation — the
MailboxProcessoragent and concurrency. - Microsoft — .NET documentation — the threading model and why shared mutable state races.
- The F# Software Foundation — F# on the .NET runtime.
Related in this course
- Elixir — F4.09 · Branded CHAMP maps & GenServer — the same store, serialized by a
GenServer. - C4.09.2 — The MailboxProcessor — the loop this dive proves safe.
- C4 — Algorithms & Data Structures — the chapter this module closes.