Skip to content

← True Parallelism and the Runtime step 7 of 18

Hard End-to-End

Migrating a Fork-Dependent Codebase to Forkserver

The 3.14 default flipped from fork to forkserver on Linux, and a codebase that quietly depended on inheritance now has to say out loud what each worker needs. Doing that migration wrong produces one of two incidents, and neither shows up in a unit test that runs a single task:

  • N copies of a large model resident in memory, because a global that used to be inherited for free is now built once per worker. OOM kill.
  • Workers silently returning wrong results, because a module-level cache they assumed was warm is empty in the child.

The four breakage categories, and their fixes

1. Implicitly inherited globals. Under fork, _MODEL loaded at import time was simply there in the child. Under forkserver/spawn it is not. Fix: ProcessPoolExecutor(initializer=..., initargs=...), which runs once per worker process, not once per task.

2. Unpicklable arguments. Connections, sockets, sessions, CUDA contexts. Fix: pass a recipe, build inside the worker. Send the DSN, not the connection. Send the model path and dtype, not the model.

3. Module-level side effects. Under spawn and forkserver the child re-imports your __main__ module, so anything at top level runs again in every child — including a bare Pool() constructor, which recurses. Fix: the if __name__ == "__main__": guard, which is not a style convention but a correctness requirement.

4. Cost. forkserver is 1.40 ms per process against fork‘s 0.87 ms — a rounding error. But every child still imports your dependency tree. multiprocessing.set_forkserver_preload(["numpy", "myapp.models"]) makes the server process import them once so each fork inherits them. Two traps: it must be called before the forkserver launches, and it silently ignores ImportError — a typo in a module name costs you the entire benefit with no diagnostic.

(3.14 also hardened the forkserver: it now authenticates its control socket rather than relying on filesystem permissions alone.)

Your task

Refactor the shape rather than the syntax. You are given a Conn that owns a threading.Lock — a live resource that can never be pickled and never be inherited — and a Recipe that is a plain tuple of strings, which can.

def worker_init(recipe: Recipe) -> None:      # once per worker lifetime
def worker_handle(row: str) -> str:           # once per row
def solve(*, dsn: str, rows: list[str], workers: int) -> tuple[tuple[str, ...], int]:

solve distributes rows over workers workers — worker w takes rows[w::workers] — and returns the results in the original input order, plus the number of connections actually opened.

Two rules make the test meaningful:

  • A worker that receives no rows opens no connection. workers=8 with two rows must open two connections, not eight.
  • Each connection numbers the rows it serves from 1, so the output shows the connection boundaries. Conn.query produces f"{dsn}#{serial}:{row.upper()}".

That serial is the fixture: if you kept one shared connection, the serial runs 1..n and the test fails. If you rebuilt per worker, it restarts — which is what initializer actually does.

The typing lesson

ProcessPoolExecutor.__init__ is overloaded with a TypeVarTuple, so a mismatched initializer/initargs pair does not produce one clear message — it produces “no overload variant matches” followed by a list of the candidates. Learn to read that shape now, because it is how every variadic overload in typeshed reports a mistake.

The other half is the worker-side global:

_CONN: Conn | None = None

The | None is not decorative — before worker_init runs, there genuinely is no connection, and in a spawn child that window is real. Narrow it properly (bind to a local, check, then use) and raise if it is unset. A # type: ignore here is exactly the sticking plaster this problem exists to remove.

Loading visualization…