Python Advance 📂 Multiprocess · 1 of 1 71 min read

Python Multiprocessing & Queues

Master Python's escape from the GIL. Learn multiprocessing.Process for true parallel execution, Pool for effortless work distribution (map, imap_unordered, starmap), IPC through Queue and Pipe, and blazing-fast data sharing via Value, Array, Manager, and the modern shared_memory module. Includes benchmarks, image-processing and NumPy examples, fork vs spawn internals, synchronisation primitives, pitfalls, and a golden-rules cheat sheet.

Section 01

The Story That Explains Multiprocessing

Building a House With One Contractor vs Five
Imagine you're building a house. With one contractor, work is sequential — foundation, then framing, then plumbing, then wiring, then drywall. Even if you hire assistants (threads), they all have to share the one contractor's single set of tools (the GIL). No matter how many assistants, only one saw is running at a time.

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 Core Insight

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.


Section 02

Threads vs Processes — The Fundamental Difference

🏗️ Diagram — Memory Model: Threads Share, Processes Don't
THREADING (one process) Single Python Interpreter Shared Memory globals, lists, dicts, objects 🔒 Protected by ONE GIL Thread 1 Thread 2 Thread 3 Free data sharing Only 1 runs Python at a time Good for I/O, bad for CPU MULTIPROCESSING (many processes) Process 1 own memory own GIL own interpreter Process 2 own memory own GIL own interpreter Process 3 own memory own GIL own interpreter IPC Channel Queue • Pipe • SharedMemory Data must be pickled to share All processes run in TRUE parallel Perfect for CPU-bound

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.

AspectThreadingMultiprocessing
MemorySharedIsolated per process
Parallelism on multi-coreNo (GIL)Yes — true parallel
Startup cost~ms~100 ms (fork/spawn)
Memory footprint~8 MB per thread~30 MB per process
Data sharingFree (just pass objects)Pickled or shared memory
DebuggingRace conditionsEach process isolated
Best forI/O-boundCPU-bound

Section 03

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.")
OUTPUT
Main pid=52014 [P0] pid=52015 starting [P1] pid=52016 starting [P2] pid=52017 starting [P0] pid=52015 done [P1] pid=52016 done [P2] pid=52017 done All processes complete.
⚠️
The 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.


Section 04

Daemon Processes — Background Workers That Die With The Parent

The Office Cleaner After Hours
Imagine an office building. During work hours, the main staff (parent process) does important work. In the background, a cleaner (daemon process) empties bins, wipes desks, restocks paper. When the last staff member leaves and locks up, the cleaner leaves too — nobody stays behind alone. Daemon processes work exactly like that: they run alongside the parent, but the moment the parent exits, they're terminated automatically. No lingering zombies, no orphans.
👾 Diagram — Daemon vs Non-Daemon Lifecycle
NON-DAEMON (default) Parent running exit BLOCKS on child Child runs until it finishes on its own done Parent waits for child before actually exiting Good for: work that MUST complete DAEMON (p.daemon = True) Parent running exit Child background work KILLED Child is terminated the instant parent exits Good for: background helpers, monitors Set p.daemon = True BEFORE calling p.start(). Cannot change it after.

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
OUTPUT
[main] doing work for 3 seconds... [daemon] still watching... [daemon] still watching... [daemon] still watching... [main] exiting — daemon will be killed automatically (daemon dies immediately, no more prints)

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
⚠️
Three Non-Negotiable Rules For Daemons

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 DAEMON
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 NON-DAEMON
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
💡
Graceful Daemon Alternative

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")

Section 05

Fork vs Spawn — How Processes Are Created

🌿 Diagram — Fork vs Spawn Start Methods
FORK (Linux default) Parent Process state, globals, imports all loaded already os.fork() Child 1 copy of parent (copy-on-write) Child 2 copy of parent Child 3 copy of parent FAST • inherits everything Unsafe with threads, GUIs, some libs SPAWN (Windows & macOS) Parent Process sends pickled instructions to fresh interpreter start fresh python.exe Child 1 fresh interpreter re-imports script Child 2 fresh interpreter Child 3 fresh interpreter SLOW • nothing inherited Safe, portable, predictable

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())
💡
Which To Choose?

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.


Section 06

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.

🛡️ Diagram — How Pool Distributes Work
TASKS square(1) square(2) square(3) square(4) square(5) square(6) ... more ... Task Q (internal) Pool(4) — Worker Processes Worker 1 (pid 501) square(1) = 1 Worker 2 (pid 502) square(2) = 4 Worker 3 (pid 503) square(3) = 9 Worker 4 (pid 504) square(4) = 16 Result Q RESULTS [1, 4, 9, 16, ...] pool.map() preserves order

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}")
OUTPUT (8-core machine)
Sequential: 6.42s Pool(4): 1.71s <- ~3.75x speedup (linear scaling) 1: 49999995000000 2: 99999990000000 ...

Section 07

Pool Methods — Choosing The Right One

MethodBehaviourReturnsUse When
map(fn, iter)Blocking, orderedList of results (in order)Need all results together
map_async(fn, iter)Non-blocking, orderedAsyncResult (call .get())Want to do other work while pool runs
imap(fn, iter)Lazy iterator, orderedIterator (streams results)Large input, process results as they arrive
imap_unordered(fn, iter)Lazy iterator, UNorderedIterator (fastest first)Order doesn't matter, want max throughput
apply(fn, args)Blocking, single callSingle resultRarely useful — blocks the pool
apply_async(fn, args)Non-blocking, single callAsyncResultFire-and-forget individual tasks
starmap(fn, iter_of_tuples)Blocking, ordered, unpacks tuplesList of resultsFunction 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()
💡
When Order Doesn't Matter, Use imap_unordered

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.


Section 08

multiprocessing.Queue — Inter-Process Communication

The Pneumatic Tube System
Old banks had pneumatic tube systems — cylinders sucked between buildings by air pressure. Each department (process) was physically separate, but the tubes let them send capsules back and forth. You couldn't just hand someone a piece of paper across the street — you had to package it, put it in a capsule, and shoot it through the tube.

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.
📫 Diagram — IPC Queue Between Processes
Producer Process pid 501 q.put({"data": 42}) object pickled here pickle multiprocessing.Queue (OS pipe + shared memory) byt byt byt byt byt serialised bytes flow through OS unpickle Consumer Process pid 502 item = q.get() object reconstructed Every put/get incurs serialisation overhead — keep messages small

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.")
⚠️
Not All Objects Can Be Pickled

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.


Section 09

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()
PropertyQueuePipe
EndpointsMany producers, many consumersExactly 2 endpoints
SpeedMediumFast (raw OS pipe)
Thread-safe on same sideYesNo — must lock manually
APIput/getsend/recv
Best forWork-queue patternsParent-child bidirectional

Section 10

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.

💾 Diagram — Three Shared-Memory Strategies
1. Value / Array Proc A Proc B Value('i', 0) Array('d', [1,2,3]) C types, tiny Simple counters, fixed arrays of numbers Built-in Lock support 2. Manager (proxied) Proc A Proc B Manager Server Proc manager.dict() manager.list() any Python object Full Python objects, accessed via proxy Slower (RPC each call) 3. shared_memory (3.8+) Proc A Proc B Raw Shared Memory SharedMemory(size=N) ShareableList / NumPy raw bytes, zero-copy Massive numeric data, NumPy arrays, images Zero pickle overhead

Value/Array for small primitives. Manager for arbitrary Python objects. shared_memory for large numeric blobs like NumPy arrays.


Section 11

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[:]))
OUTPUT
Final counter: 40000 [0.0, 1.0, 4.0, 9.0, 16.0, 25.0, 36.0, 49.0, 64.0, 81.0, 100.0, 121.0, 144.0, 169.0, 196.0, 225.0, 256.0, 289.0, 324.0, 361.0]
🔑
Type Codes

'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.


Section 12

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))
OUTPUT
Dict: {0: 'done by worker 0', 1: 'done by worker 1', 2: 'done by worker 2', 3: 'done by worker 3', 4: 'done by worker 4'} List: [0, 1, 4, 9, 16]
🐌
Manager Is Slow

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.


Section 13

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
OUTPUT
First 5 : [0. 2. 4. 6. 8.] Last 5 : [1999990. 1999992. 1999994. 1999996. 1999998.]

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()
OUTPUT
Before: ['hello', 42, 3.14, True] After : ['modified by child', 142, 3.14, True]
🏆
Speed Win Example

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.


Section 14

Synchronisation Primitives

Just like threads, processes sometimes need to coordinate. multiprocessing provides process-safe versions of all the usual primitives.

🔒
Lock / RLock
mutual exclusion
Only one process can hold the lock at a time. Use to protect shared state (e.g. a shared counter) from race conditions. Use with lock: as a context manager.
📣
Event
broadcast signal
One process calls event.set(), all waiting processes on event.wait() proceed. Great for "start signal" or graceful shutdown.
🔁
Semaphore
counted resource
Limits how many processes can access a resource simultaneously. Useful for capping concurrent DB connections or API calls across all workers.
🏁
Barrier
wait for all
All N processes must reach barrier.wait() before any can proceed. Useful for stepped algorithms (all workers finish phase 1 before starting phase 2).
📌
Condition
wait for state
Wait for a specific condition to become true. Producers signal, consumers wait. More flexible than Event but more complex.
👤
JoinableQueue
queue + task_done
Like Queue but supports 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()

Section 15

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")
TYPICAL RESULT — 500 images on 8-core machine
Sequential (1 core): 152.4s Pool(8): 21.7s ~7x speedup

Section 16

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()
OUTPUT
Parallel: 0.34s using 8 workers First 5 row means: [0.4995, 0.5013, 0.4998, 0.5024, 0.4987]

Section 17

Common Pitfalls And Fixes

⚠️ Multiprocessing Bugs You Will Hit
Bug 1
Missing if __name__ == "__main__" guard → on Windows/macOS, child processes re-import and re-fork forever. Always guard process creation.
Bug 2
Trying to pickle a lambda or a closurePicklingError. Move the function to module level.
Bug 3
Sharing a normal Python object across processes → each process gets its own private copy. Use Manager, Value/Array, or shared_memory.
Bug 4
Forgetting to close() and unlink() shared memory → leaked shared memory blocks stay in /dev/shm until reboot.
Bug 5
Passing huge objects to Pool.map → each task pays pickling cost. Use shared_memory or reduce data movement.
Bug 6
Zombie processes on exit → Always call p.join() or use with Pool() as pool:. Otherwise child processes may become defunct.
Bug 7
Exceptions in workers vanish silently → With apply_async, exceptions surface only when you call .get(). Always call it, or use a callback.
Bug 8
Deadlock with full Queue → If the child process fills a Queue and the parent isn't draining, the child blocks on put() and never returns. Drain before joining.
Safe Pool Template

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")

Section 18

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

Section 19

Golden Rules

🔑 Multiprocessing & Queues — Non-Negotiable Rules
1
Always wrap process creation in if __name__ == "__main__":. Without it, Windows and macOS will spawn infinite child processes.
2
Use multiprocessing for CPU-bound work, threading for I/O. Rule of thumb: if your workers hit 100% CPU, use processes. If they mostly wait, use threads.
3
Default to Pool with imap_unordered for batch processing. It gives you streaming results and the best throughput with the least code.
4
For pool workers, set chunksize when you have many small tasks. Default chunksize=1 pays per-task IPC overhead. For 10,000+ tiny tasks, use chunksize=100.
5
For large arrays, use shared_memory instead of pickling through a Queue. A 100 MB NumPy array pickled costs seconds; shared costs microseconds.
6
Always call shm.close() and shm.unlink() on shared memory blocks. Leaked shared memory persists across program crashes and fills up /dev/shm.
7
Never share a raw Python object between processes. Each process gets its own copy. Use Manager, Value/Array, or shared_memory.
8
Only picklable objects can cross process boundaries. Lambdas, closures, open files, DB connections, and sockets are all off-limits. Move helper functions to module level.
9
Always drain your Queue before joining the producing process. A child process blocked on put() to a full Queue will hang forever if the parent is waiting on join().
10
Handle exceptions in workers explicitly. Wrap the task body in try/except and return an error tuple. Otherwise Pool.map raises only the first exception and you lose partial results.
11
Use cpu_count() as your default pool size for CPU-bound work. Going higher just adds context-switching overhead without more parallelism.
12
Set 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.
You have completed Multiprocess. View all sections →