๐Ÿญ Pattern 1: Process Basics

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.

The Problem

Scenario: You have several CPU-bound jobs โ€” pure-Python number crunching, each burning a full core-second. Run them one after another on your 8-core machine and you use exactly one core while seven sit idle.

Goal: run them on separate cores at the same time and pay roughly the cost of the slowest one, not the sum.

The Sequential Baseline (โŒ One Core)

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.

The Four Verbs

Every process you launch follows the same lifecycle. It mirrors threads โ€” but the process runs in its own private memory, not shared with you.

flowchart LR C["mp.Process(target=fn)
construct"] --> S["p.start()
spawns a new interpreter"] S --> R["running
(OWN memory + OWN GIL)"] R --> D["fn returns
process exits"] D --> J["p.join()
parent waits here"] J --> E["p.exitcode = 0"]

Three read-only attributes you'll reach for constantly:

Key Insight: 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().

The Rule You Can't Skip: 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
Gotcha: a missing guard gives you either an infinite process explosion or 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.

Getting a Return Value: You Get NONE

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:

Option A โ€” write into a 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', ...}
Option B (better) โ€” use 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']
Key Insight: with threads you shared a dict because memory was shared. With processes it isn't โ€” every result must be pickled and copied back across the boundary. Use a Queue for a few long-lived workers; reach for the pool the moment you have many tasks or want results.

The Common Bug: Start-and-Join in One Loop

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
Gotcha: the parallel version only wins if you have the cores. Four CPU-bound children on a 2-core machine still contend โ€” see Pattern 10 for sizing.

Daemons and Termination

Two knobs you'll meet early, both about lifecycle rather than results:

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)
Real-world: prefer a graceful shutdown (an 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.

Key Takeaways

Verb / attrWhat it doesBlocks?
mp.Process(target=..., args=...)Builds the process objectNo โ€” nothing runs
.start()Spawns a new interpreter, runs targetNo โ€” returns instantly
.join([timeout])Wait for the child to exitYes โ€” blocks the caller
.is_alive()Is it running right now?No
.exitcode0 clean, >0 sys.exit, <0 signalledNo
Real-world: raw 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.