Running Concurrency on Four Backends
The type system made concurrency safe on paper; now it has to run — spawn as real threads, channels as real shared memory, four times over, held to identical output. The same primitive is realized four different ways, each fitting its backend: host threads, pthreads with per-type channels, pthreads with an i64-slot channel, and Wasm workers over shared memory. The closure representation from the codegen part is what makes all four possible, and a pessimistic worry about Wasm dissolves.
The type system is done: concurrency is safe on paper. Now it has to run. spawn
has to become a real thread and a channel has to become real shared memory — and,
per the rule that has governed code generation from the start, it has to happen four
times, once per backend, with no two of them producing different output. The
striking thing is that the same primitive comes out four genuinely different ways,
each shaped by what its backend is, and that the closure representation built back in
the code-generation part is exactly what makes all four work.
Why a closure is what makes a thread
A thread, stripped down, is “run this closure somewhere else.” And a closure, in every one of Mere’s backends, was always represented the same way in spirit: an entry point plus its captured environment. In C it is a struct of a function pointer and an environment pointer; in Wasm it is a function-table index and a memory offset. Because that representation is uniform in shape across backends, handing a closure to a new thread is mechanically natural everywhere — you already have the two things a thread needs, the code to run and the data it closed over. The concurrency feature inherits the closure work from Part IV rather than reinventing it.
Four fittings of one primitive
The interpreter is the easiest and the fastest to pay off. A closure there is a
real host-language closure with a real environment, so spawn is just the host’s own
thread-spawn, and a channel is a queue with a lock and a condition variable. The host
runtime handles the shared environment and the threads. A few dozen lines, and the
original pain point — two forever-loops in one process — is solved. The why-OCaml
bet pays off one more time: the host’s concurrency becomes Mere’s.
The C backend is where concurrency stops being theoretical. spawn copies the
closure to the heap and starts a POSIX thread on a trampoline that calls it; a
channel is monomorphized per element type — one channel implementation per concrete
type, matching how the language already specializes its vectors — each a ring buffer
with a mutex and a condition variable. Here the environment is a shared heap pointer,
so a genuine data race is now possible, which means the Send and Sync checks
from three episodes ago are, for the first time, standing between the programmer and a
real bug. Run under a thread sanitizer, the channels show zero races — the type-system
work validated by a race detector finding nothing.
That backend surfaced a hazard worth naming. The default region — where closure environments are allocated — is a shared bump allocator, and bumping its pointer is not atomic, so two threads allocating at once would race. The fix was lighter than it first looked: that region is never freed, an immortal arena, so there is no lifetime problem to solve, only the bump race — and a single lock on the shared arena’s allocation closes it, while per-block regions, being thread-local stack values, stay lock-free. The design had once proposed giving each child thread its own region; root-causing showed that was unnecessary for correctness and kept it only as a possible future optimization.
The LLVM backend does the same threads as C but a different channel. Where C
monomorphizes one channel per type, LLVM uses a single generic i64-slot channel —
because its scalar values all fit in eight bytes, an element can be cast into and out
of an i64 slot, so one channel serves every type. That shortcut had a hole:
aggregates — tuples and records — do not fit in a register and can’t be widened into
one, and the compiler rejected the attempt. The fix boxes aggregates on the heap and
passes a pointer through the slot instead. A backend-specific optimization with a
backend-specific edge case, found and closed.
The Wasm backend was the one a design note had been pessimistic about: it worried
there was no way to run a Mere closure in a child at all. That worry dissolved. With
shared-memory threads, a worker re-instantiates the same module over one shared memory
and one shared function table, and the closure representation — a table index and a
memory offset — is valid in the worker unchanged, because both point into memory the
two instances share. spawn starts a worker through a host import; channels are a
small shared-memory region operated with the host’s atomic primitives, keeping the
delicate lock-and-wait logic out of hand-written Wasm. The old pessimism was simply
wrong, because the closure design from earlier had already made the child reachable.
The bug only an allocating worker produces
Wasm had one more subtle failure. A worker that merely reads shared data is fine, but a worker that allocates — even implicitly, as a curried call does when it builds an intermediate closure — bumps a per-instance allocation pointer, and two workers bumping their own pointers into the same shared memory collide and deadlock. The fix was not to make the bump atomic but to hand each worker a disjoint region of the shared memory to allocate in: the host sets each worker’s allocation pointer to a distinct, non-overlapping high offset after it starts. No change to the generated code, no atomics — just partitioned space. The program that previously deadlocked, four workers each doing curried work, then completed.
One behavior, four mechanisms
At the end, spawn runs on all four backends and channels run across them, and a
data-parallel program — fan work out to several workers, fan the partial results back
through channels — produces the same number on every one: interpreter, C, LLVM, and
Wasm. The mechanisms could hardly be more different — host threads, pthreads with
per-type channels, pthreads with a boxed i64 slot, workers over shared memory with
host-driven atomics and partitioned allocation — and that is exactly the point the
whole code-generation discipline was built to make. Parity lives in the observable
output, not in the implementation; each backend gets the realization that fits it, and
they are held to agreeing anyway.
Concurrency now exists, safely and everywhere. But spawn and channel are the raw
primitives — the assembly language of parallelism, not the way most code should want to
express it. The next step is to make the common case humane: to give a parallel map,
the shape people actually reach for, and to get it on all four backends without writing
it four times. Next: par_map.