Python Lesson 31 of 71

Concurrency: Threading vs Multiprocessing and the GIL

You have eight cores. Your Python program uses one. So you reach for threads — the thing every other language uses to spread work across cores — you split the job across eight of them, you run it, and it comes back exactly as slow as before. Sometimes slower. Nothing crashed, nothing errored, the CPU meter shows one core pinned at 100% and seven asleep. You did everything right and got nothing.

This is the single most confusing thing about Python, and it has one cause with a three-letter name: the GIL. Understand it and the whole picture snaps into focus — you will know, before you write a line, whether threads will help, whether you need processes instead, and why “just add more threads” is sometimes the exact wrong move.

Here is the one sentence the rest of the lesson unpacks and then measures: threads give you no speedup for CPU-bound work, but a huge speedup for I/O-bound work; processes give you real multi-core parallelism for CPU-bound work, at the cost of copying everything. Every number below is real, measured on the machine writing this lesson (an 8-CPU laptop — 4 fast “performance” cores plus 4 “efficiency” cores — running Python 3.12.3). Your numbers will differ; the ratios are the lesson.


Why this matters

Concurrency is where competent Python programmers write their most confident wrong code. The APIs are friendly, the examples in the docs run fine, and the failure mode is not a crash — it is a program that is silently the wrong speed, or worse, silently the wrong answer. Both failures are invisible until you measure, and both trace back to one design decision CPython made in 1992 and has never fully undone.

The first trap is the one above: using threads for CPU-bound work and getting no speedup. People add threads, see no improvement, conclude “Python is slow,” and move on. Python is not slow here — it is serialising your threads on purpose, and the fix is not more threads, it is a completely different tool. Getting this right can turn a six-second job into a one-and-a-half-second job on the same laptop. Getting it wrong wastes the seven idle cores you paid for.

The second trap is the opposite and nastier: using threads for shared mutable state and getting the wrong answer. Two threads incrementing the same counter can lose updates — not theoretically, but tens of thousands of them, every run, as you will see measured below. The GIL protects Python’s internals from corruption; it does not protect your data. That distinction is the difference between a program that works and a bank that loses money.

The mental model that prevents both is small. There is a lock — the GIL — that a thread must hold to run Python bytecode, and only one thread can hold it at a time. When a thread does I/O (reads a socket, sleeps, waits on a database) it drops the lock so another thread can run. So threads overlap beautifully when they spend their time waiting, and not at all when they spend their time computing. Processes sidestep the whole thing by each having their own interpreter and their own GIL — genuine parallelism, paid for in memory and in the cost of shipping data between them. Hold those two pictures and you can choose correctly every time.


The GIL: one mutex, one running thread

The Global Interpreter Lock is a single mutex inside the CPython interpreter. The rule it enforces is exactly one sentence: a thread must hold the GIL to execute Python bytecode, and only one thread can hold it at a time. You can have twenty threads, all Runnable, all scheduled by the OS onto twenty different cores — and nineteen of them are blocked on the GIL while one runs. The others are not doing Python work; they are waiting in line for the lock.

That is the whole mechanism. Everything surprising about Python concurrency is a consequence of it.

Why CPython has a GIL

This looks like a design flaw until you see what it buys. Every Python object carries a reference count — a small integer, ob_refcnt, tracking how many names point at it. When the count hits zero the object is freed. Almost every operation touches these counts: assigning a variable, passing an argument, returning a value, appending to a list. If two threads incremented and decremented the same object’s refcount at the same time without synchronisation, the count would corrupt — an object freed while still in use (a crash) or never freed (a leak).

CPython has two ways to make refcounting safe. One: put a tiny lock around every object — millions of locks, taken and released constantly. Two: put one lock around the whole interpreter. Option two is the GIL, and for single-threaded code — which is most Python code — it is dramatically faster, because taking one uncontended lock occasionally is far cheaper than taking millions of fine-grained locks constantly. The GIL also makes C extensions simple: an extension author can assume the interpreter is not being re-entered by another thread, so they can touch Python objects without their own locking. That assumption is baked into thirty years of NumPy, pandas, lxml, database drivers, and image libraries — which is the real reason removing the GIL took until 2023 to even attempt.

Question Answer
What is the GIL? One mutex; a thread must hold it to run Python bytecode
How many threads run bytecode at once? Exactly one, no matter how many cores or threads
Why does it exist? Makes reference counting safe with one lock instead of millions; keeps C extensions simple
Does it make single-threaded code slower? No — it makes it faster than fine-grained locking would
Does it protect my data from races? No. It protects the interpreter’s internals, not your objects
Do other Pythons have it? CPython and PyPy do. Jython and IronPython do not. It is not in the language spec
Can I turn it off? Experimentally, in the 3.13+ free-threaded build — see the last section

The GIL is released constantly

If one thread held the GIL forever, multithreading would be pointless. It doesn’t. The GIL is dropped in three situations, and the middle one is the entire reason threads are useful:

The GIL is released… When Effect
Every ~5 ms The interpreter checks an “eval breaker” and voluntarily drops the GIL so another thread can grab it Fairness between CPU-bound threads — but no extra throughput
During blocking I/O time.sleep, socket/file reads, input(), subprocess, DB waits — the C code wraps the call in Py_BEGIN_ALLOW_THREADS Other threads run while this one waits. This is where threading wins
Inside many C extensions hashlib, zlib, numpy, pandas, lxml, compression, crypto — during heavy compute they explicitly release it CPU work that is not Python bytecode can run in parallel

The 5 ms figure is sys.getswitchinterval(); you can read and set it. (Before Python 3.2 the switch was every 100 bytecodes, via the now-removed sys.getcheckinterval — you will see the old name in ancient answers online.)

import sys
print(sys.getswitchinterval())    # => 0.005    seconds between GIL hand-offs

Before the mechanics, clear out the five beliefs that cause the most wasted effort:

Common belief Reality
“The GIL makes my threaded code thread-safe” No — it protects interpreter internals (refcounts), not your data. Races are yours to prevent
“Threads speed up any slow code” Only I/O-bound (and GIL-releasing C-extension) work. Pure-Python CPU work sees ~1.0x
“More threads = more speed” Past a point, more threads means more context-switch overhead and slower
“The GIL is part of the Python language” No — it is a CPython implementation detail. Jython and IronPython have none
“Removing the GIL is a free win” It exposes hidden races and (today) slows single-threaded code — see PEP 703

Here is the model in one picture. Read it left to right: a workload of eight tasks arrives; the threading path runs them in one interpreter behind one GIL, so they take turns — which is useless for CPU work (measured ~1.0x) and a big win for I/O (measured 15.9x, because the GIL is released while each thread waits); the multiprocessing path runs them in eight separate interpreters, each with its own GIL, on separate cores — genuine parallelism (~4x here) paid for by pickling every argument.

The GIL and concurrency model, left to right: a workload of eight tasks flows into either a single-process threading model where one Global Interpreter Lock lets only one of eight threads execute Python bytecode at a time while the rest wait their 5ms turn, producing no speedup for CPU-bound work but a 15.9x speedup for I/O-bound work because the GIL is released while a thread waits; contrasted with a multiprocessing model of eight separate interpreters each with its own GIL running truly in parallel on the machine's cores for a roughly 4x CPU speedup, at the cost of pickling every argument, slow process startup and inter-process communication

The six badges mark where people lose the most time. The GIL is one mutex with one holder (1), and threads take fairness turns every 5 ms (2) — so CPU-bound threads measure ~1.0x and no lock will change that (3), while I/O-bound threads measure a near-linear win because waiting releases the GIL (4). Real parallelism needs separate processes, each its own interpreter and GIL — where the if __name__ == "__main__" guard is mandatory on the spawn start method (5) — and the price you pay is that every argument and result must pickle and cross an IPC boundary (6).


Threading: no help for CPU, huge help for I/O

Enough theory. Let us measure it, because measuring is the only way to believe it. The threading module gives you Thread objects; concurrent.futures.ThreadPoolExecutor gives you a pool of them with a nicer API. We will use the pool for the measurement and drop to raw Thread right after.

The CPU-bound experiment: threads do nothing

Our workload is deliberately pure Python — counting primes by trial division — so it holds the GIL the entire time and never releases it:

import time, os
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def count_primes(limit):
    """Pure-Python CPU work -> holds the GIL start to finish."""
    count = 0
    for n in range(2, limit):
        is_p = True
        for d in range(2, int(n ** 0.5) + 1):
            if n % d == 0:
                is_p = False
                break
        if is_p:
            count += 1
    return count

CHUNKS = [500_000] * 8          # eight equal chunks of CPU work

def timed(label, fn):
    t0 = time.perf_counter()
    fn()
    dt = time.perf_counter() - t0
    print(f"{label:<26} {dt:6.2f}s")
    return dt

if __name__ == "__main__":      # required — see multiprocessing, below
    print(f"CPUs: {os.cpu_count()}")
    s = timed("serial",             lambda: [count_primes(c) for c in CHUNKS])
    t = timed("ThreadPool(8)",      lambda: list(ThreadPoolExecutor(8).map(count_primes, CHUNKS)))
    p = timed("ProcessPool(8)",     lambda: list(ProcessPoolExecutor(8).map(count_primes, CHUNKS)))
    print(f"threads speedup: {s/t:.2f}x   processes speedup: {s/p:.2f}x")
CPUs: 8
serial                       5.68s
ThreadPool(8)                5.77s
ProcessPool(8)               1.27s
threads speedup: 0.99x   processes speedup: 4.46x

Look hard at that. Eight threads on an eight-CPU machine ran the CPU-bound job at 0.99x — no faster than one thread, in fact a hair slower because of the switching overhead. This is not a fluke or a bad benchmark; it is the GIL doing exactly its job. The eight threads spent the whole run passing one lock back and forth, and only one ever computed at a time. The ProcessPool on the same code and same data hit 4.46x — that is the next section’s story.

Run it three times and you will see the threads number hover around 1.0x (0.99x–1.06x on this machine) and the process number bounce between about 3.6x and 4.5x. Timings vary run to run — background load, thermal throttling, and scheduling all move them — so always report a ratio from a representative run, never a single magic number.

Why 4x and not 8x on eight CPUs? Because this laptop has 4 performance cores and 4 slower efficiency cores; the heavy lifting lands on the four fast ones, plus process startup and a little imbalance eat the rest. That honesty matters: process parallelism scales toward your core count, not past it, and rarely reaches it perfectly. For the theory of why the serial-fraction of a job caps your speedup (Amdahl’s law), see Algorithms: search, sort & complexity.

The I/O-bound experiment: threads win big

Now swap the workload for something that waits. time.sleep releases the GIL — it is a stand-in for a network request, a database query, a disk read, anything where your program hands control to the OS and waits:

import time
from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    time.sleep(0.5)             # a 0.5s "network round-trip" — RELEASES the GIL
    return f"{url} -> 200"

URLS = [f"https://api.example.com/item/{i}" for i in range(16)]

def timed(label, fn):
    t0 = time.perf_counter(); fn(); dt = time.perf_counter() - t0
    print(f"{label:<26} {dt:6.2f}s"); return dt

if __name__ == "__main__":
    s = timed("serial",         lambda: [fetch(u) for u in URLS])
    t = timed("ThreadPool(16)", lambda: list(ThreadPoolExecutor(16).map(fetch, URLS)))
    print(f"threads speedup: {s/t:.2f}x")
serial                       8.06s
ThreadPool(16)               0.51s
threads speedup: 15.93x

15.93x. Sixteen requests that took 8 seconds one at a time finished in half a second when overlapped. The moment fetch calls time.sleep, its thread drops the GIL, another thread runs, calls sleep, drops the GIL, and so on — all sixteen end up waiting simultaneously, so the total wall-clock time is one sleep, not sixteen. This is the entire case for threading: when your threads spend their time waiting, they wait in parallel. Real HTTP with the requests library behaves the same way — the socket read releases the GIL.

The exception that proves the rule: C extensions

“Threads give no CPU speedup” has one important caveat. Some CPU-heavy work is done inside a C extension that releases the GIL while it crunches. Hashing is the classic example — hashlib drops the GIL for large inputs:

import time, hashlib
from concurrent.futures import ThreadPoolExecutor

BLOB = b"x" * (60 * 1024 * 1024)      # 60 MB
def heavy_hash(_):
    h = hashlib.sha256()
    for _ in range(8):
        h.update(BLOB)                # hashlib releases the GIL for big buffers
    return h.hexdigest()[:8]

if __name__ == "__main__":
    tasks = list(range(8))
    t0 = time.perf_counter(); [heavy_hash(t) for t in tasks]
    print(f"serial:    {time.perf_counter()-t0:.2f}s")
    t0 = time.perf_counter()
    with ThreadPoolExecutor(8) as ex: list(ex.map(heavy_hash, tasks))
    print(f"threads:   {time.perf_counter()-t0:.2f}s")
serial:    4.33s
threads:   1.44s

That is 3.01x from threads on CPU-bound work — because the actual computation happens in C with the GIL released. This is why numpy, pandas, scipy and Pillow operations can genuinely parallelise across threads: the heavy loop is not Python bytecode. The rule is really: pure-Python CPU work gets no thread speedup. If your hot path is a C extension that releases the GIL, threads may be all you need.

Workload (this machine) Serial Threads Speedup Why
Pure-Python CPU (count primes) 5.68s 5.77s 0.99x GIL held throughout; threads serialise
I/O-bound (16 × 0.5s sleep) 8.06s 0.51s 15.93x GIL released while waiting; waits overlap
C-extension CPU (sha256 × 8) 4.33s 1.44s 3.01x C code releases the GIL during compute

The raw threading.Thread API

The pool hides the machinery. Underneath, a thread is a Thread object you start() and join():

import threading, time

def worker(name, seconds):
    print(f"  {name} starting")
    time.sleep(seconds)
    print(f"  {name} done")

t1 = threading.Thread(target=worker, args=("A", 0.3))
t2 = threading.Thread(target=worker, args=("B", 0.1))
t1.start(); t2.start()          # both now running concurrently
print("threads launched, main is free")
t1.join(); t2.join()            # block until BOTH have finished
print("all joined")
  A starting
  B starting
threads launched, main is free
  B done
  A done
all joined

start() launches the thread and returns immediately; join() blocks the caller until that thread finishes. Notice B finished before A (it slept less) and that “main is free” printed before either worker completed — that is concurrency. A few rules and the API surface:

Call What it does Gotcha
Thread(target=fn, args=(...), kwargs={...}) Build a thread that will run fn(*args, **kwargs) Does not start it — start() does
.start() Launch the thread; runs run() in the new thread Calling twice → RuntimeError: threads can only be started once
.join(timeout=None) Block until the thread finishes (or timeout s) Returns None; check .is_alive() to see if it timed out
.is_alive() True between start() and the target returning
.daemon (set before start) True → interpreter won’t wait for it at exit Daemon threads are killed at exit — work may be lost
.name, .ident, .native_id Debug labels / OS thread id ident is None until started
threading.current_thread(), .active_count() Introspection Handy in logs

A Thread has no built-in return value. The target’s return is discarded. To get a result out you either write into a shared structure (and now you have a race — see the next section), use a queue.Queue, or — far better — use concurrent.futures, whose Future.result() hands you the return value and re-raises any exception. This is the main reason to prefer the executor over raw threads.

Daemon threads: killed at exit

A daemon thread does not keep the program alive. When the main thread exits, daemon threads are terminated where they stand — mid-line, no cleanup:

import threading, time
done = []
def slow_work():
    time.sleep(1.0)
    done.append("finished")     # may NEVER run

threading.Thread(target=slow_work, daemon=True).start()
time.sleep(0.2)                 # main exits long before slow_work finishes
print("main exiting; done =", done)
main exiting; done = []

The daemon was killed at 0.2 s, one-fifth of the way through its second-long job, and its result was lost. Daemon threads are right for fire-and-forget background chores (a heartbeat, a metrics flusher) where losing the last iteration is fine. They are wrong for anything that must complete — for that, keep the thread non-daemon and join() it, or hand the work to a pool and shut it down cleanly.


Race conditions and the tools that fix them

The GIL protects Python’s internals. It does not protect your data — and the proof is a lost update you can measure. This is the part that turns “concurrency is confusing” into “concurrency is dangerous.”

A race condition, measured

counter += 1 looks atomic. It is not — it compiles to read the value, add one, write it back, three separate steps. If a thread is paused between the read and the write, another thread can read the same old value, and one of the two increments vanishes. Real code almost always does something between reading a shared value and writing it back — validate it, log it, compute the new one — and that gap is the whole vulnerability. We make it visible with time.sleep(0) (an explicit “let another thread run here”) and shrink the switch interval so the bug reproduces on every run instead of once a fortnight:

import sys, threading, time
sys.setswitchinterval(1e-6)     # switch threads often, so the race shows EVERY run
                                # (the bug exists at the default 5ms too — just rarely)
counter = 0
def deposit(n):
    global counter
    for _ in range(n):
        current = counter       # READ the shared value
        time.sleep(0)           # ...any work here = a window for another thread
        counter = current + 1   # WRITE back a now-stale value

threads = [threading.Thread(target=deposit, args=(25_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"expected 100,000, counter is {counter:,}  (lost {100_000 - counter:,})")
expected 100,000, counter is 25,816  (lost 74,184)

Four threads each added 25,000. The counter should read 100,000. It reads 25,816 — three-quarters of the updates were silently lost, and it lands near 25,000 every run because the threads spend the whole time clobbering each other’s writes. No exception, no warning; just a number that is wrong. In a bank, those are missing deposits. The GIL did nothing to save you, because incrementing your counter is your logic, not the interpreter’s.

Lock: the fix

A threading.Lock makes a section of code mutually exclusive — only one thread inside it at a time. Wrap the read-modify-write and the race is gone:

import sys, threading, time
sys.setswitchinterval(1e-6)
counter = 0
lock = threading.Lock()
def deposit(n):
    global counter
    for _ in range(n):
        with lock:              # only one thread past this line at a time
            current = counter
            time.sleep(0)       # even with the gap, the lock holds the door shut
            counter = current + 1

threads = [threading.Thread(target=deposit, args=(25_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"expected 100,000, counter is {counter:,}  (lost {100_000 - counter:,})")
expected 100,000, counter is 100,000  (lost 0)

Exactly 100,000, every time. The with lock: block is a critical section: a thread acquires the lock on the way in and releases it on the way out (even if the body raises — that is why with is the right tool, and the same guarantee you get from any context manager). Everything inside is serialised. The cost is contention — threads queue for the lock — so keep critical sections small: lock only the shared mutation, not the whole function.

Always prefer with lock: over manual acquire()/release(). The manual form leaks the lock forever if the body raises before release():

lock.acquire()
risky()                # if this raises, release() below never runs -> deadlock
lock.release()         # DON'T. Use `with lock:` which releases on any exit

The synchronisation toolbox

Lock is the workhorse, but threading gives you six primitives, each for a different coordination shape:

Primitive Use it when Key methods
Lock One thread at a time in a critical section (the default choice) acquire(), release(), with lock:
RLock The same thread must re-acquire a lock it already holds (recursive code) Same, but re-entrant by the owning thread
Semaphore(n) Allow up to n threads at once (a connection pool, a rate limit) acquire(), release(), with sem:
Event One thread signals; others wait for a flag to flip set(), clear(), wait(), is_set()
Condition Wait until state changes (classic producer/consumer) wait(), notify(), notify_all()
Barrier(n) All n threads must arrive before any proceeds wait()

RLock earns its keep the moment one locked method calls another. A plain Lock would deadlock a thread against itself:

import threading
lock = threading.Lock()
def outer():
    with lock:
        inner()          # inner() also wants the lock...
def inner():
    with lock:           # ...same thread, plain Lock -> HANGS forever
        pass
# outer()               # would deadlock. Swap threading.Lock() for threading.RLock() and it works.

An RLock remembers which thread owns it and how many times, so the same thread sails through; a different thread still blocks. Semaphore is the tool when the rule is “at most N at once” rather than “exactly one” — throttling concurrent downloads to five, say. Event is a one-shot (or resettable) broadcast: workers wait() until a coordinator calls set(). Condition is for the pattern “sleep until there is something to do, then wake up” without busy-looping.

queue.Queue: share work, not state

Here is the pattern that lets you skip most of the above. Do not share mutable state between threads; pass messages through a thread-safe queue. queue.Queue handles all the locking internally, so your worker code has no locks at all:

import threading, queue

work = queue.Queue()
results = queue.Queue()

def worker():
    while True:
        item = work.get()          # blocks until an item is available
        if item is None:           # the sentinel: "no more work"
            work.task_done()
            break
        results.put(item * item)
        work.task_done()           # tell the queue this item is finished

threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads: t.start()

for n in range(1, 9): work.put(n)  # produce work
for _ in threads: work.put(None)   # one sentinel per worker, to stop them
work.join()                        # block until every item is task_done()
for t in threads: t.join()

print(sorted(results.queue))       # => [1, 4, 9, 16, 25, 36, 49, 64]
[1, 4, 9, 16, 25, 36, 49, 64]

No Lock anywhere in the worker — the queue is the synchronisation. get() blocks a worker until there is something to do; put() is safe from any thread; task_done() and join() let the producer wait for completion. The critical detail is the sentinel: you must send one None per worker to tell them to stop, or the workers sit in work.get() forever after the last real item — a hang with no error. That is the number-one queue bug, and it has its own row in the troubleshooting table.

Queue call Meaning
q.put(x) / q.put(x, timeout=t) Add an item (blocks if maxsize reached)
q.get() / q.get(timeout=t) Remove and return an item (blocks if empty)
q.get_nowait() / q.put_nowait() Non-blocking; raise queue.Empty / queue.Full instead of waiting
q.task_done() Signal that one get()-ed item is fully processed
q.join() Block until every put item has been task_done()
queue.Queue / LifoQueue / PriorityQueue FIFO / stack / priority ordering
queue.SimpleQueue Simpler, faster, unbounded FIFO (3.7+) — no task_done/join

Deadlock: two locks, opposite order

Locks solve races and introduce a new failure: deadlock, where two threads each hold a lock the other needs, and both wait forever. The textbook cause is two locks acquired in opposite order — the dining philosophers, reaching for forks:

import threading, time
fork_left = threading.Lock()
fork_right = threading.Lock()

def alice():
    with fork_left:              # Alice grabs LEFT first
        time.sleep(0.1)
        with fork_right:         # ...then waits for RIGHT (Bob holds it)
            print("Alice ate")

def bob():
    with fork_right:             # Bob grabs RIGHT first
        time.sleep(0.1)
        with fork_left:          # ...then waits for LEFT (Alice holds it)
            print("Bob ate")

a = threading.Thread(target=alice, daemon=True)
b = threading.Thread(target=bob, daemon=True)
a.start(); b.start()
a.join(timeout=2.0); b.join(timeout=2.0)      # watchdog — or we'd hang forever
if a.is_alive() or b.is_alive():
    print("DEADLOCK: both threads stuck after 2s")
DEADLOCK: both threads stuck after 2s

Both threads grabbed their first fork, slept, and reached for the second — which the other thread was holding. Neither will ever let go. (Note the daemon=True and the join(timeout=...) watchdog: without them this program hangs forever, which is exactly what a deadlock does in production.) The fix is a rule so simple it feels like cheating: every thread acquires locks in the same global order. If everyone grabs left before right, no cycle can form:

def eat(name):
    with fork_left:             # EVERYONE grabs left before right — no cycle possible
        time.sleep(0.1)
        with fork_right:
            print(f"{name} ate")

a = threading.Thread(target=eat, args=("Alice",), daemon=True)
b = threading.Thread(target=eat, args=("Bob",), daemon=True)
a.start(); b.start(); a.join(2.0); b.join(2.0)
print("both finished, no deadlock" if not (a.is_alive() or b.is_alive()) else "DEADLOCK")
Alice ate
Bob ate
both finished, no deadlock
Deadlock needs all four (Coffman) Break it by
Mutual exclusion — locks are exclusive (usually can’t relax this)
Hold and wait — hold one lock, request another Acquire all locks at once, or none
No preemption — can’t force a lock away Use acquire(timeout=...) and back off on failure
Circular wait — a cycle of “waiting for” Impose a global lock order — the standard fix

Break any one condition and deadlock is impossible. Global lock ordering breaks the circular wait and is the fix you will reach for most; acquire(timeout=...) (which returns False instead of blocking forever) is the defensive backup.


Multiprocessing: a second interpreter is a second GIL

Threads can’t parallelise CPU work because they share one GIL. Processes each get their own interpreter and their own GIL — so they run genuinely in parallel, on separate cores, with no lock between them. That is the only way to make pure-Python CPU code use all your cores, and it is what turned our prime-counting job from 5.68s to 1.27s.

The API deliberately mirrors threading: Process is to Thread what multiprocessing.Queue is to queue.Queue. The difference is everything underneath — separate memory instead of shared.

Threading Multiprocessing Both give you
threading.Thread multiprocessing.Process start(), join(), is_alive(), daemon
queue.Queue multiprocessing.Queue A safe FIFO with put() / get()
threading.Lock / Semaphore multiprocessing.Lock / Semaphore Mutual exclusion between workers
ThreadPoolExecutor ProcessPoolExecutor submit, map, as_completed, futures
Shared memory (automatic, needs locks) Pickle + IPC (explicit, no locks) Moving data between workers
from multiprocessing import Process
import os

def work(name):
    print(f"  {name} in pid {os.getpid()}")

if __name__ == "__main__":
    print(f"main pid {os.getpid()}")
    procs = [Process(target=work, args=(f"P{i}",)) for i in range(3)]
    for p in procs: p.start()
    for p in procs: p.join()
main pid 51234
  P0 in pid 51235
  P1 in pid 51236
  P2 in pid 51237

Different PIDs — these are real OS processes, not threads. Each has its own memory. And that last fact is the source of every multiprocessing gotcha.

Everything must pickle

Because processes don’t share memory, every argument you pass to a worker and every result it returns must be serialised — converted to bytes, shipped through a pipe, and rebuilt on the other side. CPython uses pickle for this. Most things pickle fine. Some things famously do not:

from multiprocessing import Pool
if __name__ == "__main__":
    with Pool(2) as pool:
        print(pool.map(lambda x: x * x, range(4)))   # a lambda...
_pickle.PicklingError: Can't pickle <function <lambda> at 0x1025a6340>: attribute lookup <lambda> on __main__ failed

Lambdas can’t be pickled (they have no importable name), so they can’t cross to a worker process. Neither can locally-defined functions (AttributeError: Can't pickle local object), open file handles, sockets, database connections, or thread locks. The fix is to use a module-level def as the worker target.

Pickles fine Does not pickle
int, float, str, bytes, bool, None lambda (→ PicklingError)
list, dict, tuple, set of picklables Local/nested functions (→ AttributeError: Can't pickle local object)
Module-level functions and classes Open files, sockets, DB connections, threading.Lock
dataclass instances (of picklable fields) Generators, functools.partial of a lambda
Most standard-library value types Anything holding an unpicklable attribute

The if __name__ == "__main__" guard is mandatory

This is the single most common multiprocessing crash, and on macOS and Windows it is not optional. Those platforms use the spawn start method: to create a worker, Python launches a fresh interpreter that re-imports your module to get the worker function. If creating the pool is at module top level — not guarded — the child re-runs that line too, tries to create its own pool, whose children re-import and create more pools… a fork bomb.

from multiprocessing import Pool
def work(x):
    return x * x
pool = Pool(4)                       # NO guard — runs on every re-import
print(pool.map(work, range(8)))
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()
                ...

Python detects the runaway spawn and raises — but not before thousands of processes try to launch (this run logged over 4,500 failed spawn attempts before it settled). The fix is one line, and it is why every multiprocessing example in this lesson has it:

from multiprocessing import Pool
def work(x):
    return x * x

if __name__ == "__main__":           # child re-imports the module, but __name__
    with Pool(4) as pool:            # is NOT "__main__" there, so this block is skipped
        print(pool.map(work, range(8)))
# => [0, 1, 4, 9, 16, 25, 36, 49]

The guard means “run this only when the file is executed directly, not when it is imported” — and a spawned child imports it. Put your pool creation, your Process starts, and your top-level driver code inside it.

Start method Default on How a worker is born Notes
spawn macOS, Windows Fresh interpreter, re-imports your module Slowest start; guard is mandatory; safest
fork Linux Copies the parent process wholesale Fast; unsafe if the parent has threads (can deadlock)
forkserver (opt-in, Unix) A clean server process forks workers Middle ground; avoids fork-after-threads hazards

You can choose with multiprocessing.set_start_method("spawn"). Since fork-after-threads is genuinely dangerous, write your code to be spawn-safe (guarded, picklable, no reliance on inherited globals) even on Linux — it is the portable, future-proof default, and CPython is gradually moving Linux toward it.

Pool and the interfaces for passing data

Pool is the high-level workhorse. It starts N worker processes and feeds them tasks:

from multiprocessing import Pool
def square(x): return x * x
def add(a, b):  return a + b

if __name__ == "__main__":
    with Pool(4) as pool:
        print(pool.map(square, range(6)))            # blocks, results in order
        print(pool.starmap(add, [(1, 2), (3, 4)]))   # unpacks tuples as args
        r = pool.apply_async(square, (10,))          # non-blocking; returns a handle
        print(r.get(timeout=5))                      # fetch the one result
[0, 1, 4, 9, 16, 25]
[3, 7]
100
Pool method Behaviour
map(fn, iters) Blocks; results in input order; splits work into chunks
imap(fn, iters) Lazy iterator of results, in order — stream huge inputs without buffering all
imap_unordered(fn, iters) Same, but yields results as they finish (fastest-first)
starmap(fn, iters) Like map but unpacks each tuple: fn(*args)
apply_async(fn, args) Submit one task, get an AsyncResult; .get() fetches it
close() / join() / terminate() Stop accepting work / wait / kill immediately

For moving data between specific processes rather than a pool, there are three levels:

Mechanism Shape Cost Use for
multiprocessing.Queue Process-safe FIFO; items pickled through a pipe Medium (pickle + pipe) Fan-out work / fan-in results between any processes
Pipe() Two-ended (conn1, conn2); send()/recv() Low (one hop) Fast channel between exactly two processes
shared_memory.SharedMemory A raw byte buffer both processes map Lowest — no copy Big arrays (numpy) shared without pickling

shared_memory (3.8+) is the escape hatch when pickling a large array every call is the bottleneck — you write bytes once and every process reads the same physical memory:

from multiprocessing import shared_memory
import struct
if __name__ == "__main__":
    shm = shared_memory.SharedMemory(create=True, size=16)
    shm.buf[0:4] = struct.pack("i", 42)          # write into the raw buffer
    print("read back:", struct.unpack("i", bytes(shm.buf[0:4]))[0])
    shm.close()
    shm.unlink()                                 # ⚠️ REQUIRED — or the block leaks
read back: 42

⚠️ Shared memory is not garbage-collected. You must close() every handle and unlink() exactly once, or the block survives your program and leaks OS memory. (The OS also rounds the size up to a page — ask for 16 bytes and shm.size may report 16384.) There is also Manager(), which hands out proxied list/dict objects that look shared, but every access is a pickled round-trip to a server process — convenient, and slow; reach for it only for low-frequency coordination.

The cost side of the ledger

Processes are not free, and the costs are exactly why you don’t use them for I/O or for tiny tasks:

Cost Threads Processes
Startup Microseconds Milliseconds (spawn re-imports your module)
Memory each Shares the parent’s A full interpreter copy (tens of MB)
Passing data Free — shared memory Pickle + IPC on every argument and result
Sharing objects Automatic (and dangerous) None by default — separate memory
Right for I/O-bound, C-extension CPU Pure-Python CPU-bound

If your tasks are small, the pickling and startup overhead can cost more than the work — which is why the CPU benchmark used 500,000-iteration chunks, not eight tiny ones. Give each process enough work to dwarf the cost of getting it there.


concurrent.futures: one API, swap one word

You have now seen two APIs — threading/ThreadPoolExecutor and multiprocessing/Pool. The beautiful thing about concurrent.futures is that it unifies threads and processes behind one interface, so switching between them is a one-word edit:

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def job(x):
    return x * x

if __name__ == "__main__":
    with ThreadPoolExecutor(max_workers=4) as ex:     # threads: for I/O-bound
        print(list(ex.map(job, range(6))))
    with ProcessPoolExecutor(max_workers=4) as ex:    # processes: for CPU-bound
        print(list(ex.map(job, range(6))))            # ...identical code
[0, 1, 4, 9, 16, 25]
[0, 1, 4, 9, 16, 25]

The only difference is Thread versus Process in the class name. Write your parallel code once, measure it both ways, keep the winner. That is the practical payoff of everything above.

Futures propagate exceptions correctly

A Future is a handle to a result that may not exist yet. submit() returns one immediately; result() blocks until it is ready — and crucially, re-raises in the caller any exception the worker hit. No swallowed errors:

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def task(n):
    time.sleep(0.1 * n)
    if n == 3:
        raise ValueError("task 3 blew up")
    return n * n

if __name__ == "__main__":
    with ThreadPoolExecutor(max_workers=4) as ex:
        futures = {ex.submit(task, n): n for n in [1, 2, 3, 4]}
        for fut in as_completed(futures):        # yields each as it FINISHES
            n = futures[fut]
            try:
                print(f"task {n} -> {fut.result()}")   # re-raises here if it failed
            except ValueError as e:
                print(f"task {n} raised: {e}")
task 1 -> 1
task 2 -> 4
task 3 raised: task 3 blew up
task 4 -> 16

The ValueError raised inside a worker did not vanish — it was captured in the future and re-thrown the instant we called .result(), where a normal try/except caught it. Compare this to bare multiprocessing.Pool.apply_async, where an exception in the worker sits silently until you call .get() — a real trap if you forget to. Futures make the error handling ordinary.

Executor / Future call Behaviour
submit(fn, *args)Future Schedule one call; returns immediately
map(fn, iterable) Results in input order; re-raises on iteration
as_completed(futures) Iterator yielding futures in completion order
wait(futures, return_when=...) Block for FIRST_COMPLETED / ALL_COMPLETED
future.result(timeout=None) Block for the value; re-raises the worker’s exception
future.exception() Return the exception instead of raising it
future.done() / .running() / .cancel() State queries; cancel only if not yet started
executor.shutdown(wait=True) Wait for pending work (automatic with with)

Two default-workers facts worth knowing: ThreadPoolExecutor() defaults to min(32, cpu_count + 4) workers (12 on this machine), tuned for I/O; ProcessPoolExecutor() defaults to cpu_count (8 here), tuned for CPU. map gives you results in the order you submitted; as_completed gives them to you as they finish — use as_completed when you want to react to the fast results without waiting for the slow ones.


Choosing the right tool

Everything reduces to one question you now know how to answer: is the work CPU-bound or I/O-bound? Profile if you are unsure — but usually you know: if it is crunching numbers, parsing, hashing pure-Python, or looping, it is CPU-bound; if it is talking to a network, disk, or database, it is I/O-bound.

Your workload Reach for Why
Pure-Python CPU-bound (compute, parse, transform) ProcessPoolExecutor Only separate GILs give real multi-core parallelism
CPU-bound via a C extension (numpy, hashlib, pandas) ThreadPoolExecutor The extension releases the GIL — threads parallelise it
I/O-bound, dozens–hundreds of tasks ThreadPoolExecutor Waiting releases the GIL; simple, no async rewrite
I/O-bound, thousands–millions of tasks asyncio One thread, an event loop — no per-task thread cost
A mix (fetch then crunch) Threads/async for the fetch, a process pool for the crunch Match the tool to each phase
Tiny tasks Often none — stay serial Coordination overhead can exceed the work

The three-way choice — threads, processes, asyncio — is the concurrency trilemma, and each corner has a distinct trade-off:

Threading Multiprocessing Asyncio
Parallel CPU work ❌ (one GIL) ✅ (N GILs) ❌ (one thread)
Good for I/O ✅ (heavy) ✅✅ (lightest)
Memory per task Low High (a process each) Lowest (a coroutine)
Shares memory Yes (needs locks) No (pickle/IPC) Yes (needs care)
Scales to ~hundreds of threads ~your core count tens of thousands
Pass data Shared vars + queue.Queue pickle, Queue, Pipe, shared_memory shared vars, asyncio.Queue
Main hazard Race conditions, deadlock Pickling, startup cost, the guard Blocking the loop
Preemptive? Yes (5 ms) Yes (OS) No — cooperative (await points)

Asyncio is the subject of its own lesson; the one-line summary is that it does I/O concurrency in a single thread using cooperative await points, so it has no locking hazards and scales to tens of thousands of connections — but a single blocking call (a time.sleep, a CPU loop) freezes the whole event loop. For fewer, chunkier I/O tasks, threads are simpler; for massive I/O fan-out, asyncio wins.


PEP 703: the free-threaded future

For thirty years the honest answer to “can I remove the GIL?” was “no.” As of Python 3.13 (October 2024) the honest answer is “yes, experimentally.” PEP 703 adds a free-threaded build of CPython — compiled with --disable-gil — in which the GIL is genuinely gone and pure-Python threads run on all your cores at once. This is the biggest change to Python’s execution model in its history, and it is worth understanding precisely, including its limits.

What changes. On a free-threaded build, the CPU-bound prime benchmark that measured 0.99x on threads would approach the ~4x you currently only get from processes — without pickling, without process startup, with shared memory. The refcounting that the GIL protected is made thread-safe by other means (biased reference counting and internal per-object locks). You can check the runtime state on 3.13+:

import sys
print(sys._is_gil_enabled())   # True on a normal build; False on a free-threaded one

What does not change. Removing the GIL does not remove race conditions — it makes them worse. Today the GIL accidentally serialises many operations, so some buggy code appears to work; without it, every unsynchronised shared write is exposed. You will need locks more, not less. The threading, multiprocessing and concurrent.futures APIs are unchanged; your Locks and Queues are exactly as necessary. And multiprocessing does not become obsolete — process isolation still matters for fault tolerance and for truly independent work.

Why it is not the default. Three reasons. First, single-threaded code runs measurably slower on the free-threaded build (the fine-grained safety has overhead) — an unacceptable regression to impose on everyone. Second, the entire C-extension ecosystem assumed the GIL; each extension must be audited and rebuilt for free-threading (the build even uses a separate ABI, so binaries are often named python3.13t). Third, it is still stabilising. Python 3.14 (2025) upgraded free-threading from “experimental” to officially supported, but it remains opt-in, not the default — you must install and select the free-threaded build deliberately.

Aspect GIL build (today’s default) Free-threaded build (--disable-gil)
Pure-Python CPU threading Serial (no speedup) Parallel across cores
Single-thread speed Baseline Slightly slower
Race conditions Exist; sometimes masked Exist; fully exposed — lock more
C extensions All work Must be rebuilt/marked compatible
Status (3.13 / 3.14) Default, stable Experimental / supported, opt-in
sys._is_gil_enabled() True False

The pragmatic takeaway for 2026: write the multiprocessing/threading code this lesson teaches. It is correct on today’s default build and stays correct on the free-threaded one. If free-threading becomes the norm, your process pools quietly become thread pools with the same API — and your locks, which you were disciplined about, keep you correct through the transition.


Hands-on lab

This lab is pure standard library — no pip install. (For the habit: python3 -m venv .venv && source .venv/bin/activate; on Windows .venv\Scripts\activate and use python for python3.) It targets Python 3.12+; sys._is_gil_enabled is 3.13+, and some error text differs on older versions. Every measurement below is real — your timings will differ, the ratios are the point — and any process code must live under if __name__ == "__main__".

Create concurrency_lab.py and build it up step by step, running python3 concurrency_lab.py after each.

Step 1 — Prove the GIL: the same CPU job, three ways.

import time, os
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def count_primes(limit):                 # same explicit loop we measured earlier
    count = 0
    for n in range(2, limit):
        is_p = True
        for d in range(2, int(n ** 0.5) + 1):
            if n % d == 0:
                is_p = False
                break
        if is_p:
            count += 1
    return count

CHUNKS = [500_000] * 8

def timed(label, fn):
    t0 = time.perf_counter(); r = fn(); dt = time.perf_counter() - t0
    print(f"  {label:<24} {dt:6.2f}s"); return dt

if __name__ == "__main__":
    print(f"CPUs: {os.cpu_count()}  |  CPU-bound: 8 x count_primes(500_000)")
    s = timed("serial",         lambda: [count_primes(c) for c in CHUNKS])
    t = timed("ThreadPool(8)",  lambda: list(ThreadPoolExecutor(8).map(count_primes, CHUNKS)))
    p = timed("ProcessPool(8)", lambda: list(ProcessPoolExecutor(8).map(count_primes, CHUNKS)))
    print(f"  => threads {s/t:.2f}x   processes {s/p:.2f}x")
CPUs: 8  |  CPU-bound: 8 x count_primes(500_000)
  serial                    5.68s
  ThreadPool(8)             5.77s
  ProcessPool(8)            1.27s
  => threads 0.99x   processes 4.46x

What just happened: the headline result of the whole lesson, on one screen. Eight threads ran the CPU job at 0.99x — the GIL let only one compute at a time. Eight processes ran the identical code at 4.46x — eight interpreters, eight GILs, real cores. Same work, same data, one word of difference (Thread vs Process), a 4.5x gap.

Step 2 — The opposite result: I/O-bound work.

def fetch(_):
    time.sleep(0.5)          # simulate a network call — releases the GIL
    return 200

if __name__ == "__main__":
    print("I/O-bound: 16 x sleep(0.5)")
    s = timed("serial",          lambda: [fetch(i) for i in range(16)])
    t = timed("ThreadPool(16)",  lambda: list(ThreadPoolExecutor(16).map(fetch, range(16))))
    print(f"  => threads {s/t:.2f}x")
I/O-bound: 16 x sleep(0.5)
  serial                    8.06s
  ThreadPool(16)            0.51s
  => threads 15.93x

What just happened: the exact tool that did nothing in Step 1 gave a 15.93x win here. Because sleep releases the GIL, all sixteen waits overlapped and the total was one sleep, not sixteen. This is the whole rule in two steps: threads lose on CPU, win on I/O.

Step 3 — Reproduce a race condition.

import sys, threading
sys.setswitchinterval(1e-6)      # make the race surface every run

counter = 0
def bump(n):
    global counter
    for _ in range(n):
        current = counter
        time.sleep(0)            # the window another thread slips through
        counter = current + 1

if __name__ == "__main__":
    counter = 0
    threads = [threading.Thread(target=bump, args=(25_000,)) for _ in range(4)]
    for t in threads: t.start()
    for t in threads: t.join()
    print(f"race: expected 100,000, got {counter:,} (lost {100_000 - counter:,})")
race: expected 100,000, got 25,816 (lost 74,184)

What just happened: four threads each added 25,000 and 74,184 updates vanished. The += 1 was three non-atomic steps and the threads clobbered each other’s writes. The GIL did not save you — your counter is your logic, not the interpreter’s.

Step 4 — Fix the race with a Lock.

if __name__ == "__main__":
    counter = 0
    lock = threading.Lock()
    def bump_safe(n):
        global counter
        for _ in range(n):
            with lock:
                current = counter
                time.sleep(0)
                counter = current + 1
    threads = [threading.Thread(target=bump_safe, args=(25_000,)) for _ in range(4)]
    for t in threads: t.start()
    for t in threads: t.join()
    print(f"locked: expected 100,000, got {counter:,} (lost {100_000 - counter:,})")
locked: expected 100,000, got 100,000 (lost 0)

What just happened: wrapping the read-modify-write in with lock: made it a critical section — one thread inside at a time — and the count is now exactly right, every run. The lock, not the GIL, is what protects your data.

Step 5 — Reproduce and fix a deadlock.

if __name__ == "__main__":
    la, lb = threading.Lock(), threading.Lock()
    def t1():                      # grabs a then b
        with la:
            time.sleep(0.1)
            with lb: pass
    def t2():                      # grabs b then a  -> opposite order
        with lb:
            time.sleep(0.1)
            with la: pass
    a = threading.Thread(target=t1, daemon=True); b = threading.Thread(target=t2, daemon=True)
    a.start(); b.start(); a.join(2.0); b.join(2.0)
    print("deadlock!" if (a.is_alive() or b.is_alive()) else "ok")

    la, lb = threading.Lock(), threading.Lock()      # fresh locks
    def ordered():                 # EVERYONE grabs a then b
        with la:
            time.sleep(0.1)
            with lb: pass
    a = threading.Thread(target=ordered, daemon=True); b = threading.Thread(target=ordered, daemon=True)
    a.start(); b.start(); a.join(2.0); b.join(2.0)
    print("ordered:", "deadlock!" if (a.is_alive() or b.is_alive()) else "ok")
deadlock!
ordered: ok

What just happened: opposite lock order created a cycle — each thread holding what the other wanted — and both hung until the 2-second watchdog gave up. Making every thread acquire la before lb removed the cycle and both finished. (Note the fresh locks for the second test: the first test’s threads are still stuck holding the old locks, which would poison a re-test — a real gotcha.)

Step 6 — The unified API, both ways.

def square(x): return x * x

if __name__ == "__main__":
    with ThreadPoolExecutor(4) as ex:
        print("threads: ", list(ex.map(square, range(6))))
    with ProcessPoolExecutor(4) as ex:
        print("processes:", list(ex.map(square, range(6))))
threads:  [0, 1, 4, 9, 16, 25]
processes: [0, 1, 4, 9, 16, 25]

What just happened: identical code, one class name different, running on threads then on processes. This is the everyday shape of the decision: write it once, measure both, ship the one that wins for your workload.

You have now watched threads do nothing for CPU work and everything for I/O, seen 74,000 updates disappear and a lock bring them back, hung a program on a deadlock and un-hung it with one ordering rule, and driven both backends from one API. That is the entire mental model, earned by measurement.


Common mistakes and troubleshooting

Symptom / traceback Cause Fix
CPU-bound code, threads add no speedup (or slower) The GIL — pure-Python bytecode never runs two threads at once Use ProcessPoolExecutor; or move the hot loop into a C extension that releases the GIL
RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase On spawn (macOS/Windows) the module re-imports; unguarded Pool/Process creation runs again → fork bomb Put all process-starting code under if __name__ == "__main__":
_pickle.PicklingError: Can't pickle <function <lambda> ...> Passed a lambda to a Pool/ProcessPoolExecutor — it has no importable name Use a module-level def as the worker target
AttributeError: Can't pickle local object 'f.<locals>.g' Passed a nested/local function to a process pool Move the function to module level
TypeError: cannot pickle '_io.TextIOWrapper' object (or socket, Lock) An open file / socket / DB connection / lock was passed as an argument Pass the path/config, and open the resource inside the worker
Shared counter/list gives the wrong total across threads Race condition — non-atomic read-modify-write on shared state Guard it with a Lock (with lock:), or pass work through queue.Queue
Program hangs forever, no CPU use Deadlock — two locks acquired in opposite order, or a Lock re-acquired by its owner Impose a global lock order; use RLock for re-entrant code; acquire(timeout=...) to fail fast
RuntimeError: threads can only be started once Called .start() twice on one Thread object Create a new Thread per run (or use a pool)
Worker exception vanishes until much later multiprocessing.Pool.apply_async holds the exception until .get() Call .get()/.result(); prefer concurrent.futures, which re-raises on .result()
queue.Queue.get() blocks forever at the end No sentinel — workers wait for work that never comes put(None) once per worker as a stop signal; or get(timeout=...)
Changes to a passed object don’t appear in the parent (multiprocessing) Separate memory — the child mutated its own copy Return the value, or use shared_memory / a Manager proxy
Daemon thread’s work is half-done / lost at exit Daemon threads are killed when the main thread exits Make it non-daemon and join() it, or use a pool with clean shutdown
Machine grinds to a halt with hundreds of threads Context-switch thrash — too many threads for the work Use a bounded pool (max_workers); for CPU work, ~cpu_count processes
sys._is_gil_enabledAttributeError You are on Python < 3.13 It only exists on 3.13+; guard with hasattr(sys, "_is_gil_enabled")
FileExistsError / leaked memory from SharedMemory Forgot to unlink(), or unlinked twice close() every handle; unlink() exactly once by the owner
BrokenProcessPool A worker process crashed (segfault, os._exit, OOM-killed) Find the crashing task; catch it; don’t call os._exit in workers

Three of these deserve extra words, because they cost the most hours.

1. “Threads didn’t make my CPU code faster.” This is the GIL, working as designed, and it is not a bug you can fix with better thread code. There is no lock to tune, no thread count that helps, no clever trick — pure-Python CPU work is serialised by the interpreter. The moment you see threads flat-line on a compute job, stop reaching for threads and switch to ProcessPoolExecutor. The one exception is when your compute is really inside a C extension (numpy, hashlib): then threads do parallelise it, because the extension released the GIL for you. Measure both; the numbers tell you which world you are in.

2. The missing if __name__ == "__main__" guard. On macOS and Windows this is not a style preference — it is the difference between a working program and a fork bomb that spawns thousands of processes before Python catches it. The cause is spawn: each worker starts a fresh interpreter that imports your module, and any unguarded top-level Pool() or Process().start() runs again in every child. The rule is mechanical: every line that starts a process goes under the guard. Develop on the spawn method even on Linux (where fork is the default) so this bites you in testing, not in production — and because fork-after-threads is itself unsafe.

3. The silent race condition. The scariest failure in this lesson produces no traceback — just a wrong number, intermittently. Because the GIL serialises many operations by accident, a race can hide for months and then surface under load, on a faster machine, or after a library upgrade changes timing. The defence is discipline, not luck: any mutable state touched by more than one thread needs a Lock, or must not be shared at all. The cleanest designs share nothing mutable — they pass immutable messages through a queue.Queue and keep each thread’s state private. When you can’t, wrap every read-modify-write in with lock: and keep the critical section tiny. And remember that free-threaded Python will expose every race the GIL is currently hiding, so this discipline is an investment, not a tax.


Cheat-sheet

Syntax What it does
from concurrent.futures import ThreadPoolExecutor Pool of threads — for I/O-bound work
from concurrent.futures import ProcessPoolExecutor Pool of processes — for CPU-bound work
with ThreadPoolExecutor(max_workers=8) as ex: Auto-shutdown pool; default workers min(32, cpu+4)
with ProcessPoolExecutor() as ex: Default workers = os.cpu_count()
ex.map(fn, iterable) Results in input order; re-raises on iteration
f = ex.submit(fn, *args) Schedule one call → a Future
f.result(timeout=None) Block for the value; re-raises worker exceptions
for f in as_completed(futures): Iterate futures in finish order
import threading The low-level thread API
t = threading.Thread(target=fn, args=()) Build a thread (does not start it)
t.start() / t.join(timeout=None) Run it / wait for it
threading.Thread(target=fn, daemon=True) Daemon: killed at exit, work may be lost
lock = threading.Lock(); with lock: ... Mutual exclusion — one thread in the block
threading.RLock() Re-entrant lock — same thread may re-acquire
threading.Semaphore(n) Allow up to n threads at once
threading.Event()set()/wait() One flag, many waiters
threading.Condition()wait()/notify() Wait until state changes (producer/consumer)
import queue; q = queue.Queue() Thread-safe work handoff — no manual locks
q.put(x) / q.get() / q.task_done() / q.join() Produce / consume / ack / wait for all
q.put(None) per worker The sentinel — tell workers to stop
from multiprocessing import Process, Pool, Queue, Pipe The process API (mirrors threading)
if __name__ == "__main__": MANDATORY around process-starting code (spawn)
with Pool(4) as p: p.map(fn, xs) Process pool; fn must be a module-level def
p.starmap(fn, [(a, b), ...]) Unpack tuples: fn(*args)
r = p.apply_async(fn, args); r.get() Submit one; fetch (raises stored exception)
from multiprocessing import shared_memory Raw shared bytes; close() + unlink() (⚠️ leaks)
multiprocessing.set_start_method("spawn") Force spawn (safe, portable)
sys.getswitchinterval() GIL hand-off interval — default 0.005 s
sys._is_gil_enabled() 3.13+: is the GIL on? (False on free-threaded build)
CPU-bound → processes. I/O-bound → threads/asyncio. The whole decision, one line

Interview and exam questions

Q: What is the GIL, in one sentence, and what is its precise consequence? A: The Global Interpreter Lock is a single mutex that a thread must hold to execute Python bytecode, so only one thread runs Python code at a time regardless of how many cores or threads you have. The precise consequence: threads give no speedup for CPU-bound Python work (they serialise on the lock) but real speedup for I/O-bound work (a thread releases the GIL while it waits, so other threads run). It is a CPython implementation detail — Jython and IronPython have no GIL.

Q: Why does CPython have a GIL if it prevents multi-core threading? A: To make reference counting thread-safe cheaply and to keep C extensions simple. Every object has a refcount touched by almost every operation; protecting each object with its own lock would mean millions of locks and would slow down the common single-threaded case. One interpreter-wide lock is far faster for single-threaded code and lets C-extension authors assume no concurrent interpreter access — an assumption baked into NumPy, pandas and thirty years of the ecosystem, which is exactly why removing it is so hard.

Q: I have a CPU-bound function. I run it in eight threads on an eight-core machine and it’s no faster. Why, and what do I do? A: The GIL serialises the eight threads — only one runs bytecode at a time, so you get roughly 1.0x (measurably 0.99x in this lesson, slightly slower from switching overhead). No lock, thread count, or code change fixes it. Switch to ProcessPoolExecutor: separate processes have separate interpreters and separate GILs, so the same code runs in true parallel — this lesson measured ~4.5x on the identical workload. The one caveat: if the “CPU work” is actually inside a C extension that releases the GIL (numpy, hashlib), threads do parallelise it.

Q: The GIL makes counter += 1 safe across threads, right? A: No — this is the most dangerous misconception in Python concurrency. The GIL protects the interpreter’s internal state (refcounts, internal structures), not your data. counter += 1 is three bytecodes — read, add, write — and the GIL can switch threads between them, so two threads can read the same value and one increment is lost. This lesson measured four threads losing 74,000 of 100,000 updates. You must guard shared mutable state with a Lock yourself; the GIL will not.

Q: Walk me through fixing a race condition on a shared counter. A: Wrap the read-modify-write in a critical section so only one thread executes it at a time: create a threading.Lock() and use with lock: around counter = counter + 1. The lock forces the three steps to happen atomically with respect to other threads — one acquires, updates, releases; the next then proceeds. Keep the critical section as small as possible (lock only the shared mutation) to minimise contention, and always use with lock: rather than manual acquire()/release() so the lock is released even if the body raises. Better still, avoid shared mutable state entirely by passing work through a queue.Queue.

Q: Why is if __name__ == "__main__" mandatory with multiprocessing, and what happens without it? A: On the spawn start method (the default on macOS and Windows) creating a worker launches a fresh interpreter that re-imports your module to find the worker function. If your pool/process creation is at module top level, unguarded, the child re-runs it and creates its own children, which re-import and create more — a runaway spawn. Python detects it and raises RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase, but only after thousands of failed launches. The guard ensures that top-level driver code runs only when the file is executed directly, not when a child imports it.

Q: What can’t you pass to a process pool, and why? A: Anything that can’t be pickled, because arguments and results are serialised to cross the process boundary (processes share no memory). The classic offenders: lambdas and local/nested functions (no importable name → PicklingError / AttributeError: Can't pickle local object), and open files, sockets, database connections and locks (they wrap OS resources that can’t be recreated by unpickling). The fix is to use module-level functions as targets and to open resources inside the worker, passing only the picklable config (a path, a connection string).

Q: Compare ThreadPoolExecutor and ProcessPoolExecutor. When each? A: Same API — submit, map, as_completed, futures that re-raise exceptions on .result() — differing in one word. ThreadPoolExecutor runs tasks in threads sharing one GIL: use it for I/O-bound work and for CPU work done in GIL-releasing C extensions; it is cheap (shared memory, fast start) but can’t parallelise pure-Python CPU. ProcessPoolExecutor runs tasks in separate processes with separate GILs: use it for pure-Python CPU-bound work; it gives real multi-core parallelism but costs process startup and pickling of every argument/result. Because the API is identical, write once, measure both, keep the winner.

Q: What is a deadlock, and how do you prevent it? A: A deadlock is two (or more) threads each holding a lock the other needs, so all wait forever — no error, just a hung program. The classic cause is two locks acquired in opposite order. Deadlock needs all four Coffman conditions (mutual exclusion, hold-and-wait, no preemption, circular wait); breaking any one prevents it. The standard fix is to break circular wait by imposing a global lock ordering — every thread acquires locks in the same fixed order, so no cycle can form. Defensive backups: acquire(timeout=...) to fail instead of blocking forever, holding fewer locks, or replacing shared-lock designs with a queue.Queue.

Q: When would you choose asyncio over threads? A: For I/O-bound work with very high concurrency — thousands to tens of thousands of simultaneous connections. Asyncio runs on a single thread with a cooperative event loop, so each task is a lightweight coroutine rather than an OS thread, using far less memory and avoiding lock hazards. Threads are simpler for a modest number of I/O tasks (dozens to hundreds) and don’t require an async rewrite. The trap in asyncio is that a single blocking call — a time.sleep, a CPU loop, a synchronous DB driver — freezes the entire event loop, so everything on the hot path must be non-blocking (await).

Q: What does PEP 703 change, and does it make locks unnecessary? A: PEP 703 adds an experimental free-threaded build of CPython (3.13+, compiled --disable-gil) where the GIL is gone and pure-Python threads run in parallel on all cores — the CPU speedup you currently only get from processes, without pickling. It does not make locks unnecessary; it makes them more necessary, because today the GIL accidentally serialises some operations and hides races, and removing it exposes every unsynchronised shared write. The APIs are unchanged. It’s not the default because single-threaded code is slower on that build, C extensions must be rebuilt for it, and it’s still stabilising (3.14 made it supported but still opt-in).

Q (coding): This code sometimes prints the wrong total. Fix it.

import threading
total = 0
def add_all(nums):
    global total
    for n in nums:
        total += n
threads = [threading.Thread(target=add_all, args=([1]*100_000,)) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(total)   # sometimes < 800000

A: total += n is a non-atomic read-modify-write shared across threads — a race. Guard it with a lock:

import threading
total = 0
lock = threading.Lock()
def add_all(nums):
    global total
    for n in nums:
        with lock:
            total += n

Because this locks on every element, it is also slow. The better design accumulates a local subtotal (no sharing, no lock) and adds it once under the lock — or, cleanest, uses ProcessPoolExecutor/ThreadPoolExecutor and sums the returned results, sharing nothing:

def add_all(nums):
    return sum(nums)              # pure, no shared state
# total = sum(ex.map(add_all, chunks))

Q (coding): Why does this hang, and how do you fix it?

import threading, queue
q = queue.Queue()
def worker():
    while True:
        item = q.get()
        print(item); q.task_done()
threading.Thread(target=worker, daemon=True).start()
for i in range(3): q.put(i)
q.join()
# ...program never ends cleanly

A: The worker loops forever on q.get(), which blocks once the queue empties, so the thread never exits — it only survives here because it is a daemon (a non-daemon worker would hang the whole program). The fix is a sentinel: send a stop signal and break on it.

def worker():
    while True:
        item = q.get()
        if item is None:          # sentinel
            q.task_done(); break
        print(item); q.task_done()
# after the real items:
q.put(None)                       # one sentinel per worker

Key takeaways


This lesson gave you the one distinction the rest of Python concurrency hangs on — CPU-bound versus I/O-bound, processes versus threads — and the habit of measuring rather than guessing. The next time a job pins one core while seven sleep, you will know in a sentence whether to add threads, reach for processes, or leave it serial, and you will have the numbers to prove you chose right.

pythonconcurrencythreadingmultiprocessinggillocksrace-conditionsdeadlockconcurrent-futuresprocesspoolexecutorthreadpoolexecutorqueueparallelismpep-703
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments