◈ python mapVol 2 · Ch 28/30
Volume 2 Python, from the metal up · chapter 28

28Multiprocessing & IPC — real parallelism across the boundary

In Chapter 27 we made one core do the work of thousands. A single event loop juggled suspended frames, never blocking, never idle. But every one of those frames still ran on one core. Async never touches the oldest law of the interpreter you've lived inside since Volume 1: one interpreter, one lock, one core allowed to run Python at a time. In this chapter we finally break it and buy the other seven cores back. Here's the plan. First we feel the wall — eight cores spun up, seven sitting flat while a pure-compute job crawls on the one. Then we understand exactly why the wall is there. Then we build the machine that gets past it, handing the work to a second interpreter living in a second process. And the whole way through, we keep asking the one question that governs every border in this volume: if two processes share no memory, how does a single value ever cross from one to the other? By the end we've launched real workers onto real cores. We've watched a job finish several times faster instead of mysteriously slower. And we've counted the exact toll the boundary charges at the gate. Because none of it is free: what crosses is always bytes, never objects, flattened on one side and rebuilt on the other.

iolinked · chapter 28 — the checkpoints7 steps
$ sections covered in Multiprocessing & IPC — real parallelism across the boundary
01The need: the GIL caps you to one core
02A process is its own interpreter and its own GIL
03Nothing is shared — everything crosses as bytes
04The boundary has a toll
05Pools distribute coarse work
06Shared memory avoids the copy
07The decision table

01The need: the GIL caps you to one core

Let's start with the experiment that should have worked. You've got a pure-Python function that just grinds — counting primes, hashing, anything that's all CPU and no waiting on the outside world. So you do the obvious thing. You split its range into four chunks, hand each chunk to a thread, and expect a machine with four idle cores to finish in roughly a quarter of the time. Chapter 26 sold you threads for exactly this: more workers, more throughput. Watch what actually happens when you run it.

threads_dont_help.pypython
from concurrent.futures import ThreadPoolExecutor
import time

def count_primes(lo, hi):
    total = 0
    for n in range(lo, hi):
        if n > 1 and all(n % d for d in range(2, int(n**0.5) + 1)):
            total += 1
    return total

if __name__ == "__main__":
    chunks = [(2, 150_000), (150_000, 300_000),
              (300_000, 450_000), (450_000, 600_000)]

    t = time.perf_counter()
    for c in chunks:
        count_primes(*c)
    print("serial:  ", round(time.perf_counter() - t, 2), "s")

    t = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(lambda c: count_primes(*c), chunks))
    print("4 threads:", round(time.perf_counter() - t, 2), "s")

On the machine these words were written on, the two lines print almost the same number — the four-thread version is a hair slower. Four workers, four cores, zero speedup. Before you blame the code, understand that no amount of rewriting will fix it, because the ceiling is not in your program. It is one object inside CPython called the Global Interpreter Lock.

The GIL is a single mutex — one lock — and the rule around it is brutally simple. A thread must hold the GIL to execute a single byte of Python bytecode, or to touch any object's reference count. The interpreter's engine is the big evaluation loop called _PyEval_EvalFrameDefault, which Volume 1 chapter 4 introduced. It only turns over while the calling thread owns that lock. So at any instant, across your whole process, exactly one thread is running bytecode. The others are stopped, holding their breath, waiting for the lock. Your four threads do not run side by side. They take turns.

They take fast turns, which is why threads still felt useful for I/O. Every ~5 milliseconds — the value in sys.getswitchinterval() — the running thread voluntarily drops the GIL so another can grab it. It also drops the lock the moment it makes a blocking call like socket.recv or a disk read. That is the whole trick of Chapter 26. While one thread sits blocked on I/O, it is not holding the GIL, so a second thread runs during the wait. Waiting is free to overlap. But CPU work never waits — it always wants the lock. So on a pure-compute job the four threads just pass one core back and forth, and you even pay a small tax for every hand-off. Concurrency, yes; parallelism, no.

One interpreter, one GIL — 8 threads take turnsgreen = holds the GIL, running bytecode · grey = ready but blocked · gold = baton handed off at a bytecode boundaryT1T2T3T4T5T6T7T8bytecode5ms5ms5ms5msCPU cores, this instant1 core pegged · 7 idle
Fig — Eight ready threads, but the GIL lets only one run bytecode at a time — that is concurrency, not parallelism.

Why would the language designers hobble every thread like this? Because of a promise Volume 1 made about memory. Every Python object carries a reference count, ob_refcnt, and the interpreter bumps it up (Py_INCREF) and down (Py_DECREF) constantly — every time a name binds to an object or a frame returns. That increment is not atomic. At the machine level it is read, add one, write back. Say two threads on two cores did that to the same object at the same time. One update would clobber the other, the count would drift, and the object would either be freed while still in use (a crash) or never freed (a leak). The GIL is the shortcut that makes refcounting safe: hold one lock, and no two refcount operations can ever collide. One lock buys memory safety for the entire object model, at the cost of one core.

THE ONE IDEA TO CARRY FORWARD
The GIL is per-interpreter, and — in the classic build the standard library assumes — there is one interpreter per process. So the only way to run Python bytecode on two cores at once is to have two GILs, which means two interpreters, which means two processes. That single deduction is the whole engine of this chapter.
Wait — is the GIL forever? No. Python 3.12 gave each sub-interpreter its own GIL, and 3.13 ships an experimental free-threaded build with no GIL at all, where refcounts are made thread-safe by other means. Both are the frontier, and both are opt-in. Every tool in this chapter — multiprocessing, pools, pickled pipes — exists because the default interpreter you are almost certainly running still has exactly one lock, and the stdlib is built around routing past it.

If one GIL is the ceiling, you need a second GIL, and a second GIL means a second interpreter in a second process. So the next question is the most literal one in the whole chapter: what is a process, and how does Python conjure one? →

02A process is its own interpreter and its own GIL

You have decided you need a second interpreter. But you cannot just ask CPython for one inside your program. The whole point is that this interpreter and its GIL are baked into the running process. To get a second GIL you must get a second process, and that is not a Python idea at all. It is a kernel idea, and it is worth being exact about, because the word "process" is about to carry the entire weight of this chapter.

A process is a container the operating-system kernel builds and owns. Three things live in that container. First, its own virtual address space — a private map, held in the CPU's page tables, from the addresses your program uses to the physical RAM behind them. When Volume 1 printed an object's address like 0x7f9c..., that number was only ever meaningful inside one process's map. Second, its own file-descriptor table — the small integers (0, 1, 2, ...) that name its open files and sockets, from Chapter 15. Third, its own PID, the number the kernel and os.getpid() use to refer to it. And because a process loads its own private copy of the interpreter into that address space, it gets its own eval loop and — the payoff — its own GIL. Two processes, two GILs. Now the OS scheduler is free to place them on two different physical cores. That is genuine parallel bytecode. Watch two PIDs appear:

two_pids.pypython
import os
from multiprocessing import Process

def child():
    print("child  pid", os.getpid(), "parent", os.getppid())

if __name__ == "__main__":
    print("parent pid", os.getpid())
    p = Process(target=child)
    p.start()          # kernel creates a new process here
    p.join()           # wait for it to finish

Two different PIDs print. Two processes, side by side, each with its own lock. But how the kernel made that second process is where things get interesting. It is also where a default silently flipped out from under most programmers. multiprocessing offers a start method, and the two that matter are fork and spawn.

SYNTAX · multiprocessing.Process — four lines to a second interpreterand one law you cannot skip on Windows or macOS: every launch sits under if __name__ == "__main__"
import multiprocessing as mp def job(label, n): -- a MODULE-LEVEL def. the child has to import it back. print(label, sum(range(n))) if __name__ == "__main__": -- THE GUARD. spawn re-runs this whole file in the child. p = mp.Process(target=job, args=("child", 1000)) p.start() -- the kernel makes the process HERE, not at Process(...) p.join() -- block until it exits; only now is exitcode readable mp.Process(target=f, args=(1, 2), kwargs={"k": 3}, name="w1", daemon=True) p.is_alive() p.pid p.exitcode p.name p.terminate() p.join(timeout=2.0) mp.get_start_method() -- 'spawn' on Windows/macOS, 'fork' on Linux mp.set_start_method("spawn", force=True) -- once, inside the guard, before any start()
targetThe callable the child will run. Under spawn it travels as its importable name, so it must be a module-level def — never a lambda.
args / kwargsA tuple and a dict, pickled here and rebuilt there. Everything you hand a worker arrives as a copy.
Process(...)Builds a Python object and nothing else. No process exists yet, and p.pid is still None.
.start()The syscall boundary. CreateProcess on Windows, fork or posix_spawn on Unix. The PID appears on this line.
.join()The parent blocks here until the child exits. Forget it and your program can end while a worker is mid-sentence.
.exitcodeNone while alive, 0 on a clean exit, non-zero when it raised, negative when a signal killed it.
the guardChapter 19 taught it as a courtesy. Here it is load-bearing: without it, spawn re-runs your launch code inside every child.
you type
# ---------- skeleton.py ----------
import multiprocessing as mp
import os

def job(label, n):                      # module-level def. the child re-imports it.
    print(f"  {label}: pid {os.getpid()}, sum {sum(range(n))}", flush=True)

if __name__ == "__main__":              # THE GUARD -- non-negotiable
    print(f"parent pid {os.getpid()}, start method {mp.get_start_method()}", flush=True)

    p = mp.Process(target=job, args=("child", 1000), name="counter")
    print(f"before start: alive={p.is_alive()} pid={p.pid} exitcode={p.exitcode}", flush=True)
    p.start()                           # the kernel makes the process HERE
    print(f"after  start: alive={p.is_alive()} pid={p.pid}", flush=True)
    p.join()                            # block until it finishes
    print(f"after  join : alive={p.is_alive()} exitcode={p.exitcode}", flush=True)


# ---------- no_guard.py ----------  the SAME program, guard deleted. run it.
from multiprocessing import Process
import os

def worker():
    print("  child", os.getpid(), flush=True)

print("module body", os.getpid(), flush=True)
p = Process(target=worker)      # <-- at MODULE LEVEL, outside any guard
p.start()
p.join()
you see
$ python skeleton.py
parent pid 59368, start method spawn
before start: alive=False pid=None exitcode=None
after  start: alive=True pid=61464
  child: pid 61464, sum 499500
after  join : alive=False exitcode=0

$ python no_guard.py
module body 54908
module body 55288
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  [... 12 frames, all inside multiprocessing/ and runpy ...]
  File "...\multiprocessing\spawn.py", line 140, in _check_not_importing_main
    raise RuntimeError('''
RuntimeError:
        An attempt has been made to start a new process before the
        current process has finished its bootstrapping phase.

        This probably means that you are not using fork to start your
        child processes and you have forgotten to use the proper idiom
        in the main module:

            if __name__ == '__main__':
                freeze_support()
                ...

        The "freeze_support()" line can be omitted if the program
        is not going to be frozen to produce an executable.

        To fix this issue, refer to the "Safe importing of main module"
        section in https://docs.python.org/3/library/multiprocessing.html
where beginners trip
  • module body printed twice. The parent ran the file, then the child re-imported it and ran it again.
  • That is the whole crime. The child re-ran p.start(), which would have spawned a grandchild, and so on downward.
  • Python catches it at the first re-spawn, so you get a RuntimeError and not a machine full of Python processes.
  • Nothing fails on Linux, where fork never re-imports. The same file then breaks the moment a colleague runs it on Windows.
  • Process(...) does not start anything. Between construction and start() there is no process, and pid is None.
  • I trimmed twelve traceback frames above; every one of them lives inside multiprocessing, not in your file.
  • p.terminate() kills the child outright — no finally, no cleanup. Prefer asking it to stop over shooting it.

fork() is a Unix syscall — the default on Linux — and it is almost magical in its cheapness. It clones the calling process. The child comes into existence with a byte-for-byte copy of the parent's entire address space, the same open file descriptors, the same imported modules, the same values in every global. But the kernel does not actually copy the RAM; that would be enormous. It marks every page copy-on-write: parent and child both point at the same physical pages, flagged read-only, and only when one side writes a page does the kernel duplicate that single page. A fork is therefore near-instant (~1ms) and cheap in memory until you start mutating. The child inherits a live snapshot of the parent for free.

Two ways to make a worker processfork() — clone the parentparentaddress spacechildcopy-on-writepages shared until one side writes — then copiedspawn() — fresh interpreterparentbrand-new python.exeboots empty, re-importsyour modulepickle (func, args)travels a pipeboth paths bottom out in the same thing:own page tableown GILown core
Fig — Fork clones the parent's memory as copy-on-write; spawn boots a clean interpreter and ships the work as a pickle — either way the child gets its own page table, GIL, and core.

That free snapshot is also a trap. Suppose any other thread in the parent was holding a lock at the instant of the fork — a lock inside the memory allocator, say. The child inherits that lock in its locked state, but not the thread that would have released it. The child can then deadlock the first time it touches the allocator. This is why fork plus threads is unsafe. It is also the concrete reason Apple's system libraries made fork so dangerous on macOS that Python changed the default there to spawn in 3.8. Windows never had fork at all.

spawn is the safe, portable alternative and now the default on macOS and Windows. It shares nothing. The kernel launches a brand-new python executable from scratch (via posix_spawn or Windows CreateProcess). That fresh interpreter boots up empty and re-imports your module to rebuild the target function's definition. Then the parent ships the target (function, args) across a pipe as pickled bytes — the theme of this whole volume, arriving right on cue. It is clean, isolated, and slow: booting an interpreter and re-importing takes tens of milliseconds. The re-import is also why spawn demands the if __name__ == "__main__": guard. The child re-runs your module top to bottom. If your Process(...).start() call sits unguarded at module level, every child re-runs it and spawns its own children — an infinite fork-bomb of respawns that Python detects and stops with a RuntimeError.

TYPE THIS · save it, then run itchild_worlds.py — one file, four interpreters, four separate copies of every global
# child_worlds.py -- how many times does this file's body run?
import multiprocessing as mp
import os

TALLY = ["module-body"]                      # a module-level global

print(f"MODULE BODY  pid={os.getpid():<6} __name__={__name__!r}", flush=True)


def worker(tag):
    TALLY.append(tag)                        # mutate MY OWN copy of the global
    print(f"  child      pid={os.getpid():<6} TALLY={TALLY}", flush=True)


if __name__ == "__main__":                   # only the PARENT gets past this line
    TALLY.append("parent-edit")              # a mutation made BEFORE any launch
    print(f"PARENT       pid={os.getpid():<6} TALLY={TALLY}", flush=True)
    print(f"start method: {mp.get_start_method()}\n", flush=True)

    kids = [mp.Process(target=worker, args=(f"kid-{i}",)) for i in range(3)]
    for k in kids:
        k.start()
    for k in kids:
        k.join()

    print(f"\nPARENT after join         TALLY={TALLY}", flush=True)
$ python child_worlds.py
MODULE BODY  pid=41964  __name__='__main__'
PARENT       pid=41964  TALLY=['module-body', 'parent-edit']
start method: spawn

MODULE BODY  pid=45984  __name__='__mp_main__'
  child      pid=45984  TALLY=['module-body', 'kid-0']
MODULE BODY  pid=38560  __name__='__mp_main__'
  child      pid=38560  TALLY=['module-body', 'kid-1']
MODULE BODY  pid=45060  __name__='__mp_main__'
  child      pid=45060  TALLY=['module-body', 'kid-2']

PARENT after join         TALLY=['module-body', 'parent-edit']
Save it, run it, and count the MODULE BODY lines. There are four, for three children. Under spawn each child boots a fresh interpreter and re-imports your file to rebuild worker, and re-importing means running every top-level statement again. Now read the __name__ column, because it is the whole guard in one word. The parent runs as '__main__'; every child runs as '__mp_main__'. That difference is exactly what your if __name__ == "__main__": line tests, and it is why the launch code below it fires once instead of four times. Delete the guard and you have no_guard.py from the last box. Then look at TALLY, which is the second lesson hiding in the same run. The parent appended parent-edit before any child started, and no child sees it. Each child got ['module-body'] fresh from its own import and appended its own tag. When all three finish, the parent still holds ['module-body', 'parent-edit'] — three appends happened and not one of them landed here. This is the shared-heap habit from Chapter 26 breaking. There, a thread appending to a global appended to your global, because threads share one address space. Here there are four address spaces and four unrelated lists that merely have the same name. Two things to try. Add mp.set_start_method("fork", force=True) under the guard on Linux and re-run: one MODULE BODY, and the children inherit parent-edit, because fork clones the parent instead of re-importing it. Then move the whole launch block out from under the guard on Windows and watch the RuntimeError arrive.
fork · Linux default · clones the whole address space copy-on-write · ~1ms · sees the parent's globals · unsafe alongside threads
spawn · macOS/Windows default · boots a fresh interpreter, re-imports your module, pickles the target across a pipe · tens of ms · sees nothing · needs the __main__ guard
ALWAYS GUARD YOUR ENTRY POINT
Every multiprocessing program you write should put its launch code under if __name__ == "__main__":. On fork it costs nothing; on spawn it is the difference between a working program and one that recursively respawns itself. Write it once, out of habit, and the start-method default can flip beneath you without breaking anything.

You now have a second process with a second GIL on a second core. But it came at a price the fork/spawn split already hinted at: the two processes share nothing. Your objects live in one address space; the worker lives in another. So how does a value ever get from here to there? →

03Nothing is shared — everything crosses as bytes

Here is a bug that produces no error, which makes it the worst kind. You build a list, hand it to a worker process, the worker appends to it, you wait for the worker to finish, and then you look at your list back in the parent. It is unchanged. No exception, no warning — just silence, and a result that is flatly wrong.

the_silent_bug.pypython
from multiprocessing import Process

def worker(data):
    data.append(999)          # mutate the list we were handed
    print("child sees:", data)   # [1, 2, 3, 999]

if __name__ == "__main__":
    shared = [1, 2, 3]
    p = Process(target=worker, args=(shared,))
    p.start(); p.join()
    print("parent sees:", shared)  # [1, 2, 3]  — the 999 is gone!

The child clearly saw [1, 2, 3, 999]. The parent sees [1, 2, 3]. Nobody lied and nobody crashed. The hard truth is that the worker never touched your list. It could not have. And once you see why, an entire category of multiprocessing confusion dissolves at once.

Recall from the last section that a Python object lives at a virtual address inside one process's address space. Your list shared sits at some address like 0x7f9c0e21a3c0 in the parent. That number is an index into the parent's page tables — its private map. In the child, the exact same number 0x7f9c0e21a3c0 either points at a completely different physical page or at nothing mapped at all. A pointer is meaningless across the boundary. So you cannot pass a reference to an object between processes. There is no wire on which a pointer means the same thing on both ends. This is the metal underneath Volume 1's whole model of names and objects. Names are cheap because they are just pointers into one address space. And the instant you leave that address space, the pointer is worthless.

SYNTAX · the same worker as a thread and as a process — one line changeda global mutated inside the child comes back to the parent under Thread and vanishes under Process
HITS = [] -- ONE module-level list, in this process TOTAL = 0 -- ONE module-level int, same story def bump(tag, amount): global TOTAL HITS.append(tag) -- mutation. but whose HITS? TOTAL += amount threading.Thread(target=bump, args=("thread", 100)) -- same heap. edits land. mp.Process(target=bump, args=("process", 7)) -- other heap. edits do not. # the only way a process result comes home: def bump(tag, amount): return tag, amount -- RETURN it, so the pool pickles it back with ProcessPoolExecutor() as pool: for tag, amount in pool.map(bump, jobs): -- the value crosses; the mutation never would ...
the shared heapChapter 26 in one line: threads share the module object, so HITS is literally the same list for every thread.
the private heapA child process has its own page tables. Its HITS lives at its own address and shares nothing with yours.
global TOTALStill needed, still does the same job. It rebinds the name in this interpreter’s module dict, whichever interpreter that is.
what the child starts fromUnder spawn, the freshly imported module. So the child sees HITS = [], not whatever the parent had built.
the silenceNo exception is raised, because nothing illegal happened. Two processes each edited their own list, correctly.
returnThe only honest channel out of a worker. The value is pickled home, and the parent binds a new object.
when you truly need sharingManager().list() proxies every operation over a pipe; SharedMemory maps raw bytes. Both are section 6, both cost more than returning.
you type
# two_worlds.py -- the SAME worker, run as a thread (ch 26) and as a process (ch 28)
import multiprocessing as mp
import threading

HITS = []                       # one module-level list
TOTAL = 0                       # one module-level int


def bump(tag, amount):
    global TOTAL
    HITS.append(tag)
    TOTAL += amount
    print(f"    inside {tag:<8} HITS={HITS} TOTAL={TOTAL}", flush=True)


if __name__ == "__main__":
    t = threading.Thread(target=bump, args=("thread", 100))
    t.start(); t.join()
    print(f"  parent sees          HITS={HITS} TOTAL={TOTAL}\n", flush=True)

    p = mp.Process(target=bump, args=("process", 7))
    p.start(); p.join()
    print(f"  parent sees          HITS={HITS} TOTAL={TOTAL}", flush=True)
you see
$ python two_worlds.py
    inside thread   HITS=['thread'] TOTAL=100
  parent sees          HITS=['thread'] TOTAL=100

    inside process  HITS=['process'] TOTAL=7
  parent sees          HITS=['thread'] TOTAL=100
where beginners trip
  • Read the child’s TOTAL=7, not 107. It never saw the thread’s +100, because it started from a fresh import.
  • The child’s HITS is ['process'] alone. Same name, same line of code, a different list object entirely.
  • The parent’s last line is identical to its first. Three real mutations happened in this program and one of them reached you.
  • Nothing warned you. A silent wrong answer is the signature failure of multiprocessing, and this is the shape it takes.
  • On Linux with fork the child would print ['thread', 'process'] — it inherits a snapshot. The parent still sees nothing.
  • A Queue, a return, or shared memory. Those are the three ways home, and assignment to a global is not one of them.

So multiprocessing does the only thing it can. It does not send the object; it sends the object's value, flattened into a stream of bytes, and rebuilds a brand-new object from those bytes on the far side. The machine that does the flattening is pickle. pickle.dumps(obj) walks the whole object graph — the list, the ints inside it, a dict it might point at — and emits a little bytecode program. That program is a stream of pickle opcodes (protocol 5 is the default since Python 3.8) that, when replayed by pickle.loads, reconstructs an equivalent object. Those bytes then travel through a Pipe. On Unix that is an os.pipe() or socketpair() — file descriptors with a kernel ring buffer between them — and on Windows a named pipe. A write() syscall copies the bytes into kernel space; a read() on the other end copies them out. The child unpickles them into freshly allocated objects with their own addresses and their own refcounts, entirely disconnected from yours.

SYNTAX · Queue and Pipe — the two mailboxes across the boundaryobjects go in, bytes travel, equal-but-different objects come out — and three ordinary things cannot board at all
from multiprocessing import Process, Queue, Pipe q = Queue() -- many writers, one reader. a pipe + a feeder thread + a lock. q.put(obj) -- pickles obj, hands the bytes to the feeder obj = q.get() -- BLOCKS until bytes arrive, then unpickles q.get(timeout=2.0) -- raises queue.Empty instead of waiting forever q.put(None) -- the sentinel: one per reader, then stop here, there = Pipe() -- exactly two endpoints, one for each process here.send(obj); obj = there.recv() -- duplex by default: both ends can send here.poll() -- True if something is waiting. no block. # what can board: int str bytes list dict tuple set, module-level defs and classes, # dataclasses, anything with a sane __reduce__ / __getstate__ # what cannot: lambdas, closures, open files and sockets, threading locks, generators
Queue()A pipe with staff. A background feeder thread pickles and writes, and a lock keeps concurrent writers from interleaving bytes.
put / getNot a hand-off of your object. put serialises, get deserialises, and the two objects are strangers that compare equal.
the sentinelA queue has no end-of-stream. You invent one — usually None — and put exactly one per reader.
Pipe()The raw thing underneath: two connected file descriptors. Faster than a Queue, but strictly one process per end.
blockingget() and recv() park in a kernel read. Pass timeout= if a dead worker must not hang you forever.
the 64 KB wallA pipe buffer is finite. Send a payload larger than it and your write blocks until the reader drains the far end.
picklable, preciselyA top-level function crosses as the string "mymod.worker". A lambda has no such name, so there is nothing to write down.
you type
# pipe_queue.py -- the two mailboxes: many-to-one Queue, one-to-one Pipe
import multiprocessing as mp
import pickle


def producer(q, tag):                       # runs in the CHILD
    for row in ([1, 2, 3], {"tag": tag}, "done-ish"):
        q.put({"from": tag, "payload": row})
    q.put(None)                             # the sentinel. None pickles fine.


def echo(conn):                             # runs in the CHILD
    got = conn.recv()                       # blocks until bytes arrive
    conn.send(("echoed", got, id(got)))
    conn.close()


if __name__ == "__main__":
    q = mp.Queue()                          # a pipe + a feeder thread + a lock
    p = mp.Process(target=producer, args=(q, "worker-A"))
    p.start()
    while (item := q.get()) is not None:    # blocks, unpickles, hands you an object
        print("queue  ->", item)
    p.join()

    here, there = mp.Pipe()                 # two connected endpoints
    c = mp.Process(target=echo, args=(there,))
    c.start()
    sent = [10, 20]
    here.send(sent)
    label, back, child_id = here.recv()
    c.join()
    print("pipe   ->", label, back, "| equal:", back == sent, "| same object:", id(sent) == child_id)

    for what, obj in [("lambda", lambda x: x), ("open file", open(__file__)),
                      ("closure", (lambda k: (lambda v: v + k))(3))]:
        try:
            pickle.dumps(obj); print(f"{what:<10} boarded")
        except Exception as e:
            print(f"{what:<10} {type(e).__name__}: {str(e)[:64]}")
you see
$ python pipe_queue.py
queue  -> {'from': 'worker-A', 'payload': [1, 2, 3]}
queue  -> {'from': 'worker-A', 'payload': {'tag': 'worker-A'}}
queue  -> {'from': 'worker-A', 'payload': 'done-ish'}
pipe   -> echoed [10, 20] | equal: True | same object: False
lambda     PicklingError: Can't pickle <function <lambda> at 0x0000022602175BC0>: attribut
open file  TypeError: cannot pickle 'TextIOWrapper' instances
closure    AttributeError: Can't get local object '<lambda>.<locals>.<lambda>'
where beginners trip
  • equal: True and same object: False. That pair is the whole boundary, printed by one line of code.
  • Three different exception types for three failures to pickle. Do not memorise them; recognise the family.
  • Without q.put(None) the parent’s q.get() blocks forever after the last row. A queue cannot tell you it is finished.
  • Two workers need two sentinels. One None stops one reader, and the other waits on an empty pipe.
  • q.put() returns before the bytes have gone anywhere. The feeder thread is still pickling, so a pickle error surfaces late and elsewhere.
  • Never join() a child that is still filling a queue you have not drained. The pipe fills, the child blocks, and you deadlock.
  • The id() the child reported is a number in its address space. Comparing it to yours is meaningless, which is exactly the point.
Nothing is shared — the graph is copied by valueProcess A — address spacedictlist729id = 0x7f9c..a20dumpskernel pipe buffer80 04 95 1a .. 6b 2eone flat byte ribbon — no pointersloadsProcess B — address spacedictlist729id = 0x55e1..3d0same shape, same values — new addresses, new refcounts, id() differs
Fig — Pickle flattens the pointer graph to a linear ribbon, the pipe carries the bytes, and loads rebuilds an independent, value-equal copy in the other process.

Now the silent bug is obvious. The child received a copy, rebuilt from bytes at a new address. It appended 999 to its copy. Your original never moved. And three sharper consequences follow from the same fact, each of which you will hit in real code:

Only picklable things cross. Pickle rebuilds an object by value, so it must know how to serialize it. A top-level function pickles fine — it is stored merely as its qualified name (mymodule.worker) and re-imported in the child, which is exactly why pool targets must be module-level defs. But a lambda, a local closure, an open socket, or a threading lock has no by-value representation, and pickling one raises PicklingError. Mutations do not propagate back. The child mutated its private copy; there is no return path unless you explicitly return the value so it gets pickled home. Identity is lost. id() differs across the boundary and is always fails; only value equality survives, and only if your __eq__ is defined by value.

Wait — what about that non-picklable lock buried inside a custom object? You can teach pickle to skip it. Define __getstate__ to return a dict of only the picklable fields, and __setstate__ to rebuild the rest (a fresh lock, a reopened file) on arrival. That pair is the seam between "the object" and "the bytes that describe it" — and defining it is you telling pickle exactly what part of the value is worth carrying across the boundary.
THE ONE IDEA TO CARRY FORWARD
Chapter 15's law is now literal and unavoidable: what crosses a process boundary is bytes, never objects. Every argument you pass to a worker and every result it returns is pickled on one side and rebuilt on the other. The worker is never touching your things — it is touching faithful copies reconstructed from a byte stream.

Copies rebuilt from bytes are correct, but they are not free. Flattening an object, shoving it through a kernel pipe, and inflating it again all cost real wall-clock time — and that cost is why your first parallel program can run slower than the serial one. Time to put a number on the toll. →

04The boundary has a toll

You parallelized a tight loop and it got slower. Not a little slower — noticeably, embarrassingly slower than the plain serial version you were trying to beat. This is the most common first result in all of multiprocessing. And it is not a mystery once you learn to see the two tolls you are paying at the border. Both are real wall-clock time, and both are charged per task. If the work inside a task is smaller than the tolls around it, you lose. Every time.

Toll one: startup. Getting a worker process running is not instant. Under spawn the kernel launches a fresh python executable, that interpreter boots, and then it re-imports your modules. If those modules are numpy or torch, the import alone can cost 100ms or more. Order of tens of milliseconds is typical just to have a worker ready. Under fork it is far cheaper — a copy-on-write clone runs in around a millisecond — but even that is not zero. You pay this once per process you create.

Toll two: serialization and transport. This is the one from the last section, now with a stopwatch on it. Every argument you send a worker is pickled — CPU time roughly proportional to how big and how tangled the object graph is. Then it is copied into the pipe's kernel buffer with a write() syscall, read out the other side, and unpickled into fresh objects (allocation plus graph rebuild). And it happens both ways: the arguments cross out, and the result crosses back. So the round-trip cost of one task is the pickle-and-pipe-and-unpickle of the arguments, plus the same again for the return value.

The pipe itself has a hard limit worth knowing. A Unix pipe's kernel buffer is typically 64KB. Try to push a payload bigger than that, and your write() blocks until the reader drains the far end. So a big argument does not just cost pickle time — it can stall the sender waiting on flow control. This is a direct, physical reason that shipping half a gigabyte through a pipe to a worker is a bad idea, and it foreshadows the escape hatch two sections from now.

Put the pieces together into a cost model you can actually reason with. For one task handed to a worker:

the cost modeltext
T_parallel  =  startup / N_reused                # pay once, amortized over tasks per worker
            +  (pickle + pipe + unpickle)(args)  # toll out
            +  compute / cores                   # the ONLY part you wanted
            +  (pickle + pipe + unpickle)(result)# toll back

Only one term in that sum — compute / cores — is work you actually care about. Everything else is toll. So the entire question of "will parallelism help?" reduces to one comparison: is compute bigger than the serialization terms wrapped around it? Say you send a worker the task "square this number." The compute is a single multiply — nanoseconds — while the pickle-and-pipe of the argument and the result is microseconds. You pay a thousand-fold overhead to avoid doing a multiply. Do that a hundred thousand times and you have built an extremely elaborate way to run slower than a for loop.

The toll is fixed — only big tasks pay it backspawn / importpickle args / resultpipe copyactual computeTINY taskcomputenet LOSStoll swamps the workFAT taskactual compute dominatesnet WINbreak-even: compute time ≈ total toll — only past here does a process pool pay
Fig — Spawn, pickle, and pipe cost the same per task regardless of size, so a process pool only wins when the compute segment outweighs that fixed toll.
the break-even wall — when do the other seven cores start paying?▸ drag me
10,000 tasks · 8 cores — how much compute must one task hold before parallelism actually wins? ▼ 3.1× SLOWER eight cores spun up… to finish slower than one. the toll ate the win. SERIAL — ONE CORE, A PLAIN FOR-LOOP 210 ms 8-CORE POOL — INCLUDING THE TOLL 646 ms WHERE THE POOL'S WALL-CLOCK TIME ACTUALLY GOES pool startup 49% pickle + pipe toll 46% COMPUTE 4% Below the ≈71 µs break-even: the pool is pure overhead. Drag right until COMPUTE fills the bar. model: 10,000 tasks · 8 cores · ~30 µs pickle toll/task · 0.32 s pool startup — honest order-of-magnitude
21 µs / task
Drag from 1 µs (a single multiply) up to 1 s (a heavy chunk) of compute per task. Watch the green COMPUTE band: at tiny tasks it is an invisible sliver and the pool runs the whole job dozens of times slower than one core — you built an elaborate way to lose. Only once each task carries more than the ≈71 µs break-even does compute outweigh the fixed toll and the eight cores finally pay. This is why coarse-graining is the whole game.

The fix falls straight out of the model. The tolls are fixed per task, so pay them fewer times: make each task big. Instead of a million tasks that each square one number, send a few dozen tasks that each square a huge slice of numbers. The compute per task now dwarfs the toll around it, and the compute / cores term finally dominates. This is called coarse-graining or chunking, and it is the single most important instinct in practical multiprocessing. A million tiny tasks pays the toll a million times; a handful of fat chunks pays it a handful of times.

SYNTAX · chunksize — the coarse-graining lever, measuredthe same 20,000 items: a pool 4,250× slower than a for-loop, and the same pool 6.9× faster
pool.map(fn, items) -- ProcessPoolExecutor: chunksize=1 by DEFAULT pool.map(fn, items, chunksize=2500) -- 8 pickled tasks instead of 20,000 # multiprocessing.Pool picks a chunksize for you; concurrent.futures does not Pool().map(fn, items) -- chunksize = ceil(len(items) / (4 * workers)) # a starting point when you know the length chunksize = max(1, len(items) // (workers * 4)) # the honest test, before you keep any of it t = time.perf_counter(); serial = [fn(x) for x in items]; base = time.perf_counter() - t -- if base is already small, stop here. you are done.
chunksizeHow many items ride inside one pickled task. It changes nothing about the answer and everything about the cost.
the default trapProcessPoolExecutor.map defaults to 1. Twenty thousand items become twenty thousand round trips through a pipe.
what one round trip costsMeasured here: roughly 150–250 µs of pickle, syscall, lock and unpickle. That is the floor per task, whatever the task does.
the crossoverA task pays for itself once its compute exceeds that floor. Below it, more workers means more overhead and nothing else.
the ceilingToo few, too fat chunks and the slowest one sets your wall clock while the other workers sit idle. Aim for several chunks per worker.
trivial workLook at row two: even perfectly chunked, squaring numbers stays 20× slower than the loop. Chunking cannot rescue work this small.
the only honest first stepTime the serial version. A 1 ms for-loop has no parallel version worth writing, and now you have proof rather than an opinion.
you type
# tiny_tasks.py -- the toll, with a stopwatch on it
import time
from concurrent.futures import ProcessPoolExecutor

N = 20_000

def square(x):                       # ~60 ns of work
    return x * x

def heavy(x):                        # ~160 us of work -- 2,600x fatter
    return sum(i * i for i in range(x % 50 + 950))

def bench(label, run, base=None):
    t = time.perf_counter(); run(); dt = time.perf_counter() - t
    tail = f"{base / dt:8.2f}x serial" if base else ""
    print(f"  {label:<34}{dt * 1000:9.1f} ms  {tail}", flush=True)
    return dt

if __name__ == "__main__":
    print(f"{N:,} items\n")
    for fn, name in ((square, "square  (~60 ns each)"), (heavy, "heavy   (~160 us each)")):
        print(f"{name}")
        base = bench("serial for-loop", lambda: [fn(x) for x in range(N)])
        with ProcessPoolExecutor(8) as pool:
            list(pool.map(fn, [1] * 8))                      # pay startup first
            bench("8 procs, chunksize=1", lambda: list(pool.map(fn, range(N), chunksize=1)), base)
            bench("8 procs, chunksize=2500", lambda: list(pool.map(fn, range(N), chunksize=2500)), base)
        print()
you see
$ python tiny_tasks.py
20,000 items

square  (~60 ns each)
  serial for-loop                         1.1 ms
  8 procs, chunksize=1                 4677.3 ms      0.00x serial
  8 procs, chunksize=2500                22.1 ms      0.05x serial

heavy   (~160 us each)
  serial for-loop                      3228.1 ms
  8 procs, chunksize=1                11887.7 ms      0.27x serial
  8 procs, chunksize=2500               465.0 ms      6.94x serial
where beginners trip
  • Row two is the one to remember. Eight cores, a correct answer, and 4,250× slower than a plain for.
  • Row three still loses. Chunking fixed the toll and the work was still too small to be worth a process.
  • Row five is the same mistake on real work: chunksize=1 turned a 3.2 s job into an 11.9 s one.
  • Only row six wins, and it is the same function and the same pool. The single number chunksize is the whole difference.
  • These are my numbers on a 32-thread Windows laptop, and yours will differ. The shape — loss, loss, loss, win — will not.
  • multiprocessing.Pool guesses a chunksize; ProcessPoolExecutor does not. Switching APIs silently changes your performance.
  • Do not tune this by feel. Two perf_counter calls answer it in a minute, and the answer is often “stay serial”.
fine-grained · a million one-number tasks · toll paid a million times · overhead crushes the compute · slower than serial
coarse-grained · a few fat chunks of ten thousand each · toll paid a few times · compute dominates · near-linear speedup
MEASURE THE TOLL BEFORE YOU PAY IT
Before parallelizing anything, ask two numbers: how long does one task compute for, and how big is its data? If a task computes for microseconds, no pool on earth will help — the toll is bigger than the work. Multiprocessing pays off when tasks are chunky: hundreds of milliseconds of compute each, modest data. When in doubt, time the serial version first — it is often already fast enough.

You now know the toll and the fix: pay startup rarely, make tasks fat. But hand-spawning a process per chunk and wiring pipes by hand is miserable. The standard library already assembles all of this into one reusable machine — the pool — which pays startup exactly once and hands you coarse-graining as a single argument. →

05Pools distribute coarse work

You are not going to hand-spawn a process for every chunk of work, pay the startup toll each time, and manually thread pipes between them. The standard library already built that assembly for you, and it is called a process pool. It is the shape of code you will actually deploy for CPU-bound work. And it is where every piece of this chapter finally clicks together into a tool.

The core idea is amortization, straight from the last section. A pool starts N long-lived worker processes once — N defaults to os.cpu_count() — and keeps them alive for the whole job. The startup toll is now paid N times total, not once per task. Between the parent and the workers sit two queues built on pipes, guarded by locks and semaphores: a task queue and a result queue. The parent pickles (function, args) and drops it on the task queue. Each idle worker blocks on a read of that queue. When a task arrives, one worker pulls it, unpickles it, and runs it — each worker on its own core with its own GIL. Then it pickles the return value and pushes it onto the result queue. The parent reads results off and matches each one back to the submission that produced it. That is the entire machine, and the crucial fact is the middle of it: at one instant, all N workers are genuinely running bytecode in parallel. This is the payoff the whole chapter was building toward.

A process pool: N workers, N cores, all running at onceparentprocessTASK QUEUEchunk of 2W1core · RUNW2core · RUNW3core · RUNW4core · RUNeach: own GIL padlockRESULT QUEUE
Fig — The parent feeds pickled tasks onto one queue; each worker owns a private GIL and core, so all N compute in true parallel and ship pickled results back on the return belt.

The modern front door is concurrent.futures.ProcessPoolExecutor, the same interface Chapter 26 used for threads. That is the point: you swap one class name and the work now runs on real cores instead of taking turns on one. Here is the prime-counter from section 1, finally getting its speedup:

pool_speedup.pypython
from concurrent.futures import ProcessPoolExecutor
import os, time

def count_primes(bounds):        # module-level: picklable by name
    lo, hi = bounds
    return sum(1 for n in range(lo, hi)
               if n > 1 and all(n % d for d in range(2, int(n**0.5)+1)))

if __name__ == "__main__":
    chunks = [(i, i + 75_000) for i in range(2, 600_000, 75_000)]
    t = time.perf_counter()
    with ProcessPoolExecutor() as pool:
        total = sum(pool.map(count_primes, chunks))
    print(total, "primes on", os.cpu_count(), "cores in",
          round(time.perf_counter() - t, 2), "s")

On a four-core machine that lands near four times faster than serial — the first honest speedup in the chapter. There are two ways to feed a pool, and the difference matters. submit(fn, arg) returns a Future immediately — a placeholder that gets fulfilled when its result arrives. You can hand a batch of futures to as_completed() to process results in the order they finish. map(fn, iterable, chunksize=K) instead batches K items into each pickled task. And there is your coarse-graining lever from the last section, exposed as a single argument. A well-chosen chunksize is the difference between paying the toll once per item and once per batch.

SYNTAX · ProcessPoolExecutor — the same shape as Chapter 26, on real coresone class name changes and the wall clock finally moves: threads 0.99×, processes 2.35×, same function
from concurrent.futures import ProcessPoolExecutor, as_completed def work(n): -- MODULE LEVEL. a pool cannot pickle a lambda. return heavy_pure_python(n) if __name__ == "__main__": -- still the guard. always the guard. with ProcessPoolExecutor() as pool: -- default workers = os.cpu_count() results = list(pool.map(work, jobs)) -- SUBMISSION order, blocks at the end results = list(pool.map(work, jobs, chunksize=250)) futures = {pool.submit(work, n): n for n in jobs} -- returns immediately for f in as_completed(futures): -- COMPLETION order, fastest first try: value = f.result() -- re-raises the worker's exception HERE except ValueError as e: ... ProcessPoolExecutor(max_workers=4) -- cap it below cpu_count() ProcessPoolExecutor(mp_context=mp.get_context("spawn")) -- force a start method
withChapter 15’s context manager, doing the same job. __exit__ calls shutdown(wait=True), so every worker is joined before you leave.
mapLazy in, ordered out. It hands results back in submission order even though the workers finish out of order.
submitReturns a Future now and a value later. Use it when the jobs differ, or when you want the fastest answer first.
as_completedYields futures as they land. Pair each with its input in a dict, because a bare Future does not remember what it was asked.
result()Blocks until that one task is done. If the worker raised, the exception was pickled home and is re-raised on this line.
max_workersDefaults to os.cpu_count(). More workers than cores buys you nothing on CPU work and costs you memory.
the swapChapter 26’s ThreadPoolExecutor and this differ by one identifier. Everything else in your file stays exactly as written.
you type
# pool_shapes.py -- the same four jobs, three ways
import os, time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed

def work(n):                                  # module-level: the ONLY shape a pool accepts
    if n == 3:
        raise ValueError(f"worker refused {n}")
    return sum(i * i for i in range(n * 400_000))

JOBS = [5, 6, 7, 8]

if __name__ == "__main__":
    t = time.perf_counter(); [work(n) for n in JOBS]
    print(f"serial              {time.perf_counter() - t:5.2f} s")

    with ThreadPoolExecutor(4) as tp:
        t = time.perf_counter(); list(tp.map(work, JOBS))
        print(f"ThreadPoolExecutor  {time.perf_counter() - t:5.2f} s   <- same one core")

    with ProcessPoolExecutor(4) as pp:
        list(pp.map(work, [1] * 4))
        t = time.perf_counter(); list(pp.map(work, JOBS))
        print(f"ProcessPoolExecutor {time.perf_counter() - t:5.2f} s   <- four cores")

    with ProcessPoolExecutor(4) as pp:
        futs = {pp.submit(work, n): n for n in [1, 2, 3, 4]}
        for f in as_completed(futs):
            try:
                print(f"  job {futs[f]} -> {f.result()}")
            except ValueError as e:
                print(f"  job {futs[f]} -> raised {type(e).__name__}: {e}")
you see
$ python pool_shapes.py
serial               0.73 s
ThreadPoolExecutor   0.74 s   <- same one core
ProcessPoolExecutor  0.31 s   <- four cores
  job 3 -> raised ValueError: worker refused 3
  job 1 -> 21333253333400000
  job 2 -> 170666346666800000
  job 4 -> 1365332053333600000
where beginners trip
  • The thread row is not a typo. Four threads on four cores, and the wall clock did not move by one percent.
  • 2.35×, not 4×. The jobs are unequal, the biggest one sets the finish line, and every core clocks down under load.
  • Job 3 raised in another process on another core, and the except ValueError in your parent caught it normally.
  • Job 3 also came back first, because raising is fast. as_completed orders by finishing, not by fairness.
  • Drop the if __name__ guard around any of this and the pool respawns itself. The guard is not decoration.
  • A lambda target fails at submit, not at def. The error arrives when the pickler tries, which is later than you expect.
  • Without the warm-up map, the timed line would also be paying for eight interpreters booting.

Three mechanics will bite you if you do not know them, and all three fall straight out of "everything crosses as bytes." First, the target must be picklable — a module-level def, not a lambda or a nested closure — because it is pickled by qualified name and re-imported inside the worker. Second, results can arrive out of order. map restores submission order for you, but imap_unordered (on the older API) hands them back as they finish — fine, and faster, when you do not care about order. Third, and most usefully, exceptions cross the boundary too. If a worker raises, the exception is pickled, shipped home on the result queue, and re-raised in the parent the moment you read that task's result — traceback and all. Your parent sees the failure as if it had happened locally.

TYPE THIS · save it, then run itprime_count.py — one CPU-bound function timed three ways: serial, 8 threads (Ch 26), 8 processes
# prime_count.py -- ONE cpu-bound function, three ways to run it
import os
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor


def count_primes(bounds):                    # module-level def -> picklable BY NAME
    lo, hi = bounds
    total = 0
    for n in range(lo, hi):
        if n > 1 and all(n % d for d in range(2, int(n**0.5) + 1)):
            total += 1
    return total


CHUNKS = [(i, i + 75_000) for i in range(2, 600_002, 75_000)]   # 8 fat chunks


def timed(label, run):
    t = time.perf_counter()
    total = run()
    dt = time.perf_counter() - t
    print(f"  {label:<20} {total} primes   {dt:5.2f} s")
    return dt


if __name__ == "__main__":                   # THE GUARD -- spawn re-imports this file
    print(f"{os.cpu_count()} cores, 8 chunks of 75,000, 8 workers\n")

    serial = timed("serial", lambda: sum(count_primes(c) for c in CHUNKS))

    with ThreadPoolExecutor(8) as tp:
        threads = timed("8 threads", lambda: sum(tp.map(count_primes, CHUNKS)))

    with ProcessPoolExecutor(8) as pp:
        list(pp.map(count_primes, [(2, 3)] * 8))          # pay the spawn toll first
        procs = timed("8 processes", lambda: sum(pp.map(count_primes, CHUNKS)))

    print(f"\n  threads   {serial / threads:4.2f}x serial   <- ch 26: no cores gained")
    print(f"  processes {serial / procs:4.2f}x serial   <- ch 28: real cores")
$ python prime_count.py
32 cores, 8 chunks of 75,000, 8 workers

  serial               49098 primes    1.79 s
  8 threads            49098 primes    3.65 s
  8 processes          49098 primes    0.67 s

  threads   0.49x serial   <- ch 26: no cores gained
  processes 2.68x serial   <- ch 28: real cores
Three runs of one function, and the middle row is the one worth sitting with. Eight threads made this program twice as slow as writing no threads at all. Not equal — slower. That is Chapter 26’s ceiling with a price tag attached: the eight threads never overlapped, because counting primes wants the GIL on every bytecode, and on top of not helping they paid for a lock hand-off every five milliseconds. Concurrency you did not need is not free. The last row changes one identifier and buys 2.68×. Same function, same chunks, same file — but now there are eight interpreters, eight GILs, and the OS scheduler is free to put them on eight cores. Now the honest part, because 2.68× is not 8× and I want you to know exactly where the other five went. Print the per-chunk serial times and you will see 0.14 0.19 0.25 0.31 0.38 0.43 0.47 0.51 seconds. Trial division gets slower as numbers get bigger, so chunk eight costs nearly four times chunk one. Eight workers start together and the pool finishes when the slowest one does, so five workers sit idle at the end. The rest goes to physics: a core running alone boosts its clock, and eight busy cores each run slower than that one did. Your numbers will differ from mine and the ratios will move; the ordering will not. Two things to try. Split into 32 chunks instead of 8 so the pool can even the load out, and watch the ratio climb. Then set CHUNKS = [(i, i + 100) for i in range(2, 600_002, 100)] — 6,000 tiny tasks — and watch the process row collapse below the serial one. Same cores, same work, all of it eaten at the gate.
futures_and_errors.pypython
from concurrent.futures import ProcessPoolExecutor, as_completed

def risky(n):
    if n == 3: raise ValueError("worker hated 3")
    return n * n

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        futures = [pool.submit(risky, n) for n in range(6)]
        for f in as_completed(futures):
            try:
                print("got", f.result())   # re-raises the worker's exception here
            except ValueError as e:
                print("worker failed:", e)
THE ONE IDEA TO CARRY FORWARD
A pool is the whole chapter in one object: N interpreters, N GILs, N cores, started once and fed coarse-grained tasks through pickled pipes. Swap ThreadPoolExecutor for ProcessPoolExecutor and the exact same code stops taking turns and starts running in parallel — as long as the tasks are fat enough to outweigh the toll.

Pools are the answer for chunky compute over modest data. But there is one case they handle badly: when the data is enormous. Pickling and piping a 500MB array into every worker costs more than the computation. For that, you need the workers to touch the same bytes without ever copying them. →

06Shared memory avoids the copy

Your workers need to crunch a 500-megabyte array. Under the pool model from the last section, every worker gets its own pickled copy. That is half a gigabyte serialized, shoved through a 64KB pipe in thousands of blocking chunks, and rebuilt in the worker — and then the results pickled all the way back. The toll now dwarfs the compute completely. You measured it in section 4 and the model is merciless: when the data term is huge and the compute term is not, serialization is your program. You need a way for two processes to touch the exact same bytes without a single copy crossing a pipe.

The kernel can do exactly this, and it is one of the quietly beautiful tricks in operating systems. Recall that a process's address space is a map from virtual addresses to physical RAM. Nothing stops the kernel from mapping one physical region into two different maps at once. Both processes then have a valid virtual address — likely a different number in each — pointing at the identical physical pages. A write by one process changes the DRAM; the other process, whose map points at that same DRAM, sees the change instantly. No pickle, no pipe, no copy. It is not a message; it is the same memory wearing two names.

multiprocessing.shared_memory.SharedMemory is the Python door to this. Under the hood it is POSIX shm_open plus mmap on Unix, or a named file-mapping on Windows. You allocate a block once, it gets a short string name, and any process that opens that name gets the same physical pages mapped into its own space. The idiomatic move — and the reason this exists — is to lay a NumPy array over the block with np.ndarray(shape, dtype, buffer=shm.buf), so workers read and write scientific data at raw memory speed. And here is the elegant part. The only thing that crosses the process boundary is the tiny name string. The gigabyte never travels. You pickle-and-pipe a dozen bytes and the workers attach to the real data locally.

shared_array.pypython
from multiprocessing import Process
from multiprocessing.shared_memory import SharedMemory
import numpy as np

def square_in_place(name, shape):
    shm = SharedMemory(name=name)            # attach to the SAME physical pages
    arr = np.ndarray(shape, dtype=np.float64, buffer=shm.buf)
    arr[:] = arr * arr                          # write straight into shared RAM
    shm.close()                              # unmap our view; do NOT unlink

if __name__ == "__main__":
    data = np.arange(8, dtype=np.float64)
    shm = SharedMemory(create=True, size=data.nbytes)
    buf = np.ndarray(data.shape, dtype=np.float64, buffer=shm.buf)
    buf[:] = data                               # load once into shared RAM

    p = Process(target=square_in_place, args=(shm.name, data.shape))
    p.start(); p.join()

    print(buf)          # [0. 1. 4. 9. 16. 25. 36. 49.] — no return value crossed
    shm.close()
    shm.unlink()         # exactly ONE process must free the segment

The worker got only a name and a shape. It squared every element, and the parent read the mutated array back with no return value crossing the boundary at all. The mutation was in the shared DRAM the whole time. Compare this to the silent-bug section: there, a mutation vanished because the child had a private copy. Here it persists because there is no copy — both processes are literally writing the same physical bytes.

Shared memory: one physical page, two viewsProcess Avaddr 0x7f..1000writes 42Process Bvaddr 0x55..e000sees 42 instantlyone physical RAM page42mmapmmappipe carries only: "psm_ab12"no pickle ribbon, no copy — just the tiny name string travels the pipe
Fig — Both processes map the same physical page at different virtual addresses, so a write lands in the other's view with zero copying — only the segment's name is ever pipe-passed.

Power like this comes with three sharp edges, and you must know all of them. First, shared memory is unstructured bytes. No Python objects can live there. A Python object contains pointers into its own address space, which are meaningless in the other process — exactly the problem from section 3. All that can be shared is a raw buffer you interpret with a dtype or a struct format. Second, you own the lifecycle. One process must close() its view, and exactly one must unlink() to release the segment. Forget it and you leak a block of RAM that outlives your program, and Python's resource_tracker will print a warning scolding you for it. Third, and most dangerous, there is no GIL guarding these bytes. The GIL only serializes access within one interpreter; across two processes it does nothing. Two workers writing the same element race at the hardware level, and updates get lost silently.

You fix the race the classic way: a lock. multiprocessing.Lock is itself a shared OS semaphore — a small piece of kernel state both processes can see — and wrapping each write in it serializes access. Or, better when you can arrange it, partition the array so each worker owns a disjoint slice and no two ever touch the same bytes. Then you need no lock at all, and the workers run fully parallel with zero coordination. Both are valid; partitioning is faster when the problem allows it.

NOW WRITE IT YOURSELFpart A: take one serial loop to eight cores — part B: three things that cannot cross, and why each one fails
Part A — parallelize this, and prove it. Here is the loop you are given. Save it as collatz_serial.py. steps(n) counts how many halvings and 3n+1s take n down to 1: while n != 1: n = n // 2 if n % 2 == 0 else 3 * n + 1, incrementing a counter each time. Under a guard, find max(range(1, 400_001), key=steps) and print the winner with its step count and the wall time. Run it and write the number down. Now write collatz_parallel.py and get the identical answer on eight cores. Three rules, and they are the whole chapter: the function a worker runs must be a module-level def; a task must carry a slice of the range, never a single number; and the launch must sit under if __name__ == "__main__":. Write a second module-level function best_in((lo, hi)) that returns (steps(winner), winner) for one slice, cut the range into 16 chunks of 25,000, and take the max of what pool.map gives back. Two questions before you look at my version. Why does best_in return the step count first in the tuple? And what exactly would break if you wrote pool.map(steps, range(1, 400_001)) instead — predict it, then measure it, because the answer is worse than you think. Part B — spot the unpicklable. Three objects below are handed to Process(target=..., args=(obj,)). Each one fails. For each, name the exception type you expect and, more importantly, say what pickle would have had to write down to make it work. One: f = lambda row: row[2]. Two: handle = open("data.csv"). Three: def make_adder(k): return lambda v: v + k, then add3 = make_adder(3). Then answer the question that ties them together: a plain def sort_key(row): return row[2] at module level pickles perfectly and does the same job as object one. What does pickle store for it, and why does that trick fail for all three above? One hint and no more: print sort_key.__qualname__ and f.__qualname__ and compare them.
show the solution
# ============ PART A ============

# ---------- collatz_serial.py -- the loop you were given ----------
import time


def steps(n):
    count = 0
    while n != 1:
        n = n // 2 if n % 2 == 0 else 3 * n + 1
        count += 1
    return count


if __name__ == "__main__":
    t = time.perf_counter()
    best = max(range(1, 400_001), key=steps)
    print(f"longest chain below 400,000: {best} ({steps(best)} steps)")
    print(f"serial: {time.perf_counter() - t:.2f} s")


$ python collatz_serial.py
longest chain below 400,000: 230631 (442 steps)
serial: 2.87 s


# ---------- collatz_parallel.py -- the same answer, on every core ----------
import time
from concurrent.futures import ProcessPoolExecutor

LIMIT = 400_001


def steps(n):
    count = 0
    while n != 1:
        n = n // 2 if n % 2 == 0 else 3 * n + 1
        count += 1
    return count


def best_in(bounds):                      # ONE fat task: a whole slice, not one number
    lo, hi = bounds
    winner = max(range(lo, hi), key=steps)
    return steps(winner), winner          # (length, n) -- length FIRST, on purpose


if __name__ == "__main__":
    t = time.perf_counter()
    serial = max(range(1, LIMIT), key=steps)
    st = time.perf_counter() - t
    print(f"serial      : {serial} ({steps(serial)} steps)  {st:5.2f} s")

    chunks = [(lo, min(lo + 25_000, LIMIT)) for lo in range(1, LIMIT, 25_000)]
    with ProcessPoolExecutor(8) as pool:
        list(pool.map(best_in, [(1, 2)] * 8))          # pay the spawn toll first
        t = time.perf_counter()
        length, winner = max(pool.map(best_in, chunks))
        pt = time.perf_counter() - t
    print(f"8 processes : {winner} ({length} steps)  {pt:5.2f} s   {st / pt:.1f}x")
    print(f"tasks: {len(chunks)} chunks of 25,000  (not {LIMIT - 1:,} chunks of 1)")


$ python collatz_parallel.py
serial      : 230631 (442 steps)   2.89 s
8 processes : 230631 (442 steps)   0.90 s   3.2x
tasks: 16 chunks of 25,000  (not 400,000 chunks of 1)


# Q1: why (length, n) and not (n, length)?
#     Because max() on tuples compares element 0 first. Putting the step count
#     there makes a plain max() over the partial results the correct combine
#     step -- no key=, no lambda (which could not cross anyway).
#
# Q2: what breaks with pool.map(steps, range(1, 400_001))?
#     Nothing breaks. It returns the right numbers. It is just catastrophic:
#     400,000 tasks, each one pickle + pipe + syscall + unpickle around a few
#     microseconds of arithmetic. Measured on 100,000 numbers, one per task:


# ---------- collatz_naive.py ----------
import time
from concurrent.futures import ProcessPoolExecutor
from collatz_parallel import steps

if __name__ == "__main__":
    N = 100_000
    t = time.perf_counter(); [steps(n) for n in range(1, N + 1)]
    print(f"serial, {N:,} numbers          {time.perf_counter() - t:6.2f} s", flush=True)
    with ProcessPoolExecutor(8) as pool:
        list(pool.map(steps, [2] * 8))
        t = time.perf_counter(); list(pool.map(steps, range(1, N + 1), chunksize=1))
        print(f"8 procs, one number per task {time.perf_counter() - t:6.2f} s", flush=True)


$ python collatz_naive.py
serial, 100,000 numbers            0.63 s
8 procs, one number per task     208.60 s

#     0.63 s -> 208.60 s. Eight cores, 331x SLOWER, same answer. The fix is one
#     keyword: chunksize=12_500. The lesson is not the keyword, it is that the
#     toll is per task, so you choose how many times to pay it.


# ============ PART B ============

# 1. f = lambda row: row[2]
#    _pickle.PicklingError: Can't pickle <function <lambda> at 0x...>:
#    attribute lookup <lambda> on __main__ failed
#    Pickle stores a function as the pair (module, qualified name) and re-imports
#    it in the child. Every lambda is named "<lambda>", so the name it writes
#    down finds nothing when the child looks it up.
#
# 2. handle = open("data.csv")
#    TypeError: cannot pickle 'TextIOWrapper' instances
#    A file object is a small integer -- a file descriptor -- plus buffers, plus
#    a position, all owned by THIS process's descriptor table (chapter 15).
#    Descriptor 7 in the child is some other file, or nothing. There is no
#    by-value form of "the kernel's handle on my open file". Send the PATH and
#    let the child open it.
#
# 3. add3 = make_adder(3)
#    AttributeError: Can't get local object 'make_adder.<locals>.<lambda>'
#    The qualname says it exactly: make_adder.<locals>.<lambda>. It is a local
#    object, unreachable by import, and it also carries a closure cell holding
#    k=3. Pickle can name module-level things; it cannot name things that exist
#    only inside a call that already returned.
#
# The tie-together:
#    def sort_key(row): return row[2]
#    sort_key.__qualname__  ->  'sort_key'          <- importable. crosses.
#    f.__qualname__         ->  '<lambda>'          <- not importable. does not.
#    Pickle never serialises a function's code. It writes down where to FIND the
#    function, and the child re-imports your module to get it. That is why the
#    rule "pool targets must be module-level defs" is not style advice -- it is
#    the only thing the mechanism can express.
#
#    Fixes: replace the lambda with a def; use functools.partial(f, 3), which
#    pickles because both the function and the argument pickle; or send the
#    filename instead of the file handle.
SHARED MEMORY IS A LAST RESORT, NOT A DEFAULT
Reach for it only when profiling shows serialization — not compute — is the bottleneck, which means the data is genuinely large. For small arguments, plain pickling through a pool is simpler, safer, and fast enough. Shared memory hands you back the manual lifecycle management and the race conditions the GIL had been quietly saving you from since Volume 1. Use it deliberately.

You have now met all three tools — threads for waiting (Ch 26), async for waiting at massive scale (Ch 27), and processes for real parallel compute (this chapter). Faced with a real workload, how do you pick? The answer is not taste. It is a single causal question. →

07The decision table

Three chapters, three tools, and now a real workload in front of you. The temptation is to choose by vibe — "processes sound powerful, I'll use those." Resist it. The right choice falls out mechanically from a single question you have already been circling, and it is causal, not a lookup table you memorize. The question, from Chapter 26, is this: while this task waits, is the CPU doing work — or just waiting? And when it waits, a second question: how many things wait at once? Every branch below is a direct consequence of one fact you now understand cold — who holds the GIL, and when it is released.

Branch one: the task blocks on I/O, and you have moderate concurrency — dozens to low thousands. Use threads. Here is the mechanism, and it is the reason threads work at all despite section 1: the GIL is released during blocking syscalls. When a thread calls socket.recv or reads a disk, CPython drops the lock before descending into the kernel and reacquires it on return. So while one thread sits parked in a read(), it holds nothing, and another thread runs. The waiting overlaps for free, the OS scheduler preempts for you, and you write ordinary blocking code. The cost is that each thread carries a stack — often a megabyte or more — plus lock-contention overhead. So you top out in the hundreds to low thousands before memory and scheduling overhead bite.

Branch two: the task blocks on I/O, but you have tens of thousands of them at once. Use async. Thirty thousand threads means thirty gigabytes of stacks — a non-starter. Chapter 27's event loop replaces them with one thread sitting on an OS readiness primitive — epoll on Linux, kqueue on BSD/macOS, IOCP on Windows — that can watch tens of thousands of sockets and report which are ready. Each waiting connection costs only a small coroutine object, not a thread stack. But the discipline is absolute. The loop is one thread, so a single blocking call or one CPU-heavy coroutine freezes everything, and every library in the path must be async-aware. It is the highest-scale I/O tool and the least forgiving.

Branch three: the task keeps the CPU busy — it computes, it does not wait. Use processes. This is the whole chapter. CPU work always wants the GIL, so threads serialize it onto one core, and async cannot help at all — there is no I/O wait to hide behind. The only way to run Python bytecode on N cores is N interpreters, which is N processes, each with its own GIL. You pay the startup and serialization toll from section 4, you coarse-grain to amortize it, and you reach for shared memory when the data is large. A pool is the standard vehicle.

SYNTAX · the three-way rule — eight jobs, two flavours, three toolseach tool re-timed against a serial baseline taken the same second: on CPU work only processes move, and they move 7.7×
# the one question. everything else is a consequence. # while this task runs, is the CPU BUSY, or WAITING? WAITING, a few hundred at once -> threads ch 26. GIL released in the syscall. WAITING, tens of thousands -> asyncio ch 27. one epoll loop, tiny frames. BUSY (pure Python compute) -> processes ch 28. one GIL per core. BUSY (numpy / torch / a C ext) -> measure first it may already drop the GIL # and the two mixed cases, both of which keep the loop honest await loop.run_in_executor(ProcessPoolExecutor(), crunch, n) -- CPU inside asyncio await asyncio.to_thread(blocking_library_call, arg) -- no async version exists # the three vetoes for processes, in order of how often they bite tasks too small -> the toll wins. section 4 measured it: 4,300x slower. data too big -> pickling a 500 MB array costs more than the maths. section 6. state to be shared -> nothing is shared. every mutation dies in the child.
the axisBusy or waiting. Not “fast or slow”, not “big or small” — who owns the CPU while the task is in flight.
waiting workAll three tools tie at eight waiters: 7.9×, 7.6×, 7.8×. Pick the one whose code you would rather read next year.
busy workThreads 1.0×, async 1.0×, processes 7.7×. Two of those rows are Chapters 26 and 27 telling you the truth about themselves.
why async cannot helpThere is no await point in a tight loop. Nothing yields, so the one loop thread computes exactly as a plain function would.
the size vetoCPU-bound is necessary, not sufficient. Section 4’s toll is charged per task, so tiny tasks lose no matter how many cores you own.
the sharing vetoIf workers must see each other’s edits, processes fight you. Section 3 showed why, and shared memory is the price of the exception.
the C-extension caveatA numpy matrix multiply already releases the GIL and already uses several cores. Threads may be enough; measure before you spawn.
you type
# decide.py -- eight jobs, two flavours, three tools. one table settles it.
import asyncio, time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

def waiting(n):                       # I/O-shaped: the CPU is idle the whole time
    time.sleep(0.25)
    return n

def working(n):                       # CPU-shaped: the CPU is pinned the whole time
    return sum(i * i for i in range(3_000_000))

JOBS = list(range(8))

async def gathered(fn):
    if fn is waiting:
        await asyncio.gather(*(asyncio.sleep(0.25) for _ in JOBS))
    else:
        for n in JOBS:                # CPU work has no await point to yield at
            fn(n)

def clock(run):
    t = time.perf_counter(); run(); return time.perf_counter() - t

def row(fn, label, run):
    base = clock(lambda: [fn(n) for n in JOBS])       # re-time serial RIGHT NOW
    dt = clock(run)
    print(f"    {label:<14}{base:6.2f}s{dt:9.2f}s{base / dt:8.1f}x", flush=True)

if __name__ == "__main__":
    for fn, title in ((waiting, "WAITING  8 x sleep(0.25)"),
                      (working, "WORKING  8 x 3M multiplies")):
        print(f"{title:<30}{'serial':>10}{'tool':>9}{'gain':>9}")
        with ThreadPoolExecutor(8) as tp:
            row(fn, "8 threads", lambda: list(tp.map(fn, JOBS)))
        row(fn, "async gather", lambda: asyncio.run(gathered(fn)))
        with ProcessPoolExecutor(8) as pp:
            list(pp.map(fn, JOBS))                    # pay the spawn toll first
            row(fn, "8 processes", lambda: list(pp.map(fn, JOBS)))
        print()
you see
$ python decide.py
WAITING  8 x sleep(0.25)          serial     tool     gain
    8 threads       2.00s     0.25s     7.9x
    async gather    2.00s     0.26s     7.6x
    8 processes     2.00s     0.26s     7.8x

WORKING  8 x 3M multiplies        serial     tool     gain
    8 threads       6.27s     6.15s     1.0x
    async gather    6.22s     5.97s     1.0x
    8 processes     6.03s     0.78s     7.7x
where beginners trip
  • The top block is a three-way tie. Threads and async both win on waiting work, so the choice there is scale and taste.
  • Processes also win the waiting block — and are the worst tool for it. Eight interpreters to run eight sleep calls.
  • The two 1.0x rows are the honest ones. Chapter 26 and Chapter 27 are telling you they cannot help here.
  • Each row re-times serial immediately before its tool. Without that, a laptop that throttles under load quietly invents speedup.
  • My serial baseline moved from 1.6 s cold to 6.0 s warm on this machine. Benchmarks drift; that is why the baseline sits inside the loop.
  • The gathered branch for CPU work has no await in it, and there is nothing to add. That absence is the result.
  • Nothing here says processes are best. It says they are the only tool that moves the CPU row, which is a much narrower claim.
Which tool? Follow the workIs the CPU busy, or waiting,during this task?WAITING (I/O)BUSY (compute)How many concurrent waits?hundredstens of thousandsTHREADSGIL released on I/Ocost: ~8 MB stack eachASYNCepoll event loopcost: all-async-or-bustPROCESSESseparate GIL per corecost: pickle + spawn tollHybrids:async + process pool → CPU work inside an event loopasync + thread pool → call a blocking library without stalling the loop
Fig — Waiting work scales with threads (hundreds) or async (tens of thousands); busy work needs processes for a GIL per core — and the two hybrids bridge them.

Real programs are rarely one pure kind, and the framework anticipates the mixes. Two hybrids are worth committing to memory, and both keep the event loop honest:

hybrids.pypython
import asyncio
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

def crunch(n):              # CPU-bound — would freeze the loop if run inline
    return sum(i*i for i in range(n))

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        # offload CPU work to a real core; the loop stays responsive
        result = await loop.run_in_executor(pool, crunch, 10_000_000)
    print("crunched:", result)

asyncio.run(main())

When CPU work shows up inside an async application, do not run it inline — it would stall the loop and every other coroutine with it. Hand it to a ProcessPoolExecutor via loop.run_in_executor, and the loop keeps servicing its other sockets while a separate core grinds. Symmetrically, when you must call a blocking library that has no async version, bridge it through a ThreadPoolExecutor the same way. The blocking call parks in a thread (dropping the GIL), and the loop stays free. The event loop stays single-threaded and responsive; the heavy or blocking work happens elsewhere and reports back through an awaitable.

NOW WRITE IT YOURSELFfour real workloads — threads, processes, or async? name the tool, name the mechanism, then name what would break
No code for this one, or almost none. For each scenario below write three lines: the tool you would reach for, the mechanism that makes it the right answer, and what specifically goes wrong with the other two. Mechanism means the GIL — who holds it, and when it is released. If your reason is “processes are faster” you have not answered. Scenario one. A CLI downloads 40 URLs with requests, each taking about 300 ms of network wait, then writes them to disk. Scenario two. A service resizes 2,000 uploaded JPEGs, roughly 80 ms of pure-Python pixel work each, and returns the total byte count. Scenario three. A chat server holds 30,000 open WebSocket connections, almost all idle, each waking a few times a minute to relay a short message. Scenario four. A dashboard must apply one numeric transform to a 900 MB NumPy array, then hand the result to a plotting call — and the transform takes 4 seconds. Now three questions that cut across all four. Which scenario changes its answer if the per-item work drops to 200 microseconds, and why? Which one changes its answer if the library involved releases the GIL internally? And in scenario two, if each worker also has to append its result to a shared running total, what is the simplest correct fix — and why is it not multiprocessing.Lock? Write your answers before opening mine; the point is the reasoning, not the label.
show the solution
# ---------- SCENARIO 1: 40 URLs, ~300 ms of network wait each ----------
# TOOL       threads -- ThreadPoolExecutor(max_workers=16)
# MECHANISM  CPython releases the GIL before descending into the kernel for
#            socket.recv, and reacquires it on return. So 16 threads park in
#            16 blocking reads at once and the waits overlap. 40 x 300 ms of
#            waiting collapses toward 300 ms x ceil(40/16).
# NOT async  It works and scales further, but requests is a blocking library.
#            You would have to rewrite against httpx/aiohttp for 40 URLs.
# NOT procs  Eight interpreters booting to run code that does nothing but wait.
#            You pay spawn + pickle to buy zero cores you were not using.


# ---------- SCENARIO 2: 2,000 JPEGs, ~80 ms of pure-Python pixel work ----------
# TOOL       processes -- ProcessPoolExecutor(), map(..., chunksize=~30)
# MECHANISM  Pixel loops in Python need the GIL on every bytecode, so threads
#            serialise onto one core. N processes means N interpreters, N GILs,
#            N cores. 80 ms per item is far above the ~200 us per-task toll, so
#            even chunksize=1 would win; chunking just removes the last of it.
# NOT threads  The flat line from chapter 26: 8 workers, same wall clock.
# NOT async    Nothing to await. One loop thread computing is a plain for-loop.
# CAVEAT     If the resize is actually Pillow doing the work in C, Pillow may
#            release the GIL itself -- then threads are enough. Measure first.


# ---------- SCENARIO 3: 30,000 idle WebSocket connections ----------
# TOOL       asyncio
# MECHANISM  Each waiting connection is a suspended coroutine -- a frame on the
#            heap, a few hundred bytes -- not an OS thread with a stack. One
#            epoll/IOCP call reports which of the 30,000 sockets are ready, in
#            time that does not grow with the count.
# NOT threads  30,000 thread stacks is tens of gigabytes reserved and a
#            scheduler asked to manage 30,000 runnable entities. It falls over
#            long before the network does.
# NOT procs  Same objection, worse: 30,000 interpreters.


# ---------- SCENARIO 4: one 4 s transform over a 900 MB NumPy array ----------
# TOOL       neither, at first -- stay in one process and let NumPy do it.
# MECHANISM  A NumPy ufunc drops the GIL and runs a C loop; some builds thread
#            it internally. There is no Python bytecode in the hot path, so the
#            GIL is not the ceiling and there is nothing for a pool to fix.
# IF you must parallelise it: multiprocessing.shared_memory, and pass the
#            NAME. Pickling 900 MB into each worker costs more than the 4 s of
#            maths -- and a 64 KB pipe would carry it in ~14,000 blocking
#            writes. Section 6 exists for exactly this case.
# NOT a plain pool  pool.map(fn, big_array) pickles the array per task.


# ---------- THE THREE CROSS-CUTTING QUESTIONS ----------
# Q1  Which flips at 200 us per item?  Scenario 2.
#     At 80 ms the toll is 0.25% of the work. At 200 us it is comparable to the
#     work, so 2,000 tiny tasks lose to a for-loop -- section 4, measured. The
#     fix is chunking, not more cores: chunksize=100 makes each TASK 20 ms
#     again. Scenarios 1 and 3 do not flip; they are waiting, not computing.
#
# Q2  Which flips if the library releases the GIL?  Scenario 2 (and 4).
#     If the resize is C code that drops the lock, threads run it truly in
#     parallel with no pickling and no spawn -- strictly better than processes.
#     This is the one case where "CPU-bound" does not imply "use processes".
#
# Q3  Shared running total in scenario 2 -- simplest correct fix?
#     RETURN the number and sum in the parent:
#         total = sum(pool.map(resize_one, paths, chunksize=30))
#     Not multiprocessing.Lock, for two reasons. First, a lock protects a shared
#     object and there is no shared object -- each worker's global is its own,
#     so the lock would guard four unrelated integers (section 3). You would
#     need Value/SharedMemory as well, and then the lock. Second, even done
#     correctly it serialises the workers at exactly the moment you paid to run
#     them in parallel. Returning costs one small pickle per task and the
#     parent's sum is free. Reach for a lock only when the shared thing is real
#     and the sharing is unavoidable.
THE ONE IDEA TO CARRY FORWARD
The choice is causal, not a memorized table. Ask "is the CPU working or waiting?" Waiting, and few → threads (GIL released on I/O). Waiting, and tens of thousands → async (one epoll loop, tiny per-connection state). Working → processes (a separate GIL per core). Every row earns its place from one fact: who holds the GIL, and when it is released.
Wait — what happened to the free-threaded build from section 1? If the GIL truly goes away, branch three changes: threads would run CPU-bound bytecode on all cores, and processes lose their monopoly on parallelism. That future is being built right now. But every decision in this table assumes the interpreter almost everyone still ships — one GIL per process — and until the no-GIL build is the default, the table stands exactly as written.

You have crossed every boundary this volume set out to cross: the disk, other encodings, other people's code, future-you, and now other cores and other processes — each one governed by the same law, that only bytes cross a boundary, never objects. The machine that flattens objects to a stream and rebuilds them faithfully is the through-line of the whole volume, and you have now seen its most demanding form: the pickle pipe that lets eight cores finally think at once. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 28, in working code

Chapter 28 escapes the GIL by running real OS processes — but a process owns its memory, so nothing is shared for free. These steppers can't spawn real workers deterministically, so each is an honest, runnable model of one mechanism. Where it matters, pickle IS the wire between processes (that part is real); the pools, futures, PIDs, and shared-memory blocks are small exact simulators, labeled as such. Watch a value get flattened to bytes, copied, mutated on the far side, and shipped back.

Interleaving is not parallelism
One CPU taking turns is concurrency; a second process is what buys a second core and a second memory. First feel the turn-taking, then see what a lock actually protects, and why everything inside one process is shared.
The boundary is a pickle
Processes share no memory, so every value that crosses is flattened to bytes and rebuilt on the far side. That copy is the whole game — its cost, its rules, and the things that simply cannot make the trip. Here pickle is the real wire, not a simulation.
Two processes, two memories
fork clones the parent's live memory; spawn starts a blank interpreter that re-imports your module. That single difference decides what a child can see — and why the __main__ guard exists. These model the two start methods with plain functions.
Pools, futures, and shared memory
Real programs hand work to a pool and collect results as they finish. These model the moving parts — chunking to amortize the boundary, futures that return out of order, exceptions that ride home, and shared memory that skips the copy entirely. The pool and futures are simulators; the pickle in the exception demo is real.
end of chapter 28 · seven sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked