m0serve Realtime from a synchronous Python app

Loops on threads — 2026-09-18

The Mojo host served one loop per process and refused M0_THREADS with 78, because that variable was m0serve's and a host that ignored it would serve a configuration other than the one written down. The reasons m0serve defaults to prefork are Python's: a GIL, an interpreter that must be forked before its first call, libpython on the link line. A Mojo application has none of them. So the question for this round was whether a Mojo app's way to N cores should be N forked workers or N loops on N threads of one process — to be decided by a number, not by which reasons sounded better.

The answer: they measure the same, so the default stays prefork and M0_THREADS is served as an option (DECISIONS D35). What follows is what was built, the three design questions the round had to answer, the measurement, and what is gated.

What was built

m0_host.serve[H, P] under M0_THREADS=N runs _serve_threaded: the prefork order with "fork" struck out. It is in m0_host/host.mojo, not in the fork and not in src/ — it calls run_event_loop, and nothing in src/ may reach the event loop (D33). It takes the SHAPE of m0-wsgi/src/threaded.mojo and none of its body, which is interpreter discipline from top to bottom.

HostContext.worker and workers count loops, and a new threaded field says which kind of sibling they are. That is the whole change an application sees: apps/host_check runs under both modes without a line that asks.

Question 1: what is per thread, and what happens once

step (prefork order) under threads
refuse once, before the bind; two new refusals below
listen once; each loop gets a dup of the listener, so a loop closing its listener at shutdown is a per-thread close
shared page, app page once, sized by the loop count. Still made by prefork_page, so still file-backed and exported: a child process an application starts publishes through m0pub the same way
the bus once, one channel per LOOP. Fan-out across loops rides it exactly as fan-out across workers does. The host passes it as bus_read_fd, as under prefork — m0serve needs peer_bus_fd only because its chunk channel takes the first, and the host has no chunk channel
accept-share channels once; each loop binds a COPY to its own index, AcceptShare carrying per-worker counters
fork
signals once, BEFORE the threads exist (under prefork: after the fork). One process has one disposition
H.make, the pool lane, the loop, the pool's join per thread (_loop_run)
the producer one thread beside the loops, built after every handler, before any loop serves
exit_worker()

Accept. The handoff called this "the biggest real win or the biggest trap", and it was neither: accept sharing runs across threads unchanged. An SCM_RIGHTS hand-off to a sibling thread is the kernel doing a dup into the same descriptor table; the sender closes its number and the receiver admits the new one. What had to be measured was whether it was needed. Thirty-two keep-alive connections against four loops on macOS:

M0_ACCEPT_SHARE connections per loop
0 (the bare race) 1, 0, 30, 1
unset 8, 8, 8, 8

So N threads on one listener are the same herd as N processes (E16 found 32 of 32), and a throughput row taken without sharing would have been one loop's. Replacing the channel with a plain integer over an in-process queue is possible and was not done: the socketpair is what the gates cover, and at one hand-off per CONNECTION it is not on any request's path.

Signals. m0serve's threaded mode had already solved it and the host does the same: the handler writes its byte to the one pipe; the spawning thread is blocked reading it; it then pokes one pipe per loop (ShutdownFanout), because a loop never drains its shutdown pipe and N loops cannot share one. The coordinator's join is bounded (JOIN_TIMEOUT_NS + the pool join's floor + 1 s): a loop that never sees the stop is named and the exit is 1, where join_all would have made SIGTERM a no-op until the SIGKILL.

The producer, and what replaces the supervisor. In one process the producer is simply one thread; there is no tick-owner question because there is one process to own it. Publisher.next_id still numbers from the shared word. The respawn case that motivated E25 — ids restarting at 1 under a stream that has seen 41 — cannot happen, because nothing is respawned. Nothing replaces the supervisor: a loop that dies takes the process (exit 1, named). The alternative, serving on the loops that are left, is N−1 loops behind a listener N were promised, with every stream the dead loop held open and silent. Whatever restarts the process — a container runtime, systemd — plays the supervisor's part, and that is the real difference between the modes: under prefork a crash costs one worker's connections, here it costs all of them.

D30's threaded reading: a make that raises on any loop is the same 78, and no loop takes a connection until every loop has its handler. Each thread builds its handler and its pool, reports ready, and waits at a barrier the spawning thread opens once all have reported. Without it, loop 0 was serving for the 300 ms it took loop 1 to refuse — a server that ran a configuration it then said it would not run.

M0_BLOCKING_THREADS × M0_THREADS is T loops of N, m0serve's rule: one pool per loop, because a job names a slot and a slot indexes one loop's ProvisionPool. That is T×(N+1) handlers. The pool code is shared between the two modes (_start_pool, _join_pool) rather than written twice.

State that lives in one THREAD. fragment_notes answers max_workers() -> 1 because its notes are a list in a struct. Under threads that is N lists in one process — every loop calls make — and the app would have served them without a word, each request seeing whichever list its connection's loop held. So AppHandler and ViewState gained max_threads(), defaulting to max_workers(): the application that refused workers refuses loops without being asked, and the notes app's source did not change. An application whose state really is shared across its loops (ctx.page, which under threads is plain memory; a database) overrides it. M0_WORKERS>1 with M0_THREADS>1 is refused (threads_conflict, the sentence m0serve uses), as is M0_THREADS=0.

Question 2: does global_slot.mojo survive?

Yes. Its three slots are the shutdown pipe's write end, the supervisor's child pids and the supervisor's stopping flag. The first is written once, on the spawning thread, before any loop exists, and read only by the signal handler; the other two belong to a supervisor a threaded host never makes. Nothing per-worker is stashed there, so nothing became a race.

Question 3: exit_worker()

Not called. It exists because a forked child cannot run the runtime's teardown; nothing here was forked, and main returns. Measured: exit 0, 70 ms after SIGTERM. The two _exit paths that remain are the ones the prefork host has — a pool thread or a producer step still inside the application past its bound — plus the straggler loop above.

The measurement

uv run poe bench-host-modes (scripts/bench_host_modes.py): one binary, apps/ramp under the host, three arms per round each on a fresh server — one worker (the comparator), N workers, N threads — against /x/now, which is answered on the loop and so measures the LOOP, and /x/search?sel=25, the table's compute view. Keep-alive, the keep-alive cap off, three rounds, eight seconds each. Cores and RSS are summed over the server's process tree. The ratios below are threads over workers, the median across rounds of the ratio within each round.

macOS, Apple M4, N = 4 (host-modes-20260918T201017Z.json, rendered in SERVER_PERFORMANCE.md):

route, connections throughput per core p99 RSS
now, 16 0.99x 0.95x 1.02x 0.78x
now, 256 1.00x 0.96x 1.00x 0.79x
search, 16 0.99x 0.99x 1.02x 0.78x
search, 256 0.99x 0.99x 0.95x 0.80x

Linux 6.8 aarch64 in the colima VM, 4 CPUs shared with the client, N = 2 (host-modes-linux-20260918T201830Z.json, filed under the subdirectory because a container copy of the tree cannot say whether it was clean):

route, connections throughput per core p99 RSS
now, 16 0.95x 0.95x 1.61x 0.63x
now, 256 1.00x 0.89x 1.02x 0.68x
search, 16 1.03x 1.03x 0.98x 0.64x
search, 256 0.99x 0.99x 1.11x 0.68x

How to read them:

  • Throughput is the same, on both backends, on the loop route and the compute route, at both connection counts: between 0.95x and 1.03x, inside what one arm moves between its own rounds.
  • The tail is the same. The one ratio that stands out, 1.61x at sixteen connections on Linux, is a median of 121 µs against 48, 43 against 44 and 98 against 61 — tens of microseconds, in rounds that do not agree with each other.
  • RSS is the difference: a fifth less on macOS at four loops, a third less on Linux at two. One runtime and one allocator instead of N. The ramp's corpus is built per handler in both modes, so this is the floor of the saving, not an application's.
  • On macOS the now rows are the CLIENT's: one worker serves as many requests as four (212k against 206k), wrk being out of cores before the server is, and the server's cores column reads 2.8 of 4. What those rows compare is cost per request, where threads spend 4–5 % more CPU for the same work. The search rows and both Linux routes are the server's.
  • Linux round 3's now rows fell in BOTH arms (546k to 439k for workers, 557k to 347k for threads at 256 connections) while the one-worker comparator held: a four-CPU VM running two loops and two client threads has no core to spare for anything else the host machine does. The medians are rounds 1 and 2's figures; the round is in the artifact.
  • Not measured: Linux x86, which the plan asked for. There is none here — the local VM is aarch64, and an emulated amd64 m0serve dies of SIGFPE under QEMU. If the two architectures disagree, that is a finding for the first x86 host this runs on; nothing above predicts it.

The decision

Threads buy no throughput and no tail, and they cost crash isolation: a worker that dies is replaced, a loop that dies ends the process. They buy memory, one address space, and the absence of fork — which is what matters to an application that must use a runtime that is off limits after a fork without exec (Core ML, anything reaching CoreFoundation; coreml-embeddings), that wants its loops to share state through plain memory, or that is sized by RSS. So prefork stays what the documentation reaches for first and M0_THREADS is served, whole, for the application that has one of those reasons. Neither is a default in the sense of happening unasked: the host's default is one loop.

What is gated

smoke-host-threads, a test.yml step of its own, on every pull request. CI pins a GIL-enabled CPython, so m0serve's threaded phases skip there and its step proves the refusal alone; a Mojo host has no interpreter, so this is the first threaded SERVING the repository gates on every pull request.

  • E27, fan-out across loops. Four streams that must span both loops (x-worker; a run that cannot spread them fails as vacuous — the sim_loop lesson) each carry every beat, from one pid. And T loops of N.
  • E28, the drain. One connection per loop, proven to sit on different loops, a 1.5 s request written on each, SIGTERM while they spin: both answered whole, exit 0.
  • E29, isolation. Five /instance requests per connection: one pid, and each loop's handler has counted exactly its own requests. The three refusals, and the barrier: a knob raises on loop 1 alone, 300 ms late, and the log must hold no Event loop started. smoke-fragment-notes refuses M0_THREADS=2 for the app that said only max_workers.

Each has its negative arm in poe sabotage-host --only threads, seven rules reverted one at a time, all caught on the first run: only loop 0 draining its bus channel (a stream on loop 1 carries no beats), the stop reaching loop 0 alone (exit 1), every handler built as loop 0 (the spread is vacuous), the barrier removed, the loop limit removed, the default not the worker limit, and the conflict served. An eighth, ViewsApp dropping its state's loop limit, is caught by the notes smoke.

Not claimed: that a loop which RAISES takes the process. The line is there and it is three lines long, but nothing on the wire makes run_event_loop raise on demand, and a gate that cannot fail is not evidence.