Multiprocessing & Parallelism

Process pools, shared memory, and true parallel execution.

True Parallel Processing in Python

Multiprocessing bypasses the Global Interpreter Lock (GIL) by creating separate Python processes, each with its own interpreter and memory space. This enables true parallel execution on multiple CPU cores, making it ideal for CPU-intensive computations.

When to Use Multiprocessing:

  • ✓ CPU-bound tasks: Data processing, calculations, image manipulation, ML training
  • ✓ Parallel computation: Tasks that can run independently across cores
  • ✓ Bypassing GIL: When threading doesn't provide speedup for CPU work
  • ✗ I/O-bound tasks: Use threading or asyncio instead (lower overhead)
  • ✗ Frequent data sharing: Inter-process communication is expensive

Threading vs Multiprocessing

Threading

Shared memory: All threads share memory

GIL limitation: One thread executes at a time

Lightweight: Low overhead, fast creation

Best for: I/O-bound operations

Memory: [███ Shared ███]
Thread 1 → Same GIL
Thread 2 → Same GIL
Thread 3 → Same GIL
Multiprocessing

Separate memory: Each process isolated

No GIL: True parallel execution

Heavyweight: Higher overhead

Best for: CPU-bound operations

Process 1: [██] Own memory
Process 2: [██] Own memory
Process 3: [██] Own memory
All run in parallel!

Basic Multiprocessing

Creating and managing processes with the multiprocessing module.

import multiprocessing as mp
import os
import time

def worker(name):
    """Function to run in separate process"""
    print(f"Worker {name} started in PID: {os.getpid()}")
    time.sleep(2)
    print(f"Worker {name} finished")

if __name__ == '__main__':
    # IMPORTANT: Always use if __name__ == '__main__' guard
    # Required on Windows, good practice everywhere

    print(f"Main process PID: {os.getpid()}")

    # Create process
    process = mp.Process(target=worker, args=('A',))

    # Start process (creates new Python interpreter)
    process.start()
    print("Main process continues...")

    # Wait for process to complete
    process.join()
    print("Process completed!")

    # Multiple processes
    processes = []
    for i in range(4):
        p = mp.Process(target=worker, args=(f"Worker-{i}",))
        processes.append(p)
        p.start()

    # Wait for all processes
    for p in processes:
        p.join()

    print("All processes completed!")
    print(f"CPU cores available: {mp.cpu_count()}")
Expected Output:
Main process PID: 73024
Main process continues...
Worker A started in PID: 73025
Worker A finished
Process completed!
Worker Worker-0 started in PID: 73029
Worker Worker-1 started in PID: 73030
Worker Worker-2 started in PID: 73031
Worker Worker-3 started in PID: 73032
Worker Worker-0 finished
Worker Worker-1 finished
Worker Worker-2 finished
Worker Worker-3 finished
All processes completed!
CPU cores available: 20

Those three demos replay a real run of the benchmark below, so you can check the claim rather than take it. Run it yourself and the ratios should look similar even though the absolute times will not.

# cpu_bench.py - measure the threading/multiprocessing gap instead of asserting it
import multiprocessing as mp
import threading
import time
from concurrent.futures import ProcessPoolExecutor

def fib(n):
    """Deliberately naive: pure Python recursion, so it needs the GIL throughout"""
    return n if n < 2 else fib(n - 1) + fib(n - 2)

def timed(fn):
    start = time.perf_counter()
    fn()
    return time.perf_counter() - start

N = 35
TASKS = 4

def sequential():
    for _ in range(TASKS):
        fib(N)

def threaded():
    ts = [threading.Thread(target=fib, args=(N,)) for _ in range(TASKS)]
    for t in ts:
        t.start()
    for t in ts:
        t.join()

def processes():
    with ProcessPoolExecutor(max_workers=TASKS) as ex:
        list(ex.map(fib, [N] * TASKS))

if __name__ == '__main__':
    print(f"cores: {mp.cpu_count()}  |  fib({N}) = {fib(N)}  |  {TASKS} tasks")

    seq = timed(sequential)
    thr = timed(threaded)
    proc = timed(processes)

    print(f"  sequential    {seq:6.2f}s")
    print(f"  {TASKS} threads     {thr:6.2f}s   ({seq / thr:.2f}x vs sequential)")
    print(f"  {TASKS} processes   {proc:6.2f}s   ({seq / proc:.2f}x vs sequential)")
Expected Output:
cores: 20  |  fib(35) = 9227465  |  4 tasks
  sequential      2.22s
  4 threads       2.23s   (0.99x vs sequential)
  4 processes     0.61s   (3.66x vs sequential)
About the outputs on this page: every output here is copied from a real run, but several of them will not match yours line for line, and that is not a mistake. Process IDs are assigned by the operating system, timings depend on your machine, and when several processes print, the order is decided by the scheduler. Read those as one example of what happened. The rest are stable on purpose: a couple of blocks sort their results specifically so they can promise the same answer every time.

How Processes Start: fork, spawn & forkserver

A subtle but important detail: how a child process is created affects correctness, performance, and portability. Python offers three start methods, and the default differs by platform.

fork

Clones the parent (copy-on-write). Very fast, Unix-only. Unsafe if the parent holds locks in other threads. Historically the Linux default.

spawn

Launches a fresh interpreter and re-imports your module. Safe and portable, the default on macOS and Windows. Slower startup; everything must be picklable.

forkserver

Forks children from a clean helper process with no extra threads. Unix-only, combines fork's speed with much of spawn's safety.

import multiprocessing as mp

def square(x):
    return x * x

if __name__ == '__main__':
    # Three ways to start a process:
    #   'fork'       - clone the parent process (fast; Unix only; unsafe with threads)
    #   'spawn'      - launch a fresh interpreter (safe; default on macOS & Windows)
    #   'forkserver' - fork from a clean helper process (Unix; safe with threads)

    # Option A: set the default once (guard it with __main__)
    mp.set_start_method('spawn')

    # Option B: get an explicit context WITHOUT changing the global default
    ctx = mp.get_context('spawn')
    with ctx.Pool(processes=4) as pool:
        print(pool.map(square, range(8)))

# Why it matters:
# - 'spawn' re-imports your module in every child, so everything the child
#   needs must be importable and behind if __name__ == '__main__'.
# - 'fork' is fast (copy-on-write memory) but can deadlock if the parent held
#   a lock in another thread. Python 3.14 stops defaulting to 'fork' on Linux.
Expected Output:
[0, 1, 4, 9, 16, 25, 36, 49]
Heads up: because spawn re-imports your module, module-level code runs again in every child, which is exactly why the if __name__ == '__main__' guard is mandatory. Python 3.14 changes the Linux default away from fork for safety, so writing spawn-compatible code today keeps you future-proof.

Pool Methods: map, imap, apply_async & chunksize

Pool offers several ways to dispatch work. Picking the right one, and tuning chunksize, has a big impact on throughput.

import multiprocessing as mp
import time

def process(n):
    # Deliberately uneven work, so imap_unordered has something to reorder
    time.sleep(0.05 * (n % 4))
    return n * n

if __name__ == '__main__':
    data = range(8)

    with mp.Pool(processes=4) as pool:
        # map(): blocks until everything is done, returns a list IN ORDER
        print("map:           ", pool.map(process, data))

        # chunksize: send fewer, bigger batches to cut IPC overhead
        print("map chunksize: ", pool.map(process, data, chunksize=4))

        # imap(): lazy iterator, still ordered - good for streaming huge inputs
        print("imap:          ", list(pool.imap(process, data)))

        # imap_unordered(): yields results AS THEY FINISH, so the order differs
        print("imap_unordered:", list(pool.imap_unordered(process, data)))

        # apply_async(): run a single call in the background, collect it later
        future = pool.apply_async(process, args=(10,))
        print("apply_async:   ", future.get(timeout=5))
Expected Output:
map:            [0, 1, 4, 9, 16, 25, 36, 49]
map chunksize:  [0, 1, 4, 9, 16, 25, 36, 49]
imap:           [0, 1, 4, 9, 16, 25, 36, 49]
imap_unordered: [0, 16, 1, 25, 4, 9, 36, 49]
apply_async:    100
MethodReturnsOrderBest for
maplist (blocks)Input orderSimple "process everything, wait" jobs
imaplazy iteratorInput orderStreaming huge inputs without buffering all results
imap_unorderedlazy iteratorAs completedGetting results ASAP when order does not matter
apply_asyncAsyncResultN/A (single call)One-off background calls; collect with .get()
chunksize is the hidden lever: each task dispatched to a worker costs a pickle + IPC round trip. For many small items, a larger chunksize batches them together and can turn a multiprocessing slowdown into a real speedup.

The Pickling Boundary

Because processes do not share memory, anything crossing the boundary, the target function, its arguments, and its return value, is serialized with pickle. This is the single most common source of confusing multiprocessing errors.

Not picklable
  • Lambdas and nested/local functions
  • Closures capturing non-picklable state
  • Open files, sockets, DB connections
  • Most locks and thread objects
Picklable
  • Top-level (module-level) functions
  • Plain data: numbers, strings, lists, dicts
  • Dataclasses / objects with picklable fields
  • Results returned as plain data
import multiprocessing as mp

# The function, its arguments, AND its return value all cross the process
# boundary - so every one of them has to be picklable.

run = lambda x: x * x  # a lambda cannot be pickled, even bound to a global name

def with_lambda():
    """Won't work: lambdas and local (nested) functions are not picklable"""
    with mp.Pool() as pool:
        return pool.map(run, range(5))

def square(x):
    """Works: top-level (module-level) functions ARE picklable"""
    return x * x

if __name__ == '__main__':
    # Run the broken version rather than just asserting it breaks. Note what
    # pickle actually complains about: it stores a function by name, and the
    # name of a lambda is "<lambda>", which it cannot look up again.
    try:
        with_lambda()
    except Exception as e:
        print(f"{type(e).__name__}: {e}")

    with mp.Pool() as pool:
        print(pool.map(square, range(5)))

# Common non-picklable culprits: lambdas, closures, open files/sockets,
# database connections, and most locks. Pass plain data, not live handles.
Expected Output:
PicklingError: Can't pickle <function <lambda> at 0x772b85120e00>: attribute lookup <lambda> on __main__ failed
[0, 1, 4, 9, 16]

Multiprocessing Best Practices

Do This
  • Use it for CPU-bound work (I/O-bound goes to threading/asyncio)
  • Guard entry points with if __name__ == '__main__'
  • Prefer Pool / ProcessPoolExecutor over manual processes
  • Tune chunksize for many small tasks
  • Pass plain, picklable data, not live handles
  • Write spawn-compatible code for portability
  • Prefer Value/Array over Manager when speed matters
  • Profile: confirm the speedup beats the overhead
Avoid This
  • Multiprocessing for I/O-bound tasks (overhead wins)
  • Spawning more processes than CPU cores for CPU work
  • Passing lambdas / closures to workers
  • Sharing large objects through Manager in hot loops
  • Forgetting the __main__ guard (spawn re-import loop)
  • Assuming shared memory, each process is isolated
  • Ignoring startup/pickle cost for tiny tasks
  • Relying on fork-specific behavior in portable code

Key Takeaways

  • Multiprocessing bypasses the GIL - true parallel execution on multiple CPU cores
  • Perfect for CPU-bound tasks - data processing, calculations, simulations
  • Use Pool for most cases - high-level interface, automatic worker management
  • Processes have separate memory - use Queue, Pipe, or shared memory for IPC
  • Always use if __name__ == '__main__' guard - prevents infinite process creation
  • Chunk your data - reduces overhead for large datasets
  • Value/Array for speed, Manager for flexibility - choose based on needs
  • Know your start method - fork (fast, Unix), spawn (safe, portable), forkserver; 3.14 moves off fork on Linux
  • Pick the right Pool method - map, imap, imap_unordered, apply_async; tune chunksize to cut IPC cost
  • Everything crossing the boundary must be picklable - no lambdas, closures, or live handles
  • Profile to verify speedup - overhead can negate benefits for small tasks

Practice Exercises

Exercise 1: Parallel Prime Finder

Create a parallel prime number finder that searches for all primes between 1 and 10,000,000. Use a process pool to divide the range into chunks. Compare performance with sequential implementation and calculate speedup.

Exercise 2: Matrix Multiplication

Implement parallel matrix multiplication for two 1000x1000 matrices using shared memory. Divide the work by rows, with each process computing a subset of rows.

Exercise 3: Log File Analyzer

Build a parallel log file analyzer that processes multiple large log files simultaneously. Each process should count error messages and extract statistics using Queue for results.

Additional Resources

  • Official Docs: docs.python.org/3/library/multiprocessing.html
  • concurrent.futures: docs.python.org/3/library/concurrent.futures.html (ProcessPoolExecutor)
  • Start methods: "Contexts and start methods" in the multiprocessing docs
  • Shared memory: multiprocessing.shared_memory for zero-copy arrays (3.8+)
  • Higher-level: Joblib and Dask for parallel data pipelines
  • Talk: "Parallel Python" - understanding when parallelism actually pays off
What's Next?

You've mastered multiprocessing! Now let's dive deep into Python's object model with metaclasses and descriptors for advanced metaprogramming.

  • Metaclasses - Control class creation and customize class behavior
  • Descriptors - Implement property access with __get__, __set__, __delete__
  • Metaprogramming - Build frameworks and DSLs with Python's object model