The Worker That Couldn't Stop

A worker pool — main enqueues jobs, workers pull and process, main collects — hit two walls in the same minute. A worker's receive loop blocks forever once the jobs run out, so there is no way to stop it and join it; and because the loop never returns, its type is bottom, which the C backend cannot emit at all. Both walls turned out to be the same missing primitive: a channel needs a way to say it is finished, and a receive needs a way to hear that.

mereconcurrencychannelsshutdownlanguage-design

The memory-model episodes ended with a list of things deliberately left undone, and the top of that list was structured concurrency: the language could spawn threads and pass messages, but nothing had ever forced the question of how concurrent programs end. This part is the arc that finally forced it — three small programs in a day, each aimed at one piece of the ending problem. The theme running through all of them: the hard part of a concurrent program is not making it go. It is making it stop.

Two walls, one minute apart

The forcing program was the most ordinary shape in concurrent programming: a worker pool. Main pushes N jobs into a channel, W workers each loop pulling a job and pushing a result, main collects N results. Writing it hit two walls almost immediately. The first is semantic: when the jobs run out, each worker is parked inside a blocking receive that will never produce another value — there is no way to tell a worker “no more work,” so there is no way to stop it, so there is no way to join it, so the program cannot cleanly end. The second wall looks unrelated and is the same wall wearing a compiler’s face: a worker loop that never returns has the bottom type — the type checker’s way of saying “this expression produces nothing” — and the C backend flatly refuses to emit a value of that type. The program didn’t just hang at runtime; it didn’t compile. One missing primitive, visible once as an operational problem and once as a type.

Closing a channel

The primitive is closure, in the queue sense: channel_close marks a channel as finished, and channel_recv_opt blocks for a value but returns None once the channel is both closed and drained. A worker’s loop becomes a match: Some job — process it and loop; None — return. The loop now terminates, which fixes everything at once: the worker returns unit (not bottom, so it compiles), the pool can be joined handle by handle, and the program ends because every thread in it ended. Sending on a closed channel raises — it is a programming error, not a condition to limp through — and the old blocking receive on a closed, drained channel raises too, instead of sleeping forever on a condition variable no one will ever signal. The scope is deliberate: the interpreter and the C backend, which is where every server-shaped dogfood actually runs; the browser-side backends reject the two primitives with a compile error that says so. The pool example runs four workers over twelve jobs, sums their results, and — the part that was impossible before — joins all four workers and exits. The next episode meets the worker this one can’t save: the one that is still running and just refuses to finish.

← Back to Mere: Building a Language