Skip to content

← True Parallelism and the Runtime step 18 of 18

Medium Framework

Worker Recycling: max_tasks_per_child Without the Silent Downgrade

A long-running batch worker that grows to 8 GB and gets OOM-killed mid-job is one of the most common multiprocessing incidents, and the fix is a single keyword argument:

ProcessPoolExecutor(max_workers=8, max_tasks_per_child=100)

The worker is retired and replaced after N tasks, so a leak in a C extension, a caching decorator nobody remembered, or a fragmented allocator gets reset on a schedule instead of accumulating until the kernel intervenes. Pool spells the same thing maxtasksperchild.

The detail that is easy to miss

The docs say the feature “is incompatible with the ‘fork’ start method”. What they do not say loudly is what happens when you do not pass mp_context:

With max_tasks_per_child and no mp_context, the executor silently uses spawn.

On 3.14 that downgrades your Linux default of forkserver (1.40 ms per process) to spawn (26.2 ms) — a 19x increase on an operation you have just told the pool to perform on a schedule. Nothing warns you. Your throughput drops and the cause is invisible in the code, because the cause is an argument you did not pass.

The arithmetic you should do once

recycle overhead per task = (process start + initializer) / N

Pick N so that stays under about 1% of task time. Worked examples:

Method Start cost N Task time Overhead
forkserver 1.40 ms 100 10 ms 0.14% — free
spawn 26.2 ms 50 5 ms 10.5% — you are paying for this
spawn 26.2 ms 1 1 ms 2620% — a pathology

The third row is not a straw man. max_tasks_per_child=1 looks like maximum safety and is how people “fix” a leak they have not diagnosed.

Your task

def solve(
    *,
    platform: str,
    tasks_per_child: int | None,
    mp_context: str | None,
    task_ms: float,
) -> tuple[StartMethod, tuple[str, ...], float]:

Return the start method that will actually be used, the ordered warning codes, and the recycling overhead as a percentage rounded to two decimals.

  • No recycling (tasks_per_child is None): use the caller’s context if they gave a valid one, otherwise the platform default — spawn on win32 and darwin, forkserver elsewhere. No warnings, 0.0 overhead.
  • Recycling with no context: warn "implicit-context-downgrades-to-spawn" and fix it — choose the cheapest recycling-compatible method the platform offers (forkserver on POSIX, spawn on Windows). Naming it explicitly is the entire value of the wrapper.
  • Recycling with mp_context="fork": warn "fork-incompatible-with-recycling" and resolve the same way.
  • Recycling with an unrecognised context: warn "unknown-context" and resolve the same way.
  • Whenever overhead exceeds BUDGET_PCT, append "recycle-overhead-exceeds-budget" last.

Note the darwin case: macOS defaults to spawn, but it does offer forkserver, and for a recycling pool that is a 19x difference. Defaulting is not the same as choosing.

The typing lesson

mp_context arrives as str | None — it came from config, an environment variable, a CLI flag. if mp_context in ("spawn", "forkserver") does not narrow a str to a Literal, so returning it will be an assignment error. Write a small _as_method(value: str | None) -> StartMethod | None that returns literals directly; mypy narrows on the return "spawn" and the rest of the function type-checks for free. That function is the parse-don’t-validate boundary between untrusted configuration and your own vocabulary.

Loading visualization…