par_map: Humanizing the Common Case

spawn and a channel are the assembly language of parallelism — correct, but not what most code should have to write. A parallel map is the shape people actually reach for, and Mere gets it on all four backends without writing it four times: par_map desugars into spawn, channel, and an ordinary map. It inherits every guarantee from the primitives it lowers to — safety, byte-identical parity — for free, and one subtlety from an earlier decision forces the desugaring to happen per call site.

mereconcurrencypar_mapdesugaringlanguage-design

spawn and channel now run safely on every backend, but they are the raw primitives — the assembly language of parallelism. Writing a parallel computation with them means manually fanning work out to spawned threads and fanning the results back through channels, by hand, every time. That is fine for a primitive and hostile as an everyday tool. This short episode is about the humane form most code actually wants — a parallel map — and about getting it on all four backends without writing it four times.

The shape people reach for

The everyday parallel operation is a map: apply a function to every element of a list, but do the elements at the same time. par_map gives exactly that — take a function and a list, and get back the list of results, computed in parallel:

par_map heavy_work items

One line replaces the hand-written fan-out and fan-in. A data-parallel program that had spelled out “spawn a worker per item, collect through channels in order” becomes a single call. That is the whole point of a combinator: the primitive gives you control, the combinator gives you the common case without ceremony.

Built by desugaring, not by four implementations

The tempting way to add par_map would be to implement it in each backend’s runtime — four more pieces of concurrency code to write and keep in agreement. Mere did the opposite. par_map is realized by desugaring: a saturated call is rewritten, as the program is parsed, into a combination of things that already exist. Roughly, each element gets a worker that sends its result down a fresh channel, the channels are collected into a list in order, and then each channel is received from in that same order — a fan-out map followed by a fan-in map, expressed with spawn, channel, and the ordinary list map.

Because spawn, channel, and map already run on all four backends, this single rewrite makes par_map run on all four backends for free. No new code generation, no fourfold implementation, no new opportunity for the backends to disagree. This is the general power of desugaring: express the new construct in terms of ones that already work, and it inherits their working-ness whole. The ordering guarantee comes from collecting channels into a list; the safety comes from the primitives; the byte-identical parity comes from the primitives too. par_map did not have to earn any of those — it was handed them by what it lowers into.

Why the desugaring happens per call site

There is a subtlety here, and it is a direct consequence of a decision made two episodes ago. Why not write par_map once, as an ordinary polymorphic function in the standard library, in Mere itself? Because the Send bound would run straight into the monomorphism restriction: a definition whose type variable still carries an unmet Send obligation is not generalized, so a par_map written as a plain library function would be pinned to a single element type per program — you could map over a list of one type, but not, elsewhere in the same program, over a list of another. That is far too restrictive for the one operation everyone will reach for.

Desugaring sidesteps it exactly. Because each par_map call is lowered at its own call site, with that site’s concrete types, every use gets its own specialized expansion — and the operation is genuinely polymorphic across the program, one type here and another type there, with no monomorphism restriction biting, because there is no single generalized definition to restrict. The earlier choice — keep the type system solver-less by refusing to generalize Send-constrained variables — had a cost, and this is where the cost is paid and worked around: the polymorphic combinator is delivered by lowering rather than by a library function. The decisions compose; the shape of one answer is set by an answer three episodes back.

What comes along for free

Everything the primitives guarantee, the desugared par_map guarantees without extra work. The Send requirement is still enforced — the results travel through a channel, whose element type must be sendable, and a par_map whose function returns a non-sendable value is rejected, exactly as the raw channel would reject it. Move tracking needs no change either: the function passed to par_map is an ordinary, possibly-many-times closure, so trying to move an owned capability into it is already forbidden by the multi-run rule, which is exactly right because the function runs once per element. And the four backends still produce the identical result, verified on a real parallel program, because they are all running the same lowered primitives.

The method it establishes outlasts this one combinator: a high-level parallel operation can be delivered by desugaring it into spawn and channel, inheriting safety and parity from below, and future combinators can follow the same road rather than being built four times each.

par_map is the humane surface over safe primitives — and its existence raises the question the next episode is really about. The concurrency roadmap had more on it: atomics, mutexes, the lower-level shared-state machinery a “complete” concurrency story is supposed to include. Do they need to be built? The answer came not from a checklist but from demand — from asking which programs actually require them. Next, the close of Part VII: the decision not to build something.

← Back to Mere: Building a Language