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."
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.
fork โ the child is a near-instant copy of the parent's whole
memory (copy-on-write). Fast, no re-import; the child inherits everything โ globals,
imported modules, open file descriptors, sockets. Unix only.spawn โ a fresh Python interpreter that re-imports your module
and receives only the explicitly-passed, picklable arguments. Slower to start, clean slate,
inherits nothing. Default on macOS (since 3.8) and Windows; the only option on Windows.forkserver โ a small, clean server process is forked once at
the start, then it forks workers on request. Avoids copying a big or thread-heavy parent
while keeping fork's speed. Unix only.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.
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 / version | Default start method | Notes |
|---|---|---|
| Linux, Python โค 3.13 | fork | Fast, but unsafe with threads (see below) |
| Linux, Python 3.14+ | forkserver | Fork-in-a-threaded-parent deprecated; safer default |
| macOS, Python โฅ 3.8 | spawn | Switched from fork โ fork was unsafe with system libs |
| Windows (all) | spawn | The only option โ no fork on Windows |
spawn and it works on all three.
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))
get_context โ never
set_start_method. Mutating the global start method surprises whoever imports you.
__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.
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.
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()
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)))
| Question | fork | spawn |
|---|---|---|
| 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 speed | Fast | Slower |
| Available on Windows? | โ | โ (only option) |
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.