The Story That Explains Multiprocessing
Now hire five independent contractors, each with their own crew, their own truck full of tools, and their own workspace. They can genuinely work in parallel — one lays foundation while another frames, another wires, another plumbs. They only need to coordinate occasionally (shared blueprints, delivery of materials).
That's multiprocessing in Python. Each process is its own contractor with its own Python interpreter, its own GIL, its own memory space. True parallelism. The only cost is coordination — passing messages between processes takes more work than passing them between threads.
The GIL is per-interpreter, not per-machine. If you spawn 8 processes, you get 8 interpreters, 8 GILs, and 8 cores actually running Python bytecode simultaneously. This is Python's escape hatch from the GIL for CPU-bound work.
Threads vs Processes — The Fundamental Difference
Threads share one interpreter and one memory space cheaply but hit the GIL. Processes each have their own everything — expensive to share data, but true parallel execution.
| Aspect | Threading | Multiprocessing |
|---|---|---|
| Memory | Shared | Isolated per process |
| Parallelism on multi-core | No (GIL) | Yes — true parallel |
| Startup cost | ~ms | ~100 ms (fork/spawn) |
| Memory footprint | ~8 MB per thread | ~30 MB per process |
| Data sharing | Free (just pass objects) | Pickled or shared memory |
| Debugging | Race conditions | Each process isolated |
| Best for | I/O-bound | CPU-bound |
Your First Process — The Basics
The multiprocessing.Process class mirrors threading.Thread
almost exactly. If you know threads, you already know 80% of processes.
from multiprocessing import Process
import os
import time
def worker(name, duration):
print(f"[{name}] pid={os.getpid()} starting")
time.sleep(duration)
print(f"[{name}] pid={os.getpid()} done")
if __name__ == "__main__": # REQUIRED on Windows and macOS
print(f"Main pid={os.getpid()}")
processes = []
for i in range(3):
p = Process(target=worker, args=(f"P{i}", 2))
p.start()
processes.append(p)
for p in processes:
p.join()
print("All processes complete.")
if __name__ == "__main__" Rule
On Windows and macOS, Python spawns new processes by re-importing your
script. Without the __main__ guard, each child process would try to
start more children — an infinite recursion. Always wrap process creation in
this guard.
Daemon Processes — Background Workers That Die With The Parent
Non-daemon children keep the parent alive until they finish. Daemon children are killed automatically when the parent exits — no zombies, no orphans.
Daemon in Action
from multiprocessing import Process
import time
def background_monitor():
while True:
print(" [daemon] still watching...")
time.sleep(1)
if __name__ == "__main__":
p = Process(target=background_monitor)
p.daemon = True # MUST be set before start()
p.start()
print("[main] doing work for 3 seconds...")
time.sleep(3)
print("[main] exiting — daemon will be killed automatically")
# No p.join() needed — daemon dies with parent
The Same Program WITHOUT daemon flag
if __name__ == "__main__":
p = Process(target=background_monitor)
# p.daemon = True <-- omitted!
p.start()
time.sleep(3)
print("[main] want to exit...")
# PROGRAM HANGS FOREVER — child runs infinite loop, parent waits
1. You must set p.daemon = True BEFORE calling
p.start(). Setting it after raises RuntimeError.
2. A daemon process cannot have children of its own.
Attempting to spawn a Process from inside a daemon raises
AssertionError: daemonic processes are not allowed to have children.
3. Daemons are killed abruptly — no cleanup, no
finally blocks, no exit handlers. Never use them for anything that needs graceful
shutdown (open files, DB transactions, incomplete writes).
When To Use Each
| Use case |
|---|
| Background logging / metrics collector |
| Heartbeat sender to health-check service |
| Idle-connection watchdog |
| Periodic cache refresher |
| Any "if parent dies, this is meaningless" work |
| Use case |
|---|
| Writing critical data to disk / database |
| Uploading files to cloud storage |
| Rendering / encoding a video |
| ML training epoch that must finish |
| Anything with cleanup logic in a finally block |
If you want background work AND clean shutdown, don't use daemon=True.
Instead, run a normal process with an Event for shutdown signalling:
the parent calls event.set() on exit, the child polls the event and
exits cleanly. That way you get the "dies with parent" behaviour PLUS proper cleanup.
from multiprocessing import Process, Event
import time
import atexit
def graceful_monitor(stop_event):
try:
while not stop_event.is_set():
print(" [worker] doing work...")
stop_event.wait(timeout=1) # wakes early if signalled
finally:
print(" [worker] shutting down cleanly — flushing state")
if __name__ == "__main__":
stop = Event()
p = Process(target=graceful_monitor, args=(stop,))
p.start()
atexit.register(lambda: (stop.set(), p.join(timeout=5)))
time.sleep(3)
print("[main] done")
Fork vs Spawn — How Processes Are Created
Fork duplicates the parent process instantly (Linux). Spawn starts a fresh Python interpreter and re-imports your script (Windows, macOS default from 3.8).
import multiprocessing as mp
# Set start method explicitly for consistent behavior across platforms
if __name__ == "__main__":
mp.set_start_method("spawn") # or "fork", "forkserver"
# Check what's available on this platform
print("Available:", mp.get_all_start_methods())
print("Current: ", mp.get_start_method())
Use spawn for portability and safety — it's the modern default. Use fork only on Linux when you need to avoid the 100ms startup cost and don't use threads/GUIs. Use forkserver if you fork many workers from a clean state.
Pool — The Workhorse of Parallel Python
Manually creating and joining processes gets tedious for anything beyond trivial
scripts. multiprocessing.Pool gives you a persistent pool of worker
processes and a batch of high-level methods to distribute work across them.
Pool starts N worker processes once, then feeds them tasks through an internal queue. Results come back in order (map) or as soon as ready (imap_unordered).
from multiprocessing import Pool
import time
def heavy_compute(n):
# Simulated CPU work
total = 0
for i in range(10_000_000):
total += i * n
return n, total
if __name__ == "__main__":
numbers = list(range(1, 9))
# --- Sequential ---
start = time.perf_counter()
results = [heavy_compute(n) for n in numbers]
print(f"Sequential: {time.perf_counter() - start:.2f}s")
# --- Pool with 4 workers ---
start = time.perf_counter()
with Pool(processes=4) as pool:
results = pool.map(heavy_compute, numbers)
print(f"Pool(4): {time.perf_counter() - start:.2f}s")
for n, total in results:
print(f" {n}: {total}")
Pool Methods — Choosing The Right One
| Method | Behaviour | Returns | Use When |
|---|---|---|---|
map(fn, iter) | Blocking, ordered | List of results (in order) | Need all results together |
map_async(fn, iter) | Non-blocking, ordered | AsyncResult (call .get()) | Want to do other work while pool runs |
imap(fn, iter) | Lazy iterator, ordered | Iterator (streams results) | Large input, process results as they arrive |
imap_unordered(fn, iter) | Lazy iterator, UNordered | Iterator (fastest first) | Order doesn't matter, want max throughput |
apply(fn, args) | Blocking, single call | Single result | Rarely useful — blocks the pool |
apply_async(fn, args) | Non-blocking, single call | AsyncResult | Fire-and-forget individual tasks |
starmap(fn, iter_of_tuples) | Blocking, ordered, unpacks tuples | List of results | Function takes multiple arguments |
Practical Examples of Each
from multiprocessing import Pool
def square(x): return x * x
def add(a, b): return a + b
if __name__ == "__main__":
with Pool(4) as pool:
# 1. map — simplest, blocks until done, results in order
print(pool.map(square, [1, 2, 3, 4, 5]))
# [1, 4, 9, 16, 25]
# 2. starmap — for multi-arg functions
print(pool.starmap(add, [(1, 2), (3, 4), (5, 6)]))
# [3, 7, 11]
# 3. imap_unordered — stream results as they finish
for result in pool.imap_unordered(square, range(10)):
print(result, end=" ") # might print: 0 4 1 9 25 16 36 49 64 81
# 4. apply_async — fire off a single task without blocking
r = pool.apply_async(square, (10,))
# ... do other work ...
print(r.get(timeout=5)) # 100
# 5. map_async with callback
def on_complete(results):
print(f"Done! {len(results)} results")
r = pool.map_async(square, range(100), callback=on_complete)
r.wait()
For long-running jobs (image processing, web scraping, ML inference), use
imap_unordered with a progress bar. You'll see results streaming
in the moment they complete, instead of waiting for the entire batch.
Add chunksize=10 for a big speedup on many small tasks.
multiprocessing.Queue — Inter-Process Communication
multiprocessing.Queue is the pneumatic tube of Python processes. Objects
get pickled (serialised), sent across an OS pipe or shared memory,
then unpickled at the other end. Slower than passing a reference
between threads, but the only safe way to move data between processes.
Unlike a threading Queue (in-memory), multiprocessing.Queue pickles objects, sends bytes through an OS pipe, then unpickles on the other side.
Producer-Consumer Between Processes
from multiprocessing import Process, Queue
import time
import os
def producer(q, count):
for i in range(count):
item = {"id": i, "pid": os.getpid(), "payload": i * 2}
q.put(item)
print(f"[P] produced {item}")
time.sleep(0.1)
q.put(None) # poison pill
def consumer(q):
while True:
item = q.get()
if item is None: break
print(f" [C pid={os.getpid()}] got {item}")
time.sleep(0.15)
if __name__ == "__main__":
q = Queue(maxsize=10)
p1 = Process(target=producer, args=(q, 5))
p2 = Process(target=consumer, args=(q,))
p1.start(); p2.start()
p1.join(); p2.join()
print("Done.")
Lambdas, open file handles, database connections, sockets, and locally-defined functions cannot be pickled and will fail when you try to put them in a multiprocessing Queue. Stick to plain data types (dicts, lists, numbers, strings) or dataclasses. Use module-level functions, not lambdas.
Pipes — The Faster, Two-Endpoint Alternative
multiprocessing.Pipe() returns a pair of connected endpoints for
bidirectional communication between exactly two processes. It's faster than Queue
for point-to-point traffic but doesn't have safe multi-producer or multi-consumer
semantics.
from multiprocessing import Process, Pipe
def worker(conn):
conn.send({"status": "ready", "pid": 42})
reply = conn.recv() # wait for parent's message
print(f"Worker got reply: {reply}")
conn.close()
if __name__ == "__main__":
parent_conn, child_conn = Pipe()
p = Process(target=worker, args=(child_conn,))
p.start()
print("Parent got:", parent_conn.recv())
parent_conn.send("go ahead")
p.join()
| Property | Queue | Pipe |
|---|---|---|
| Endpoints | Many producers, many consumers | Exactly 2 endpoints |
| Speed | Medium | Fast (raw OS pipe) |
| Thread-safe on same side | Yes | No — must lock manually |
| API | put/get | send/recv |
| Best for | Work-queue patterns | Parent-child bidirectional |
Shared Memory — When Pickling Is Too Slow
Pickling a 1 GB NumPy array to send across a Queue is absurdly slow. For high-volume data, Python offers real shared memory — a chunk of RAM that multiple processes can read and write directly, no serialisation needed.
Value/Array for small primitives. Manager for arbitrary Python objects. shared_memory for large numeric blobs like NumPy arrays.
Shared Memory — Value & Array (Simple C Types)
For a shared counter or a small fixed-size array of numbers, use
Value and Array. They wrap C-level primitives with an
optional lock for atomicity.
from multiprocessing import Process, Value, Array, Lock
def increment(counter, lock, n):
for _ in range(n):
with lock: # atomic increment
counter.value += 1
def square_in_place(arr, start, end):
for i in range(start, end):
arr[i] = arr[i] * arr[i]
if __name__ == "__main__":
# --- Shared counter ---
counter = Value('i', 0) # 'i' = signed int
lock = Lock()
procs = [Process(target=increment, args=(counter, lock, 10_000))
for _ in range(4)]
for p in procs: p.start()
for p in procs: p.join()
print(f"Final counter: {counter.value}") # 40000 (deterministic!)
# --- Shared array ---
arr = Array('d', range(20)) # 'd' = double
p1 = Process(target=square_in_place, args=(arr, 0, 10))
p2 = Process(target=square_in_place, args=(arr, 10, 20))
p1.start(); p2.start()
p1.join(); p2.join()
print(list(arr[:]))
'i' = int, 'd' = double, 'f' = float,
'b' = signed char, 'B' = unsigned char, 'c' = char.
Same codes as Python's array module. For anything more complex,
use shared_memory with NumPy.
Shared Memory — Manager (Any Python Object)
When you need to share dicts, lists, or arbitrary Python objects, use
Manager. It runs a separate server process that owns the real object;
every access from a worker is a small RPC call.
from multiprocessing import Process, Manager
def worker(shared_dict, shared_list, worker_id):
shared_dict[worker_id] = f"done by worker {worker_id}"
shared_list.append(worker_id ** 2)
if __name__ == "__main__":
with Manager() as manager:
shared_dict = manager.dict()
shared_list = manager.list()
procs = [Process(target=worker, args=(shared_dict, shared_list, i))
for i in range(5)]
for p in procs: p.start()
for p in procs: p.join()
print("Dict:", dict(shared_dict))
print("List:", list(shared_list))
Every read or write to a Manager object goes through a background server process
via IPC. For high-frequency access (millions of ops), this is much slower than
raw Value/Array. Use Manager for convenience with
moderate access rates, not for hot loops.
Shared Memory — The Modern shared_memory Module
Introduced in Python 3.8, multiprocessing.shared_memory lets processes
share a raw block of memory with zero pickling overhead. Combined with NumPy,
this is the fastest way to share large numeric arrays.
from multiprocessing import Process
from multiprocessing.shared_memory import SharedMemory
import numpy as np
def worker(shm_name, shape, dtype, start, end):
# Attach to existing shared memory block by name
existing = SharedMemory(name=shm_name)
arr = np.ndarray(shape, dtype=dtype, buffer=existing.buf)
# Mutate in place — no copy, no pickle
arr[start:end] *= 2
existing.close()
if __name__ == "__main__":
# Allocate a 1 million-element float64 array in shared memory
shape = (1_000_000,)
dtype = np.float64
nbytes = np.zeros(shape, dtype=dtype).nbytes
shm = SharedMemory(create=True, size=nbytes)
arr = np.ndarray(shape, dtype=dtype, buffer=shm.buf)
arr[:] = np.arange(1_000_000)
# Spawn 4 workers, each mutating a slice
chunk = 250_000
procs = [Process(target=worker,
args=(shm.name, shape, dtype, i * chunk, (i + 1) * chunk))
for i in range(4)]
for p in procs: p.start()
for p in procs: p.join()
print("First 5 :", arr[:5]) # [0, 2, 4, 6, 8]
print("Last 5 :", arr[-5:]) # [1999990, ..., 1999998]
shm.close()
shm.unlink() # free the shared memory block
ShareableList — Simpler Alternative
from multiprocessing import Process
from multiprocessing.shared_memory import ShareableList
def worker(name):
sl = ShareableList(name=name)
sl[0] = "modified by child"
sl[1] += 100
sl.shm.close()
if __name__ == "__main__":
sl = ShareableList(["hello", 42, 3.14, True])
print("Before:", list(sl))
p = Process(target=worker, args=(sl.shm.name,))
p.start(); p.join()
print("After :", list(sl))
sl.shm.close(); sl.shm.unlink()
Passing a 100 MB NumPy array through Queue: ~2 seconds (all pickling).
Passing the same array via shared_memory: ~0.001 seconds (just a name).
For any parallel workload on large arrays, this is the difference between
a 10× speedup and a 0.5× slowdown.
Synchronisation Primitives
Just like threads, processes sometimes need to coordinate. multiprocessing provides process-safe versions of all the usual primitives.
with lock: as
a context manager.
event.set(), all waiting processes on
event.wait() proceed. Great for "start signal" or graceful shutdown.
barrier.wait() before any can proceed.
Useful for stepped algorithms (all workers finish phase 1 before starting phase 2).
task_done() and join()
— the multi-process equivalent of threading's Queue for the poison-pill pattern.
from multiprocessing import Process, Event, Barrier
import time
import random
def worker(worker_id, start_event, barrier):
start_event.wait() # wait for green light
print(f"[W{worker_id}] phase 1 starting")
time.sleep(random.uniform(0.5, 2))
print(f"[W{worker_id}] phase 1 done, waiting at barrier")
barrier.wait() # wait for all workers
print(f"[W{worker_id}] phase 2 starting")
if __name__ == "__main__":
N = 3
start_event = Event()
barrier = Barrier(N)
procs = [Process(target=worker, args=(i, start_event, barrier))
for i in range(N)]
for p in procs: p.start()
time.sleep(1) # let workers set up
print("All workers ready. GO!")
start_event.set() # release all at once
for p in procs: p.join()
Real-World Example — Parallel Image Processing
A classic use case for multiprocessing: apply a heavy filter to hundreds of images. Threads would be crippled by the GIL; processes scale linearly with cores.
from multiprocessing import Pool, cpu_count
from PIL import Image, ImageFilter
from pathlib import Path
import time
INPUT_DIR = Path("input_images")
OUTPUT_DIR = Path("output_images")
OUTPUT_DIR.mkdir(exist_ok=True)
def process_image(input_path):
"""Load, apply expensive filter, save."""
img = Image.open(input_path)
# CPU-heavy filter chain
img = img.filter(ImageFilter.GaussianBlur(radius=5))
img = img.filter(ImageFilter.EDGE_ENHANCE_MORE)
img = img.convert("RGB")
output_path = OUTPUT_DIR / input_path.name
img.save(output_path, quality=85)
return input_path.name
if __name__ == "__main__":
images = list(INPUT_DIR.glob("*.jpg"))
print(f"Processing {len(images)} images on {cpu_count()} cores")
start = time.perf_counter()
with Pool(processes=cpu_count()) as pool:
# imap_unordered streams results, chunksize batches for efficiency
for i, name in enumerate(pool.imap_unordered(process_image, images, chunksize=4), 1):
print(f" [{i}/{len(images)}] {name}")
print(f"\nCompleted in {time.perf_counter() - start:.2f}s")
Real-World Example — Parallel Data Aggregation With Shared Memory
Compute per-row statistics on a huge NumPy array using all cores, without copying
the data to each worker. Uses shared_memory for the input and a small
Array for the output.
from multiprocessing import Process, Array, cpu_count
from multiprocessing.shared_memory import SharedMemory
import numpy as np
import time
def worker(shm_name, shape, dtype, out_arr, start_row, end_row):
shm = SharedMemory(name=shm_name)
data = np.ndarray(shape, dtype=dtype, buffer=shm.buf)
# Compute row means for our slice
for i in range(start_row, end_row):
out_arr[i] = data[i].mean()
shm.close()
if __name__ == "__main__":
# Simulate a 100k x 1000 dataset (~800 MB float64)
N_ROWS, N_COLS = 100_000, 1_000
data = np.random.rand(N_ROWS, N_COLS)
# Move into shared memory
shm = SharedMemory(create=True, size=data.nbytes)
shared = np.ndarray(data.shape, dtype=data.dtype, buffer=shm.buf)
shared[:] = data
# Shared output — one mean per row
results = Array('d', N_ROWS)
# Split work across cores
n_workers = cpu_count()
chunk = N_ROWS // n_workers
start = time.perf_counter()
procs = []
for i in range(n_workers):
s = i * chunk
e = N_ROWS if i == n_workers - 1 else (i + 1) * chunk
p = Process(target=worker,
args=(shm.name, data.shape, data.dtype, results, s, e))
procs.append(p); p.start()
for p in procs: p.join()
elapsed = time.perf_counter() - start
print(f"Parallel: {elapsed:.2f}s using {n_workers} workers")
print(f"First 5 row means: {list(results[:5])}")
shm.close(); shm.unlink()
Common Pitfalls And Fixes
if __name__ == "__main__" guard → on Windows/macOS, child processes re-import and re-fork forever. Always guard process creation.
PicklingError. Move the function to module level.
close() and unlink() shared memory → leaked shared memory blocks stay in /dev/shm until reboot.
p.join() or use with Pool() as pool:. Otherwise child processes may become defunct.
apply_async, exceptions surface only when you call .get(). Always call it, or use a callback.
put() and never returns. Drain before joining.
Wrap your pool in with Pool() as pool:, use imap_unordered
for streaming, and always call .get() on async results so exceptions surface.
This eliminates 90% of multiprocessing bugs.
from multiprocessing import Pool
def safe_task(item):
try:
return {"ok": True, "result": process(item)}
except Exception as e:
return {"ok": False, "error": repr(e), "item": item}
if __name__ == "__main__":
with Pool(processes=8) as pool:
results = pool.map(safe_task, items)
ok = [r for r in results if r["ok"]]
fail = [r for r in results if not r["ok"]]
print(f"{len(ok)} succeeded, {len(fail)} failed")
Choosing The Right IPC — Decision Table
| Need | Use | Why |
|---|---|---|
| Distribute independent tasks to workers | Pool.map / imap_unordered |
Simplest, batteries-included |
| Streaming producer-consumer | Queue + Process |
Many-to-many, thread-safe |
| Fast parent-child two-way channel | Pipe |
Lower overhead than Queue |
| Shared counter or small numeric array | Value / Array |
Built-in locking, C-fast |
| Shared dict/list of Python objects | Manager().dict() |
Flexible, but slower (RPC) |
| Large NumPy arrays or byte buffers | shared_memory + numpy |
Zero-copy, blazing fast |
| Coordinate start/stop across workers | Event |
Broadcast signal to all |
| Synchronise phase transitions | Barrier |
All must reach before any proceeds |
| Limit concurrent access to a resource | Semaphore |
Cap concurrent DB or API calls |
Golden Rules
if __name__ == "__main__":.
Without it, Windows and macOS will spawn infinite child processes.
Pool with imap_unordered
for batch processing. It gives you streaming results and the best throughput
with the least code.
chunksize when you have many
small tasks. Default chunksize=1 pays per-task IPC overhead.
For 10,000+ tiny tasks, use chunksize=100.
shared_memory instead of
pickling through a Queue. A 100 MB NumPy array pickled costs seconds;
shared costs microseconds.
shm.close() and shm.unlink()
on shared memory blocks. Leaked shared memory persists across program crashes and
fills up /dev/shm.
Manager, Value/Array, or shared_memory.
put() to a full Queue will hang forever
if the parent is waiting on join().
Pool.map raises only the first
exception and you lose partial results.
cpu_count() as your default pool size for
CPU-bound work. Going higher just adds context-switching overhead without
more parallelism.
p.daemon = True BEFORE calling p.start()
for background workers that should die with the parent. Never use daemon processes
for anything requiring cleanup — they're killed abruptly. And remember:
a daemon process cannot spawn its own child processes.