๐Ÿด Pattern 7: Start Methods

How a child process is created โ€” fork, spawn, or forkserver โ€” is the single biggest reason multiprocessing code "works on my Linux box, breaks on the Mac."

Three Ways to Make a Child

When you call Process.start() or open a pool, Python has to bring a new worker into existence. There are three mechanisms, and which one you get depends on your OS and Python version โ€” not on your code. They behave very differently, and code that quietly relies on one will explode on another.

Key Insight: fork gives the child your entire world for free; spawn gives it a blank interpreter and mails it only what you pickled. Almost every "it worked locally" bug is code that assumed fork running under spawn.

fork vs spawn, Side by Side

flowchart TB subgraph FORK["fork (Unix) โ€” copy the parent"] direction TB PF["Parent process
globals, FDs, imports"] --> CF["Child = COPY of parent
inherits everything
no re-import"] end subgraph SPAWN["spawn (macOS / Windows) โ€” fresh interpreter"] direction TB PS["Parent process"] --> NP["new python interpreter"] NP --> RI["re-import your module
(top-level code runs again)"] RI --> UP["unpickle passed args
inherits NOTHING else"] end

The Defaults Are Moving โ€” Don't Assume fork

For years, tutorials assumed fork because it was the Linux default. That assumption is being actively dismantled because fork is unsafe in modern, multi-threaded programs.

Platform / versionDefault start methodNotes
Linux, Python โ‰ค 3.13forkFast, but unsafe with threads (see below)
Linux, Python 3.14+forkserverFork-in-a-threaded-parent deprecated; safer default
macOS, Python โ‰ฅ 3.8spawnSwitched from fork โ€” fork was unsafe with system libs
Windows (all)spawnThe only option โ€” no fork on Windows
Takeaway: the default has already changed under you on macOS, and Python 3.14 changes it on Linux too. Never write code whose correctness depends on inheriting the parent's memory. Write for spawn and it works on all three.

Setting the Start Method Explicitly

Two ways. set_start_method mutates a global for the whole program and can only be called once. get_context is preferred โ€” it hands you a context object whose Process/Pool use that method without touching global state, so libraries don't fight each other.

import multiprocessing as mp

def worker(x):
    print(f"got {x}")

if __name__ == "__main__":
    # Option A: global switch โ€” call ONCE, under __main__, before starting anything.
    mp.set_start_method("spawn")
    p = mp.Process(target=worker, args=(1,))
    p.start(); p.join()
import multiprocessing as mp

def worker(x):
    print(f"got {x}")

if __name__ == "__main__":
    # Option B (preferred): a context object โ€” no global mutation.
    ctx = mp.get_context("spawn")
    p = ctx.Process(target=worker, args=(2,))   # ctx.Process, ctx.Pool, ctx.Queue ...
    p.start(); p.join()

    with ctx.Pool(processes=4) as pool:         # the pool also uses spawn
        pool.map(worker, range(4))
Rule: in a library, always use get_context โ€” never set_start_method. Mutating the global start method surprises whoever imports you.

Consequence 1: Why Every Example Has the __main__ Guard

With spawn, the child re-imports your module to rebuild the worker. If your launch code sits at module top level, the child re-runs it on import โ€” and spawns children that spawn children, forever.

Bug: under spawn, top-level launch code โ†’ infinite process explosion, or RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase. The if __name__ == "__main__": guard is exactly what stops the re-import from re-launching: in a spawned child, __name__ is the module's real name, not "__main__", so the guarded block is skipped.
import multiprocessing as mp

def worker():
    print("working")

# โŒ At top level: re-runs on every spawn re-import โ†’ explosion / RuntimeError.
# โœ… Guarded: only the ORIGINAL process has __name__ == "__main__".
if __name__ == "__main__":
    p = mp.Process(target=worker)
    p.start()
    p.join()

This is the reason every example in this whole guide wraps its launch in if __name__ == "__main__": โ€” it's a portability requirement, not a stylistic one.

Consequence 2: fork + Threads = Deadlock

The trap: fork copies memory as-is โ€” including any lock currently held by another thread. But it copies only the forking thread, not the thread that would have released that lock. The child starts life owning a locked lock that nothing will ever unlock โ†’ instant, silent deadlock the first time the child touches it.

This is subtle because you often don't hold the lock yourself โ€” a library does, in a background thread. It classically bites the logging module (which locks internally) and anything that runs threads under the hood. This exact hazard is why macOS abandoned fork and why Python 3.14 is retiring it as the Linux default in favor of forkserver.

import logging, multiprocessing as mp

def worker():
    logging.info("hi from child")   # under fork, can deadlock on an inherited locked handler

# Safe fix: pick a start method that gives the child a clean slate.
if __name__ == "__main__":
    ctx = mp.get_context("spawn")   # or "forkserver" on Unix
    p = ctx.Process(target=worker)
    p.start(); p.join()

Consequence 3: With spawn, Globals Are Not Inherited

Under fork a child can read a module-level global the parent built (a loaded model, a config dict) because it inherited the whole memory. Under spawn the child rebuilds the module by importing it โ€” so module-level state created inside the __main__ block, or mutated at runtime, is gone. Whatever a worker needs must either be passed in as an argument or reconstructed in an initializer.

import multiprocessing as mp

_MODEL = None   # rebuilt per worker under spawn โ€” NOT inherited from the parent

def init_worker(config):
    global _MODEL
    _MODEL = load_model(config)     # build the expensive thing ONCE per worker

def predict(item):
    return _MODEL.score(item)       # reuse the per-worker global

def load_model(config):
    return config                   # stand-in

if __name__ == "__main__":
    ctx = mp.get_context("spawn")
    with ctx.Pool(4, initializer=init_worker, initargs=({"weights": "..."},)) as pool:
        print(pool.map(predict, range(8)))
Key Insight: the initializer= pattern is the portable answer to "the worker needs something big that can't be inherited" โ€” see Pattern 6 for passing sync primitives this way, and Pattern 8 for what can and can't be pickled into a worker.

Key Takeaways

Questionforkspawn
Re-imports your module?โŒ Noโœ… Yes
Inherits globals / FDs?โœ… EverythingโŒ Nothing
Needs __main__ guard?Not strictlyโœ… Required
Args must be picklable?Not for inherited stateโœ… Always
Safe with threads?โŒ Deadlock riskโœ… Clean slate
Startup speedFastSlower
Available on Windows?โŒโœ… (only option)
Real-world: write every multiprocessing program as if it will run under spawn โ€” guard the launch with if __name__ == "__main__":, pass everything a worker needs explicitly, keep those arguments picklable, and rebuild expensive per-worker state in an initializer. Do that and the same file runs identically on Linux, macOS, and Windows across Python versions. Next up: Pattern 8 โ€” exactly what "picklable" means and the serialization tax it charges.