We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← True Parallelism and the Runtime step 7 of 18
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=8with 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.queryproducesf"{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…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.