Construct a process, start it, and join it โ the four verbs, plus why you get nothing back and how memory isolation breaks everything you know from threads.
import time
def burn(n):
total = 0
for i in range(n): # pure-Python loop โ the GIL cannot release here
total += i
return total
start = time.perf_counter()
for _ in range(4):
burn(30_000_000) # ~1s each, run back-to-back
print(f"{time.perf_counter() - start:.1f}s") # ~4s on any machine, 1 core busy
Threads won't help: the GIL serializes pure-Python bytecode, so four threads still take ~4s. Processes are the only standard-library way past it โ each child is a full interpreter with its own GIL.
Every process you launch follows the same lifecycle. It mirrors threads โ but the process runs in its own private memory, not shared with you.
p = mp.Process(target=fn, args=(...)) โ builds the object; nothing runs yet.p.start() โ spins up a new OS process (a fresh Python interpreter) that runs fn. Returns immediately.fn on its own core, in its own memory, with its own GIL.p.join() โ blocks the caller until the child has exited. Optional timeout=.Three read-only attributes you'll reach for constantly:
p.pid โ the OS process id (None before start()).p.is_alive() โ True between start() and exit.p.exitcode โ None while running, 0 on clean exit, > 0 for a nonzero sys.exit, and negative if killed by a signal (e.g. -9 = SIGKILL). Your first stop when a child "just died".p.start() is not fn(). start() forks/spawns a new process; calling fn() directly just runs it inline in your process with no parallelism. As with threads, never call the target yourself โ always start().
if __name__ == "__main__"
Unlike threads, launching a process must be guarded. On macOS and Windows (the spawn start method) each child re-imports your module to rebuild the worker. If your start() call sits at module top level, every child re-runs it on import โ spawning children that spawn children.
import multiprocessing as mp
def worker(name):
print(f"{name} running in pid {mp.current_process().pid}")
# โ
this block runs ONLY in the original process, never on re-import
if __name__ == "__main__":
p = mp.Process(target=worker, args=("A",))
p.start()
p.join()
print("exitcode:", p.exitcode) # 0
RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase. This is the single most common multiprocessing bug. Details in Pattern 7: Start Methods.
Here's where processes diverge hard from threads. A Process gives you no way to read what target returned โ the return value is discarded when the child exits. And because the child has separate memory, the thread trick of writing into a shared dict does not work: the child mutates its own private copy, which vanishes on exit.
import multiprocessing as mp
results = {}
def fetch(name):
results[name] = f"data-{name}" # writes the CHILD's private copy โ parent never sees it
if __name__ == "__main__":
procs = [mp.Process(target=fetch, args=(n,)) for n in "ABC"]
for p in procs: p.start()
for p in procs: p.join()
print(results) # {} โ EMPTY. The child's memory was thrown away.
You have exactly two ways to get data back across the memory boundary:
multiprocessing.Queue: a pickle-backed pipe that carries objects between processes. Correct, but verbose. (Pattern 3)
import multiprocessing as mp
def fetch(name, q):
q.put((name, f"data-{name}")) # push result onto the shared queue
if __name__ == "__main__":
q = mp.Queue()
procs = [mp.Process(target=fetch, args=(n, q)) for n in "ABC"]
for p in procs: p.start()
for p in procs: p.join()
print({k: v for k, v in (q.get() for _ in procs)}) # {'A': 'data-A', ...}
ProcessPoolExecutor: it hands you a Future per task and re-raises worker exceptions when you read .result(). No manual queue plumbing. (Pattern 2)
from concurrent.futures import ProcessPoolExecutor
def fetch(name):
return f"data-{name}" # a plain return โ the pool collects it
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
print(list(pool.map(fetch, "ABC"))) # ['data-A', 'data-B', 'data-C']
Queue for a few long-lived workers; reach for the pool the moment you have many tasks or want results.
Same pitfall as threads, same fix. If you start() and join() a process inside the same loop iteration, you wait for each child to finish before launching the next โ perfectly sequential, and you've paid the process-startup cost for nothing.
import multiprocessing as mp, time
def burn(n):
total = 0
for i in range(n):
total += i
if __name__ == "__main__":
# โ SEQUENTIAL: each join blocks before the next start
for _ in range(4):
p = mp.Process(target=burn, args=(30_000_000,))
p.start()
p.join() # waits here โ next process hasn't started yet
# โ
PARALLEL: start them all, THEN join them all
procs = [mp.Process(target=burn, args=(30_000_000,)) for _ in range(4)]
for p in procs: p.start()
for p in procs: p.join() # total โ time of one, not four
Two knobs you'll meet early, both about lifecycle rather than results:
daemon=True โ set before start(). A daemon child is killed abruptly when the parent exits (no cleanup, no finally, no join of its children). Good for a background heartbeat you don't care about losing; dangerous for anything holding a resource. Non-daemon (the default) children keep the interpreter alive until they finish.p.terminate() โ sends SIGTERM to the child; p.kill() sends SIGKILL. Both are blunt: the child stops without running cleanup, and any half-written Queue data can corrupt the queue. Always join() after terminating to reap the zombie.import multiprocessing as mp, time
def loop_forever():
while True:
time.sleep(0.5)
if __name__ == "__main__":
p = mp.Process(target=loop_forever, daemon=True)
p.start()
time.sleep(1)
p.terminate() # SIGTERM โ abrupt stop
p.join() # reap it; now p.exitcode is negative (signalled)
print(p.exitcode) # -15 (== -SIGTERM)
Event the worker polls) over terminate() in production โ abrupt kills lose in-flight work and can wedge shared queues. The full playbook is in Pattern 9: Shutdown & Errors.
| Verb / attr | What it does | Blocks? |
|---|---|---|
mp.Process(target=..., args=...) | Builds the process object | No โ nothing runs |
.start() | Spawns a new interpreter, runs target | No โ returns instantly |
.join([timeout]) | Wait for the child to exit | Yes โ blocks the caller |
.is_alive() | Is it running right now? | No |
.exitcode | 0 clean, >0 sys.exit, <0 signalled | No |
Process is for a handful of long-lived workers you manage by hand โ a background consumer, a dedicated writer, a supervised daemon. The instant you have many short tasks or need return values, graduate to ProcessPoolExecutor (Pattern 2); it handles spawning, result collection, exception propagation, and cleanup for you.