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

26Threads & the GIL — derived from refcounting

You have heard both halves of the folk wisdom, usually in the same breath: threads make Python faster, and threads don't help in Python. Both are true. The gap between them is where a beginner loses a weekend and blames the language. Back in Chapter 3 we gave every object a header carrying an ob_refcnt counter that ticks up and down as names come and go. It looked like private bookkeeping. In this chapter we take that one counter seriously across two cores, and it turns out to decide whether your program can use a second core at all. Here's the plan. We point two threads at the same heap, then watch a refcount race quietly corrupt memory. Out of the wreckage falls CPython's answer, the Global Interpreter Lock — not a wart someone forgot to delete, but a theorem. And the whole way through we keep asking the one question that settles the folklore: given refcounting, could Python ever safely let two threads run bytecode at once? By the end we can look at any slow program and say, in a single measurement, whether threads will rescue it or do nothing at all. The same lock that makes threads useless for computing leaves them untouched for waiting, and we'll know exactly why.

iolinked · chapter 26 — the checkpoints6 steps
$ sections covered in Threads & the GIL — derived from refcounting
01The one-question diagnosis
02A thread is a flow sharing the heap
03Why the GIL must exist
04One lock, one runner at a time
05The crunch test: I/O overlaps, CPU doesn't
06When threads still win

01The one-question diagnosis

Let's start with the trap, because almost everyone falls into it once. You read that threads make programs faster. So you wrap a loop in a ThreadPoolExecutor, run it, and watch: it comes back slower. Not a little slower. Measurably, embarrassingly slower, fans spinning up for nothing. The instinct is to decide Python is broken, or that you held it wrong. Neither is true. You reached for the right tool on the wrong workload. The difference between the two workloads isn't a matter of taste. It's physical: it lives in what the CPU is actually doing while the wall clock ticks. So before we write a single thread, we need the one question that sorts any program cleanly into one bucket or the other. Every later decision in this chapter falls straight out of the answer.

Here's the fork. A running program spends its wall-clock time in exactly one of two states, and down at the silicon they could not be more different. In the first, the core is busy computing. The instruction pointer never leaves your code. The pipeline retires an arithmetic, logic, or branch instruction essentially every cycle, and the ALU is hot. Summing a hundred million integers, resizing an image, hashing a password — this is CPU-bound work. The only way to finish sooner is more cycles per second, or more cores working the problem at the same time. In the second state, the core is waiting. Your thread issued a system call — read(), recv(), connect() — and the kernel put it to sleep in a wait queue. Linux calls this the uninterruptible-sleep, or D-state. The CPU is not slow here. It is idle for this task, descheduled, doing other people's work or nothing at all. It wakes your thread only when an interrupt fires: a packet arrives, a disk DMA transfer completes. Downloading fifty pages, querying a database, reading files — this is I/O-bound work, and the bottleneck is not computation at all. It is the waiting.

So here's the diagnostic question — call it the crunch test: if the network and the disk were infinitely fast, would this program finish instantly? If yes, the time was spent waiting, the work is I/O-bound, and the win is to overlap the waits. You keep many requests in flight so their idle stretches stack on top of each other instead of end to end. If instead the only cure is a faster CPU, the work is CPU-bound. No amount of overlapping helps. Only true parallelism — more cores actually executing — moves the needle. That single fork decides everything: threads, async, or processes.

You do not have to guess which state you are in. You can measure it, because the operating system already keeps two different clocks. time.perf_counter() measures wall time — elapsed reality, including every second spent asleep in a kernel queue. time.process_time() measures only CPU time actually charged to your process, the cycles you truly burned. Hold them up against the same function, and the ratio confesses the workload:

classify.pypython
import time

def fetch_url(url):            # pretend network: the CPU does almost nothing
    time.sleep(2)              # blocked in the kernel — not computing
    return f"<html>{url}</html>"

def crunch(n):                 # pure arithmetic: the ALU never rests
    return sum(i * i for i in range(n))

def classify(fn, *args):
    wall0, cpu0 = time.perf_counter(), time.process_time()
    fn(*args)
    wall = time.perf_counter() - wall0
    cpu  = time.process_time()  - cpu0
    verdict = "CPU-bound" if cpu / wall > 0.5 else "I/O-bound"
    print(f"{fn.__name__:9} wall={wall:5.2f}s cpu={cpu:5.2f}s  {verdict}")

classify(fetch_url, "example.com")   # wall=2.00s cpu=0.00s  I/O-bound
classify(crunch, 10_000_000)         # wall=0.80s cpu=0.80s  CPU-bound

Read the two comment lines like an X-ray. The fetch burned two full seconds of wall time and essentially zero CPU — the process was asleep, the core free. The crunch spent its wall time and its CPU time in lockstep. Every second of clock was a second of computation. That cpu / wall ratio is the whole diagnosis in one number: near 1 means you were computing, near 0 means you were waiting. Everything the rest of this chapter says about the GIL is downstream of this split, because the GIL punishes the crunch and leaves the waiting untouched.

Where the time actually goeswall-clock time →I/O-boundblocked in kernel wait queuesyscall → leaves CPUwakes → back on CPUCPU-boundALU work — never leaves the CPUA faster network,or a faster CPU?
Fig — An I/O-bound task is mostly idle time spent blocked in the kernel, so a faster CPU barely helps — a CPU-bound task is solid ALU work, so only a faster core does.
THE ONE QUESTION TO CARRY FORWARD
Before you pick threads, async, or processes, ask: would infinitely fast I/O make this finish instantly? Yes → I/O-bound, overlap the waits. No → CPU-bound, you need real parallelism. The cpu/wall ratio measures it for you: near 0 is waiting, near 1 is working.
Wait — time.sleep(2) is a suspiciously honest stand-in for a network call. It really does hand control to the kernel and park your thread in a wait queue with the CPU released — exactly like a real recv(). That is why every benchmark in this chapter can fake I/O with sleep and stay physically truthful: to the scheduler, waiting on a socket and waiting on a timer are the same D-state.

The classifier says overlap the waits for I/O and use more cores for compute. But to see why one works in Python and the other refuses to, you first have to know what a thread actually is — and what, alarmingly, it shares. →

02A thread is a flow sharing the heap

To predict what threads do, you have to know precisely what a thread is — and, more sharply, what it is not. The dangerous half is what a thread does not get: its own copy of your data. In Volume 1 every object lived on the heap behind a header — ob_refcnt, ob_type, then the payload — at a real address in RAM, and every name was a pointer to it. When you spin up a second thread, none of that is duplicated. The list is not cloned. The dict is not cloned. Your module globals are not cloned. Both threads reach into the same heap and touch the same objects.

Formally, a process is an address space plus one or more threads of execution. Each thread is scheduled independently by the OS kernel, and each owns exactly three private things. It has its own call stack — its chain of frame objects, its local variables. It has its own instruction pointer, which line it is on. And it has its own register set, the handful of CPU registers the kernel saves and restores every time it switches threads. That is the entire list of what is private. Everything else is common property: the heap and every Python object on it, the loaded bytecode, the open file descriptors, the interpreter's own state. So when thread A and thread B both name results, they are not holding two similar lists. They hold the identical 8-byte pointer to the identical header at the identical address. One list, two arrows.

Now add the part that turns “shared” into “dangerous.” The kernel does not switch threads politely at the end of your Python statements. It switches on a timer interrupt, at an arbitrary machine instruction — possibly halfway through evaluating a single expression — saving A's registers and restoring B's. This interleaving is invisible from your source code and non-deterministic run to run. A thread can be frozen mid-reach, its half-finished work exposed, while another thread walks up to the very same object and starts changing it. Hold that image: two arrows into one list on one heap, and a scheduler that can pause either arrow between any two instructions. Every race condition in the chapter is already implied by that picture.

You can prove the sharing is literal, not a metaphor, in five lines. Use id(), which in CPython returns the object's actual heap address, and threading.get_ident(), which returns the OS-level identity of the running thread:

one_heap.pypython
import threading

shared   = []            # one list object, at one address on the heap
seen_ids = []

def worker():
    shared.append(threading.get_ident())   # which thread am I?
    seen_ids.append(id(shared))            # what address is the list at?

t1 = threading.Thread(target=worker)
t2 = threading.Thread(target=worker)
t1.start(); t2.start()
t1.join();  t2.join()

print(shared)      # [140192..., 140188...]  — two DIFFERENT thread ids
print(seen_ids)    # [4501...840, 4501...840] — ONE identical address

Two different thread identities, appended into one list, which both threads saw at the same address. That is the whole point compressed into two numbers: the flows of control are separate, but the data underneath them is one and the same. Notice too that neither thread had to be handed the list — shared was simply a global, and globals are just names in the module object, which lives on the shared heap. There is no per-thread copy of your module. Everyone reads and writes the one original.

SYNTAX · threading.Thread — the ten lines you actually typetarget is the function object, args is a tuple, start() launches it and join() waits
# four moves. that is the whole API you need today. import threading t = threading.Thread(target=work, args=("A", 3)) -- BUILT. nothing is running yet. t.start() -- a real OS thread. RETURNS AT ONCE. t.is_alive() -- True until work() returns t.join() -- block HERE until it does. gives None. # two threads, the entire pattern, ten lines t1 = threading.Thread(target=work, args=("A", 3)) t2 = threading.Thread(target=work, args=("B", 3)) t1.start(); t2.start() -- both flows live. their order is undefined. t1.join(); t2.join() -- main waits for both before going on work("A", 3) -- CALLING it: no thread. runs here, to the end. threading.Thread(target=work("A", 3)) -- the classic bug: called it, threaded the RESULT
target=The function object, written with no parentheses. You hand Python the function; the thread does the calling.
args=A tuple of positional arguments, unpacked into the call. One argument still needs its comma: ("A",).
kwargs=A dict for the keyword half: kwargs={"n": 3}. Same call, spelled the other way.
start()Asks the kernel for a real thread and returns immediately. Your next line runs while the target runs.
join()The only synchronisation you get free: this line blocks until that thread's function returns. Always gives None.
the return valueThrown away. work's return goes nowhere and join() will not hand it to you. Use a Queue or a pool.
flush=TrueWhen stdout is a pipe it is block-buffered, so lines arrive in clumps. Flushing makes what you see the order that happened.
you type
# ---------- skeleton.py ----------
import threading
import time


def tick(label, n):
    for i in range(n):
        print(f"{label}-{i}  ({threading.current_thread().name})", flush=True)
        time.sleep(0.05)


# 1. CALLING it: runs here, in this thread, now, to completion.
tick("direct", 2)

# 2. STARTING it: a real OS thread runs it; start() returns immediately.
t1 = threading.Thread(target=tick, args=("A", 3))
t2 = threading.Thread(target=tick, args=("B", 3))

t1.start()
t2.start()
print("main is free", flush=True)

t1.join()
t2.join()
print("both joined")
you see
$ python skeleton.py
direct-0  (MainThread)
direct-1  (MainThread)
A-0  (Thread-1 (tick))
B-0  (Thread-2 (tick))
main is free
A-1  (Thread-1 (tick))
B-1  (Thread-2 (tick))
B-2  (Thread-2 (tick))
A-2  (Thread-1 (tick))
both joined

$ python skeleton.py          # the very same file, run again
direct-0  (MainThread)
direct-1  (MainThread)
A-0  (Thread-1 (tick))
B-0  (Thread-2 (tick))
main is free
B-1  (Thread-2 (tick))
A-1  (Thread-1 (tick))
A-2  (Thread-1 (tick))
B-2  (Thread-2 (tick))
both joined
where beginners trip
  • target=work("A", 3) calls work in the main thread and threads its return value. Since that value is None, the thread starts and silently does nothing.
  • args=("alpha") is a string, not a tuple, and Python unpacks it: TypeError: work() takes 2 positional arguments but 5 were given.
  • t.start() twice raises RuntimeError: threads can only be started once. A Thread object is single-use; build a new one.
  • t.join() before t.start() raises RuntimeError: cannot join thread before it is started.
  • The two direct- lines finish before anything else begins. That is exactly what "calling it" means, and why start() is a different verb.
  • An exception in the target prints Exception in thread Thread-1 (tick): and kills that thread only. Main never sees it and the program exits 0.
  • Nothing orders the two threads. The two runs above already disagree, and yours will disagree with both.

This is exactly where the metal-up thread of Volume 2 splits in two. When you cross a process boundary — the subject of chapter 28 — fork() copies the entire address space. So processes share nothing by default and can never corrupt each other's objects. What passes between them is bytes, never live objects, which is precisely why processes escape the GIL. Threads are the opposite border: nothing is copied, everything is shared, live objects are touched directly by two cores at once. That single structural difference — copy-everything versus share-everything — is the root of every behavior in this chapter, including the lock we are about to prove must exist.

Threads
Share the whole heap. Two names → one object at one address. Cheap to start, dangerous to write. Bounded by the GIL.
Processes
Copy the address space. Share nothing by default. Objects must be flattened to bytes to cross. Escape the GIL.
One process, one heap, two threadsscheduler timer interrupt — can freeze either thread between any two bytecodesPROCESS — one address spaceshared HEAPlist objectob_refcnt: 2elementsThread 1 · own stackmain()append()IP ↑ mid-mutationThread 2 · own stackmain()append()IP ↑ mid-mutationmutatesmutates
Fig — Threads share the heap but not their stacks — so two private call stacks can reach the same object at once, and the scheduler may pause either one mid-access.
SYNTAX · daemon=True and name= — who waits for whom, and who is speakinga daemon is not a polite background worker: at exit the interpreter shoots it, and its finally never runs
# naming: costs one keyword, pays you back in every log line t = threading.Thread(target=job, name="keeper") -- default is Thread-1 (job) threading.current_thread().name -- who am I? "MainThread" up top. threading.enumerate() -- every live Thread, main included threading.active_count() -- len(threading.enumerate()). never 0. # daemon: the exit rule, and it is brutal t = threading.Thread(target=job, daemon=True) -- in the constructor t.daemon = True -- or before start(). after start: RuntimeError. # non-daemon -> the interpreter WAITS for it before it exits # daemon -> the interpreter KILLS it at exit. no finally. no cleanup.
name=A label carried into current_thread().name, into every traceback header, into your log lines. Free readability.
the default nameThread-1 (tick) — a counter plus the target's name. Fine for two threads, useless for twenty.
daemon=TrueSays do not wait for me. When the last non-daemon thread returns, the interpreter tears the process down with this one still mid-instruction.
what a daemon losesIts finally blocks, its with cleanups, its half-written file. Nothing runs on the way out, because nothing unwinds.
when to use oneOnly for work you can afford to lose mid-sentence: heartbeats, watchdogs, metric pollers. Never for writes.
enumerate()The live roster, main thread included. The cheapest way to answer "did I actually leak a thread?"
you type
# ---------- daemon_box.py ----------
import threading
import time


def job(label, secs):
    me = threading.current_thread().name
    try:
        print(f"  [{me}] {label} working", flush=True)
        time.sleep(secs)
        print(f"  [{me}] {label} finished", flush=True)
    finally:
        print(f"  [{me}] {label} cleanup", flush=True)


keeper = threading.Thread(target=job, args=("short", 0.4), name="keeper")
ghost  = threading.Thread(target=job, args=("long", 5.0), name="ghost", daemon=True)

keeper.start()
ghost.start()

print("alive       :", [t.name for t in threading.enumerate()], flush=True)
print("daemon flags:", keeper.daemon, ghost.daemon, flush=True)

keeper.join()                 # we wait for this one
print("main: returning -- nobody waits for a daemon", flush=True)
you see
$ python daemon_box.py
  [keeper] short working
  [ghost] long working
alive       : ['MainThread', 'keeper', 'ghost']
daemon flags: False True
  [keeper] short finished
  [keeper] short cleanup
main: returning -- nobody waits for a daemon

# and that is the entire output. the process exits after 0.4s,
# not 5s. "ghost long finished" never prints -- fine, it was cut short.
# but "ghost long cleanup" never prints either, and that one is a promise
# the language just broke. finally: does not run on a killed daemon.
where beginners trip
  • The word "daemon" suggests a tidy background service. It means the opposite: an unowned thread the interpreter is free to kill mid-instruction.
  • Setting t.daemon = True after start() raises RuntimeError: cannot set daemon status of active thread. Decide before you launch.
  • A daemon holding a half-written file leaves a half-written file. There is no flush, no close, no finally.
  • Daemons are not how you cancel work. Python has no thread-kill; a worker stops when its function returns, so you must ask it to.
  • A non-daemon thread you forgot to join() still keeps the process alive. Programs that "hang at the end" are usually one of these.
  • A thread inherits daemon from its creator, so a thread started by a daemon is a daemon too, whether you meant it or not.
THE SHARING IS THE HAZARD
Because there is one object and two arrows, any moment thread A trusts an object to hold still — read its length, then index it — thread B can change it in between. The convenience (no copying, instant sharing) and the danger (mid-flight corruption) are the same fact seen from two sides.

Two threads, one heap, a scheduler that pauses either one between any two instructions. Now aim both of them at the one field every object carries — ob_refcnt — and watch the crash assemble itself. →

03Why the GIL must exist

This is the spine of the chapter, and its payoff. You have been told the GIL is an embarrassment, a bottleneck someone should simply delete. By the end of this section you will see it is nothing of the kind. It falls straight out of Volume 1's refcounting as an inevitability. Take refcounting seriously across the two shared-heap threads you just met, and a global lock — or a lock on every single object — stops being a design choice. It becomes forced.

Recall the mechanism from chapter 3. Every object carries ob_refcnt, a count of how many references point at it. Every time a reference is created or destroyed — an assignment, passing an argument, returning a value, a name going out of scope — CPython runs Py_INCREF or Py_DECREF. And here is the fact that detonates everything: at the machine level, incrementing a counter in memory is not one instruction. It is three. LOAD the current count from RAM into a CPU register. ADD one to the register. STORE the register back to RAM. A read, a modify, a write — and, as you learned last section, the scheduler can fire a timer interrupt between any two of them.

Now interleave two threads over one object whose count is 2. Thread A executes LOAD and pulls 2 into its register. The timer fires. The kernel freezes A, its register still privately holding 2, and runs Thread B. B does the whole tick cleanly: LOAD 2, ADD to 3, STORE 3. The count in RAM is now correctly 3. The kernel switches back to A, which has no idea anything happened. It resumes from where it stood, with its stale 2, ADDs to get 3, and STOREs 3. Two increments occurred. The count should read 4. It reads 3. One increment vanished into the gap — a classic lost update.

A lost increment is bad. The mirror case is catastrophic. Run the same race on Py_DECREF and you can lose a decrement the same way — but worse, an interleaving can drive the count to 0 while a live reference still exists. Reaching 0 is CPython's signal to free the object: call its destructor, return its memory to the allocator. So the object is torn down and its bytes recycled while another thread is still holding a pointer into it. The next time that thread dereferences its now-dangling pointer, it reads freed memory — a use-after-free. That is not a wrong answer; that is a segfault, or worse, silent corruption. The very mechanism that makes CPython's memory management deterministic becomes, under naive threading, a machine for crashing.

You can watch the lost-update half happen in pure Python, no C required. Spell the read-modify-write out by hand, so the interpreter can be preempted between the pieces:

lost_update.pypython
import threading

counter = 0

def bump():
    global counter
    for _ in range(1_000_000):
        tmp = counter        # LOAD  — read the count into a local
        tmp = tmp + 1        # ADD   — bump the private copy
        counter = tmp        # STORE — write it back; preemptible before this

a = threading.Thread(target=bump)
b = threading.Thread(target=bump)
a.start(); b.start(); a.join(); b.join()

print(counter)   # the race in principle — see the honesty note below

Here is the honest 3.12 wrinkle. This exact loop happens to print 2,000,000 on current CPython, because since 3.10 the interpreter only offers to switch threads at a few instructions, and none of them fall inside this tiny window. The race is real, but it needs a genuine preemption point between the LOAD and the STORE — put a function call in that window and updates are lost by the hundreds of thousands, as the lock box below shows with captured numbers. When the race does fire, every shortfall is one thread's LOAD stomped by the other's STORE — the exact race, made visible in your own code. And ob_refcnt is bumped this way not once per million iterations but on every reference operation the interpreter performs, millions of times a second, on every object alive. You can even watch the count move:

a live sessionpython
>>> import sys
>>> x = object()
>>> sys.getrefcount(x)     # 2: your name x, plus getrefcount's own argument
2
>>> y = x                    # a second name — the count ticks up
>>> sys.getrefcount(x)
3
>>> del y                     # drop it — the count ticks down
>>> sys.getrefcount(x)
2

So the fix space has exactly two shapes. Option one: make every refcount tick atomic, or give every object its own lock. Correct — and ruinous. There are millions of tiny objects, and a per-object lock on each is enormous overhead. Atomic increments force the CPU to serialize the cache line on every tick, which murders performance on the single-threaded programs that are the overwhelming majority. Option two: a single Global Interpreter Lock that a thread must hold to touch any Python object. Then refcount ticks are never concurrent, because only one thread runs at a time. CPython chose option two. One mutex. Near-zero cost in the common single-threaded case, and refcounting stays sane — hence deallocation, hence all of C-level memory safety. The GIL is the price of refcounting without per-object locks.

Read–modify–write: the lost updateob_refcnt = 2Thread AThread Btime ↓LOAD reg ← count (2)timer interrupt — A pausedLOAD reg ← count (2)ADD reg = 3STORE count ← 3A resumes — register still holds stale 2ADD reg = 3 (stale!)STORE count ← 3count = 3 — should be 4the mirror image on DECREF frees an object that is still in use:count = 1A: DECREF → 0free() ☠B: use-after-free
Fig — Two threads each read 2 and each write 3, so one increment silently vanishes — and the same interleaving on DECREF frees a live object. This is exactly what the GIL exists to prevent.
SYNTAX · threading.Lock — the one line that makes a read-modify-write wholeCPython solved its own version of this race with the GIL; with lock: is how you solve yours
import threading lock = threading.Lock() -- one lock per thing you are protecting with lock: -- acquire. blocks if another thread holds it. seen = total -- | seen = step(seen) -- | THE CRITICAL SECTION: indivisible total = seen -- | -- release, on the way out. even if it raised. lock.acquire() -- the long form. you pair it yourself... try: ... finally: lock.release() -- ...and a missed release hangs every other thread lock.acquire(timeout=0.5) -- False if it did not get it in time threading.RLock() -- re-entrant: the SAME thread may acquire twice
Lock()One bit and a wait queue. One thread holds it; every other thread that asks parks in the kernel until it drops.
with lock:The law. __enter__ acquires, __exit__ releases, and the release happens even when the body raises. Never hand-roll the pair.
the critical sectionEverything between acquire and release. Keep it short: while you hold it, every other interested thread is doing nothing.
what it protectsWhatever you say it protects. A lock guards nothing on its own. It is a convention every writer of that data must keep.
RLock()Same idea, but the owning thread may acquire again. For a guarded method that calls another guarded method.
the priceReal, and visible below: correct, and about six times slower, because two threads now queue for one baton two million times.
you type
# ---------- lock_box.py ----------
import threading
import time

TIMES = 1_000_000
total = 0
lock  = threading.Lock()


def step(n):                 # a call, so the GIL really can change hands here
    return n + 1


def bump():                  # the race
    global total
    for _ in range(TIMES):
        seen  = total        # LOAD
        seen  = step(seen)   # <-- handoff point
        total = seen         # STORE


def bump_safe():             # the same three lines, made indivisible
    global total
    for _ in range(TIMES):
        with lock:
            seen  = total
            seen  = step(seen)
            total = seen


def run(fn, label):
    global total
    total = 0
    t0 = time.perf_counter()
    ts = [threading.Thread(target=fn) for _ in range(2)]
    for t in ts: t.start()
    for t in ts: t.join()
    print(f"{label:9} {total:>9,} of 2,000,000   {time.perf_counter() - t0:5.2f}s")


run(bump,      "no lock")
run(bump,      "no lock")
run(bump_safe, "with lock")
run(bump_safe, "with lock")
you see
$ python lock_box.py
no lock   1,371,227 of 2,000,000    0.14s
no lock   1,616,963 of 2,000,000    0.15s
with lock 2,000,000 of 2,000,000    0.95s
with lock 2,000,000 of 2,000,000    0.98s

$ python lock_box.py
no lock   1,784,646 of 2,000,000    0.16s
no lock   1,841,344 of 2,000,000    0.15s
with lock 2,000,000 of 2,000,000    1.00s
with lock 2,000,000 of 2,000,000    0.90s

# four unguarded runs, four different wrong answers.
# four guarded runs, one right answer, every time, at 6x the cost.
where beginners trip
  • The race needs a real preemption point between the read and the write — here, the step() call. Delete that call and CPython 3.12 quietly returns the exact answer, because the interpreter only checks for a thread switch at a few instructions. That is an accident of the current build, not a promise, and it is the worst kind of correct.
  • A lock is not attached to the data. Every function that touches total must take the same lock object, or the guard is decoration.
  • Wrapping the whole for loop in with lock: is not a fix, it is a rewrite. You get a single-threaded program with extra machinery.
  • Missing the release() in the try/finally form parks every other thread forever. A deadlock does not crash; it just stops.
  • lock.acquire() twice from one thread deadlocks it against itself. That single case is the whole reason RLock exists.
  • The GIL is not your lock. It serialises ob_refcnt so the interpreter survives. total is yours to defend.
THE GIL IS A THEOREM, NOT A WART
Given (1) reference counting and (2) real preemptive OS threads sharing one heap, unsynchronized refcount ticks race, and a lost DECREF is a use-after-free. You must serialize access to ob_refcnt. Per-object locking is correct but crushingly slow for millions of tiny objects; one global lock is correct and nearly free single-threaded. The GIL is the forced consequence of chapter 3, not a mistake bolted on top.
Wait — if the GIL is inevitable, how does Python 3.13's free-threaded build remove it? By paying option one's bill at last: refcounts become atomic (with a fast biased/deferred scheme for the common cases), and objects that need it get per-object locks. It works — and it is measurably slower single-threaded, exactly the tax the GIL was avoiding. Which is the final proof of the theorem: the GIL was always about protecting ob_refcnt, and the only way to delete it was to protect ob_refcnt some other way.

The lock must exist — accepted. But “the GIL” is still a fog until you know the exact rule for who holds it and when they hand it off. That rule explains every surprising thing threads do next. →

04One lock, one runner at a time

You have accepted the lock. Now you need its operating manual, because without the precise handoff rule “the GIL” stays a vague fog and threads keep surprising you. The confusion to kill first: are Python threads even real? Yes. Emphatically. threading.Thread creates a genuine operating-system thread — a pthread on Linux and macOS, a native thread on Windows. The kernel schedules them, they can be placed on different physical cores, they are not a simulation. What they cannot do is run the interpreter simultaneously, because advancing the bytecode evaluation loop requires holding the GIL, and there is one GIL. Real threads; one baton; they take turns carrying it.

The rule in a single line: a thread must hold the GIL to execute Python bytecode. Everything else is the mechanics of how the baton gets passed, and it passes in exactly two ways.

The first is voluntary release. Whenever a thread is about to make a blocking call into C — a syscall, time.sleep, a socket recv, a file read — CPython wraps the call in the macro pair Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS. The first macro drops the GIL right before the thread goes to sleep in the kernel. The second reacquires it when the call returns. The logic is obvious once you see it. A thread that is about to block for two seconds has no use for the interpreter lock during those two seconds, so it lets go, and some other thread gets to run. This one mechanism is the entire reason I/O-bound threading works — hold that thought for the next section.

The second is involuntary preemption. A thread doing pure arithmetic never makes a blocking call, so it would never voluntarily let go. It could hog the baton forever and starve every other thread. To prevent that, the interpreter forces a checkpoint. Since Antoine Pitrou's 3.2 redesign, CPython uses a time-based switch interval, default 5 milliseconds, which you can read with sys.getswitchinterval(). A background timer sets a flag — the eval_breaker — when the interval elapses. The running thread checks that flag at the boundary between bytecode instructions. When it sees the flag set, it releases the GIL and signals a waiting thread to take it. And here is the load-bearing detail: the release happens only between bytecode instructions, never in the middle of one.

a live sessionpython
>>> import sys
>>> sys.getswitchinterval()      # seconds a thread may hold the GIL before a forced checkpoint
0.005
>>> import dis
>>> dis.dis("x += 1")          # one Python line — several bytecodes
  LOAD_NAME     0 (x)
  LOAD_CONST    0 (1)
  BINARY_OP    13 (+=)
  STORE_NAME    0 (x)

Now the two halves click together. Because the GIL is released only between bytecodes, any operation that is a single bytecode is effectively atomic. No other thread can wedge itself into the middle of it. A dict[key] = value insert compiles to one STORE_SUBSCR. It cannot be interrupted mid-store, so concurrent inserts into one dict never corrupt the dict's internal structure. But x += 1, as the disassembly shows, is four bytecodes: load, load, add, store. The scheduler can preempt between the ADD and the STORE, and you are back in the exact lost-update race from section 3, on your own variable this time. This is the single most misunderstood thing about the GIL. It protects ob_refcnt, the interpreter's private bookkeeping, so the interpreter never crashes. It does not protect the logical invariants of your program. Your counter, your list-of-results, your two-line “read balance, then write balance” — those span multiple bytecodes and remain fully racy.

TYPE THIS · save it, then run itrace_and_lock.py — the same job six times: three wrong answers, then three right ones, all timed
"""race_and_lock.py -- one job, run twice: unguarded, then under a Lock."""
import sys
import threading
import time

ROUNDS = 2_000_000
WORKERS = 2

total = 0
lock = threading.Lock()


def charge(seen):
    """One step of work. Being a call, it is also a real preemption point."""
    return seen + 1


def unguarded():
    global total
    for _ in range(ROUNDS):
        seen = total            # LOAD  -- read the shared value
        seen = charge(seen)     # the GIL can change hands right here
        total = seen            # STORE -- write it back, stale or not


def guarded():
    global total
    for _ in range(ROUNDS):
        with lock:              # nobody can sit between LOAD and STORE
            seen = total
            seen = charge(seen)
            total = seen


def run(fn, label):
    global total
    total = 0
    t0 = time.perf_counter()
    threads = [threading.Thread(target=fn) for _ in range(WORKERS)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    secs = time.perf_counter() - t0
    want = ROUNDS * WORKERS
    print(f"{label:9} total={total:>9,}  want={want:,}  lost={want - total:>9,}  {secs:5.2f}s")


print("switch interval:", sys.getswitchinterval(), "s   workers:", WORKERS)
for _ in range(3):
    run(unguarded, "no lock")
for _ in range(3):
    run(guarded, "with lock")
$ python race_and_lock.py
switch interval: 0.005 s   workers: 2
no lock   total=2,682,966  want=4,000,000  lost=1,317,034   0.28s
no lock   total=2,882,105  want=4,000,000  lost=1,117,895   0.28s
no lock   total=3,235,045  want=4,000,000  lost=  764,955   0.28s
with lock total=4,000,000  want=4,000,000  lost=        0   1.84s
with lock total=4,000,000  want=4,000,000  lost=        0   5.02s
with lock total=4,000,000  want=4,000,000  lost=        0   5.62s
Save it and run it, and read the first three lines before anything else. One program, three runs, three different answers, all of them wrong. That is the signature of a race: no crash, no traceback, no exception to catch. Just a number that is quietly incorrect and incorrectly in a different way each time. Now look at the size of the loss, because it is far bigger than the story suggests. A quarter of a second holds only a few dozen forced handoffs, yet over a million increments vanish. Here is why. When a thread is preempted between its charge() call and its total = seen, it wakes holding a value it read a whole timeslice ago. It writes that stale value back, rolling the counter over everything the other thread accomplished in between. One badly-placed handoff does not cost you one update. It costs you a slice of work. Now the second half of the output. The same three lines, wrapped in with lock:, land on 4,000,000 — every run, on every machine, for as long as the program exists. That is not luck. While one thread is inside the block the other is parked in the kernel, so nothing can wedge itself between the LOAD and the STORE. And then the honest part: the price. The unguarded runs take 0.28 s; the guarded ones take 1.84 s, then 5.02 s, then 5.62 s. Correct, six to twenty times slower, and wildly variable — because two threads now queue for one baton four million times, and how long each of those waits takes is the operating system's business, not yours. Your numbers will differ from mine and from each other, and that spread is the measurement, not noise inside it. Two things to try before moving on. Raise WORKERS to 8: the unguarded total gets dramatically worse, the guarded total stays exactly 8 × 2,000,000. Then move with lock: outside the for loop and time it again. It becomes fast and stays correct — because you have just written a single-threaded program that pays for two threads.

One more confirmation that these are real kernel threads and not green threads. They share a process id, but each has its own OS-level identity. And two of them pinned to pure computation still deliver only one core's worth of throughput — because the baton, not the cores, is the bottleneck.

real_threads.pypython
import os, threading

def who():
    print(f"pid={os.getpid()}  native_id={threading.get_native_id()}")

who()
t = threading.Thread(target=who); t.start(); t.join()
# same pid (one process), different native_id (two real OS threads)
One GIL baton, three real threadswall-clock time →T1T2T3runs bytecoderuns bytecoderunsblocked in syscallgrabs baton, runs5ms tick5ms ticksyscall: T3 drops batonholds the GIL (runs)blocked / waiting — real OS threads, one baton
Fig — The GIL is a single baton: a thread is forced to hand it off at a bytecode boundary every ~5ms, or drops it voluntarily the moment it enters a blocking syscall.
NOW WRITE IT YOURSELFis list.append actually atomic? — two shared structures, one of them corrupt, and you must say which by reading bytecode
First, build the broken program. Call it tally.py. At the top put import sys, threading and then sys.setswitchinterval(1e-6), which forces the interpreter to hand the GIL over as often as it can — you are not creating the bug, you are raising its odds so it shows in one run instead of one deploy. Make two module-level shared things: seen = [] and counts = {"total": 0}. Write scan(tag) that loops 100_000 times doing exactly two statements: seen.append(tag) and counts["total"] = counts.get("total", 0) + 1. Start four threads with tags 0 to 3, join them, then print len(seen) and counts["total"]. Both should be 400,000. Second, predict, then run, then explain. Before you press Enter, write down which of the two you expect to be wrong. Run it. One is exactly right every single time and one is catastrophically short — and you must not accept "lists are thread-safe" as the reason, because it is folklore. Get the real answer from the machine: import dis and disassemble a function containing both statements. Count the bytecodes each one compiles to, and find, for each, whether there is a CALL sitting between the read of the shared value and the write back to it. That single structural fact decides both outcomes. Then say precisely why the switch interval mattered for one line and was irrelevant for the other. Third, fix it twice and time both. Fix A: guard the counter with a module-level threading.Lock(), wrapping the read-modify-write and nothing else. Fix B: take the counter out of the shared world altogether — each thread counts into a plain local, and merges once, under the lock, after its loop. Both are correct; one is several times faster than the other and faster than the broken version. Predict which, then measure with time.perf_counter(), then explain the gap in one sentence about contention. One hint and no more: count how many times each fix acquires the lock.
show the solution
# ---------- tally.py ----------
"""Is list.append atomic? Yes. Is a read-modify-write? No."""
import sys
import threading
import time

sys.setswitchinterval(1e-6)          # force frequent handoffs, expose the bug fast
PER, WORKERS = 100_000, 4
WANT = PER * WORKERS

lock = threading.Lock()


def drive(body, label):
    seen, counts = [], {"total": 0}
    t0 = time.perf_counter()
    ts = [threading.Thread(target=body, args=(k, seen, counts)) for k in range(WORKERS)]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    secs = time.perf_counter() - t0
    print(f"{label:22} len(seen)={len(seen):>7,}  counts={counts['total']:>7,}"
          f"  of {WANT:,}  {secs:5.2f}s")


def broken(tag, seen, counts):
    for _ in range(PER):
        seen.append(tag)                                  # (A) one C call
        counts["total"] = counts.get("total", 0) + 1      # (B) get, add, store


def fix_lock(tag, seen, counts):
    for _ in range(PER):
        seen.append(tag)
        with lock:                                        # make (B) one step
            counts["total"] = counts.get("total", 0) + 1


def fix_local(tag, seen, counts):
    mine = 0
    for _ in range(PER):
        seen.append(tag)
        mine += 1                                         # nothing shared: no race
    with lock:                                            # merge once, at the end
        counts["total"] = counts.get("total", 0) + mine


drive(broken,    "broken")
drive(broken,    "broken")
drive(fix_lock,  "fix 1: lock each tick")
drive(fix_local, "fix 2: count locally")


# ------------- $ python tally.py -------------
broken                 len(seen)=400,000  counts=151,292  of 400,000   0.41s
broken                 len(seen)=400,000  counts=146,577  of 400,000   0.55s
fix 1: lock each tick  len(seen)=400,000  counts=400,000  of 400,000   1.57s
fix 2: count locally   len(seen)=400,000  counts=400,000  of 400,000   0.23s

# ------------- $ python tally.py  (again) -------------
broken                 len(seen)=400,000  counts=148,513  of 400,000   0.52s
broken                 len(seen)=400,000  counts=144,148  of 400,000   0.57s
fix 1: lock each tick  len(seen)=400,000  counts=400,000  of 400,000   1.48s
fix 2: count locally   len(seen)=400,000  counts=400,000  of 400,000   0.23s


# ---------- the machine's answer: dis, not folklore ----------
>>> import dis
>>> def f(bag, counts, tag):
...     bag.append(tag)
...     counts['n'] = counts.get('n', 0) + 1
>>> dis.dis(f)
  4    LOAD_FAST     0 (bag)
       LOAD_ATTR     1 (NULL|self + append)
       LOAD_FAST     2 (tag)
       CALL          1                 <-- the ENTIRE mutation is inside this one op
       POP_TOP

  5    LOAD_FAST     1 (counts)
       LOAD_ATTR     3 (NULL|self + get)
       LOAD_CONST    1 ('n')
       LOAD_CONST    2 (0)
       CALL          2                 <-- READ happens here...
       LOAD_CONST    3 (1)
       BINARY_OP     0 (+)
       LOAD_FAST     1 (counts)
       LOAD_CONST    1 ('n')
       STORE_SUBSCR                    <-- ...and WRITE happens here. a gap.


# The three answers.
#
# 1. len(seen) is 400,000 every run. list.append IS atomic -- but not because
#    "lists are thread-safe." It is atomic because the whole mutation lives
#    inside a single CALL bytecode, executing C code that holds the GIL for its
#    entire duration. There is no instant at which another thread can observe a
#    half-appended list, so there is nothing to interleave with.
#
# 2. counts['total'] loses ~60% of its updates. The read (CALL to .get) and the
#    write (STORE_SUBSCR) are separate bytecodes with a gap between them, and a
#    CALL is one of the few instructions where CPython checks whether to hand
#    the GIL over. So the handoff lands exactly in the gap. A thread wakes with
#    a value it read a timeslice ago and stomps it back over everyone's work --
#    which is why the loss is hundreds of thousands, not a handful.
#
# 3. The switch interval only changes how OFTEN a handoff is offered. It cannot
#    make append racy (there is no gap to land in) and it cannot make the
#    counter safe (the gap is structural). Shrinking it just converts a bug you
#    would meet in production into one you meet in 0.4 seconds.
#
#
# Fix 2 wins, and by a lot: 0.23s against 1.57s -- and it beats the BROKEN
# version too. Fix 1 acquires and releases the lock 400,000 times, and every
# acquisition that finds the lock held parks a thread in the kernel and wakes
# it again. That is contention, and contention is the cost. Fix 2 acquires it
# 4 times, once per thread. The general rule this is teaching: the cheapest
# lock is the one you do not take. Keep per-thread work per-thread, and share
# only the summary.
THE GIL DOES NOT MAKE YOUR CODE THREAD-SAFE
It guarantees the interpreter won't crash by serializing refcount ticks. It says nothing about your logic. x += 1 is four bytecodes with preemption points between them, so it races just like any hand-rolled read-modify-write. For your own invariants you still need a Lock or a queue.Queue.
Wait — the switch interval is time-based, not instruction-based — 5 milliseconds, not “every N bytecodes,” which was the pre-3.2 scheme (every 100 ticks). Why time? Because 100 bytecodes might be a microsecond of trivial ops or a millisecond of heavy ones; a wall-clock budget makes the timeslice fair regardless of what each op costs. You can shrink it with sys.setswitchinterval() and watch threads interleave more finely — at the cost of more handoff overhead.

You now hold the two release rules — voluntary on blocking, forced every 5ms. Point them at the two workloads from section 1 and the folklore finally resolves into arithmetic: why compute threads flatline and wait threads fly. →

05The crunch test: I/O overlaps, CPU doesn't

Theory earns its keep only when it predicts a measurement you can run yourself. So take the two release rules from the last section — voluntary drop on blocking, forced 5ms handoff otherwise — and push both workloads from section 1 through them. This is where “I added threads and it got slower” finally gets its full explanation. And this is where you get to watch the two outcomes diverge.

Start with CPU-bound. Two threads each summing a hundred million integers. Neither ever makes a blocking call, so neither ever voluntarily releases the GIL. The only handoff is the forced 5ms checkpoint, and while one thread holds the baton, the other is parked, not computing. So the total arithmetic is unchanged and now serialized through one lock. Two threads do not split the work. They take turns doing all of it. Wall time comes out roughly equal to the single-threaded time, plus the overhead of thousands of GIL acquire/release handoffs and context switches. That is why it often runs slightly slower, and never near half. Run it on 1, 2, 4 threads and the wall-time line stays flat, or drifts upward. That flat line is the definitive fingerprint of the GIL.

SYNTAX · ThreadPoolExecutor — the five lines that retire every manual Threadmap for a batch in argument order, submit for one Future, and the with block does your join()
from concurrent.futures import ThreadPoolExecutor, as_completed # the batch form. this is the one you will reach for 90% of the time. with ThreadPoolExecutor(max_workers=8) as pool: results = list(pool.map(fetch, urls)) -- results in ARGUMENT order -- leaving the block joins every worker. there is no join() left to forget. # the one-job form. submit hands back a Future immediately. with ThreadPoolExecutor(max_workers=8) as pool: fut = pool.submit(fetch, "levels") -- returns AT ONCE, work in flight val = fut.result() -- blocks HERE. re-raises any exception. # completion order, not argument order -- take each result as it lands futures = {pool.submit(fetch, n): n for n in names} for fut in as_completed(futures): name = futures[fut] -- the dict is how you get the label back
max_workers=How many blocking waits you keep in flight. For I/O this is about what the service tolerates, never about your core count.
pool.map()A lazy iterator. Results come back in argument order however they finished, so list() it inside the block.
pool.submit()Fire one job, get a Future now. The work is already running before the next line of yours does.
Future.result()Blocks until that job finishes, then hands you the return value — or re-raises the exception it died of, in your thread.
as_completed()Yields futures in finishing order, fastest first. The map from future back to input is a dict you build yourself.
the with blockCalls shutdown(wait=True) on exit, so leaving it blocks until every job is done. That is the join() you no longer write.
you type
# ---------- pool_box.py ----------
import time
from concurrent.futures import ThreadPoolExecutor, as_completed


def fetch(name):
    time.sleep(0.3)
    if name == "bad":
        raise ValueError(f"no such track: {name}")
    return f"{name}:{len(name)}"


names = ["levels", "titanium", "alive", "bad"]

# 1. map -- results in ARGUMENT order; an exception surfaces when you read it
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
    it = pool.map(fetch, names[:3])
    print("map      ", list(it), f"{time.perf_counter()-t0:.2f}s")

# 2. submit -> Future -- results in COMPLETION order, one failure isolated
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
    futures = {pool.submit(fetch, n): n for n in names}
    for fut in as_completed(futures):
        name = futures[fut]
        try:
            print("  done  ", name, "->", fut.result())
        except ValueError as exc:
            print("  failed", name, "->", type(exc).__name__, exc)
print(f"submit    {time.perf_counter()-t0:.2f}s")
you see
$ python pool_box.py
map       ['levels:6', 'titanium:8', 'alive:5'] 0.30s
  done   levels -> levels:6
  done   titanium -> titanium:8
  failed bad -> ValueError no such track: bad
  done   alive -> alive:5
submit    0.30s

# four 0.3s waits finished in 0.30s, and the one failure
# stayed inside its own Future instead of killing the batch.
where beginners trip
  • pool.map is lazy. Nothing is raised, and sometimes nothing is even collected, until you consume it — so list() it inside the with.
  • An exception in a worker is stored in its Future, not printed. Never call .result() and you will never learn the job failed.
  • With map, the first exception you hit stops your iteration. With submit plus as_completed, each failure is isolated to its own future.
  • max_workers=os.cpu_count() is the wrong instinct for I/O. Cores are irrelevant when the work is waiting; size it by fan-out.
  • A pool of threads is still threads. On CPU-bound work it is still one baton — swap in ProcessPoolExecutor, same five lines, real cores.
  • as_completed gives you futures, not inputs. Keep a {future: label} dict or you will not know which result is which.
crunch_bench.pypython
import time
from concurrent.futures import ThreadPoolExecutor

def crunch(n):
    return sum(i * i for i in range(n))

N = 30_000_000

t = time.perf_counter()                       # sequential: do it twice, back to back
crunch(N); crunch(N)
print("2x sequential:", round(time.perf_counter() - t, 2), "s")

t = time.perf_counter()                       # two threads: hoping for half the time
with ThreadPoolExecutor(max_workers=2) as pool:
    list(pool.map(crunch, [N, N]))
print("2 threads    :", round(time.perf_counter() - t, 2), "s")
# 2x sequential: 4.10 s      2 threads: 4.30 s   — no speedup, a little worse

Now I/O-bound, same harness shape, but the work is a blocking wait instead of arithmetic. Each thread's time.sleep(2) — physically identical to a socket wait — hits Py_BEGIN_ALLOW_THREADS and drops the GIL before parking in the kernel. So while thread A sleeps, the baton is free. Thread B grabs it and starts its own sleep, dropping the baton again for C, and so on. All the waits are in flight at once. Four two-second sleeps that took eight seconds end to end now finish in about two, because the idle time — which was the entire cost — overlaps for free.

io_bench.pypython
import time
from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    time.sleep(2)          # blocking wait — GIL released the whole time
    return url

urls = ["a", "b", "c", "d"]

t = time.perf_counter()
for u in urls: fetch(u)              # sequential: waits stack end to end
print("sequential:", round(time.perf_counter() - t, 2), "s")   # ~8.0 s

t = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
    list(pool.map(fetch, urls))       # 4 threads: waits overlap
print("4 threads :", round(time.perf_counter() - t, 2), "s")   # ~2.0 s

Put the two results side by side and the whole chapter resolves into a single sentence. The GIL serializes bytecode execution; it does not serialize kernel waiting. CPU work is bytecode execution, so it serializes — flat line, no speedup. I/O work is kernel waiting with the GIL released, so it overlaps — near-linear speedup until the network or disk saturates. And now the folklore is finally exact. Threads win precisely to the degree your program is waiting rather than computing. That is nothing but section one's cpu/wall ratio read a second time. A ratio near 0 means all waiting, so threads overlap it away. A ratio near 1 means all computing, so the GIL turns your threads into a queue.

CPU-bound under threads
No blocking call, so no voluntary release. Handoff only every 5ms; the other thread parks. Work serializes through one baton. 2 threads ≈ 1's time, often worse.
I/O-bound under threads
Each blocking call drops the GIL. Waits proceed in parallel in the kernel. N waits finish in ≈ the time of one. Speedup until the pipe saturates.
Adding a thread: when it helps, when it doesn'tCPU-boundI/O-bound1 thread2 threadsswitch cost≠ 2×≈ 2×running bytecode (GIL held)GIL released — kernel wait (overlaps freely)
Fig — CPU work can't overlap under the GIL, so a second thread only adds switch cost; I/O work waits in the kernel with the GIL released, so waits stack and total time roughly halves.
TYPE THIS · save it, then run itdownload_simulation.py — eight metadata lookups, sequential then pooled, both timed on the same clock
"""download_simulation.py -- eight metadata lookups, sequential then pooled."""
import time
from concurrent.futures import ThreadPoolExecutor

TRACKS = ["Blinding Lights", "Titanium", "Levels", "Midnight City",
          "Instant Crush", "Rather Be", "Alive", "Wake Me Up"]

LATENCY = 0.40      # stand-in for one blocking HTTP round trip


def fetch_meta(title):
    """Pretend network call. sleep() really does park the thread and drop the GIL."""
    time.sleep(LATENCY)
    return {"title": title, "seconds": 120 + 7 * len(title)}


def timed(label, fn):
    t0 = time.perf_counter()
    rows = fn()
    secs = time.perf_counter() - t0
    print(f"{label:12} {len(rows)} tracks in {secs:5.2f}s")
    return secs, rows


seq_s, rows = timed("sequential", lambda: [fetch_meta(t) for t in TRACKS])


def pooled():
    with ThreadPoolExecutor(max_workers=8) as pool:
        return list(pool.map(fetch_meta, TRACKS))


par_s, rows = timed("8 threads", pooled)

print()
print(f"speedup      {seq_s / par_s:.2f}x   (perfect overlap would be {len(TRACKS)}.00x)")
print(f"cpu time     {time.process_time():.3f}s of {seq_s + par_s:.2f}s wall -- almost none of it was work")
print(f"first row    {rows[0]}")
$ python download_simulation.py
sequential   8 tracks in  3.21s
8 threads    8 tracks in  0.41s

speedup      7.86x   (perfect overlap would be 8.00x)
cpu time     0.062s of 3.61s wall -- almost none of it was work
first row    {'title': 'Blinding Lights', 'seconds': 225}

$ python download_simulation.py
sequential   8 tracks in  3.21s
8 threads    8 tracks in  0.41s

speedup      7.83x   (perfect overlap would be 8.00x)
cpu time     0.016s of 3.61s wall -- almost none of it was work
first row    {'title': 'Blinding Lights', 'seconds': 225}
This is the payoff program of the chapter, and it is nine real lines of work. Eight lookups at 0.40 s each: 3.21 s in a row, 0.41 s through a pool of eight. A speedup of 7.86× out of a theoretical 8. Then read the line most people skip. Across 3.61 seconds of wall clock, the process burned 0.062 seconds of CPU time — about one and a half percent. That number is the whole chapter in one measurement. The other 98% was spent parked in a kernel wait queue with the GIL released, which is precisely why eight threads could stack their waits on top of each other. The GIL was never in the way, because nobody was executing bytecode. Now the honesty, and please build the habit here. Run it twice and the numbers move: 7.86× then 7.83×, and the CPU line swung from 0.062 s to 0.016 s — a factor of four on a measurement of nearly nothing. Timings are samples, not constants. What is stable is the shape: sequential near 3.2 s, pooled near 0.4 s, ratio near 8. What is not stable is any single digit, and a benchmark you ran once is a rumour. The 0.41 s is also honest about its own overhead: it is not 0.40 s, because starting eight OS threads and collecting eight results costs about ten milliseconds you cannot wish away. Three things to try. Drop max_workers to 2 and predict the wall time before you run it — four rounds of two, so around 1.6 s. Push it to 16 with only 8 tracks and watch nothing improve, because you cannot overlap waits you do not have. Then swap the body of fetch_meta for sum(i * i for i in range(4_000_000)), keeping everything else identical, and watch the same pool deliver no speedup at all. Same code, same executor, opposite result — the workload decided, not the tool.
THE UNIFYING LINE
The GIL serializes bytecode execution, not kernel waiting. So threads do nothing for computation (it's all bytecode) and everything for I/O (it's all waiting, with the lock released). “Threads made it slower” = you threaded a CPU-bound job and paid handoff overhead for zero parallelism.
Wait — if two CPU threads just take turns, why is 2 threads slower than 1, not merely equal? Two taxes. First, every 5ms one thread must release the GIL, wake another, and the kernel must context-switch registers and often reload cache — thousands of times over a long crunch. Second, on multiple cores the two threads can thrash the GIL's own signaling (the historic “convoy” effect). Same total arithmetic, extra coordination cost — so the line doesn't just stay flat, it tilts the wrong way.
Interactive · the crunch dialdrag the ratio
One number decides whether 8 threads helpspeedup from N threads ← vs → threads, for the ratio you setideal · 8 real coresGIL wall · 1×148threads8 threads, 8 cores8.0×actual speedupI/O-bound — threads fly8 tasks · wall timeseq 8.0s1.0s
0.05
At cpu/wall = 0.05 the work is almost all waiting, so 8 threads overlap it into a 5.9× win. Slide toward 1.00 and watch the curve peel off the ideal line and flatten onto the GIL wall.
Fig — The same 8 threads on the same 8 cores: near-8× when the work is waiting, exactly 1× when it is computing. The whole outcome is one number — the cpu/wall ratio from §1 — read through Amdahl’s law, N / (1+(N−1)·r), where the GIL makes the compute fraction the serial part.

So threads are a scalpel, not a hammer — useless on compute, decisive on waiting. The last question is the positive one: exactly when do you reach for them, and how do you share results without re-opening the race? →

06When threads still win

The wrong lesson to walk away with is “threads are useless in Python.” They are not. They are specialized, and knowing their exact envelope means you reach for them with confidence, instead of over-engineering an async rewrite or spinning up processes you don't need. Threads win wherever the bottleneck is waiting — which, thanks to the GIL-release-on-blocking rule, is exactly the work the GIL lets overlap. Two canonical patterns cover almost every real case.

The first is I/O concurrency: many blocking calls in flight at once — dozens of HTTP requests, database queries, file reads. Each spends nearly all its time asleep in a kernel wait with the GIL released. So a ThreadPoolExecutor of N workers gives roughly N-way overlap, right up until the network, disk, or remote service saturates. This is the pragmatic, drop-in win. It needs no async rewrite and works with any ordinary blocking library — requests, a database driver, open(). If your code is already written in plain synchronous style and it is I/O-bound, a thread pool is very often the shortest path from slow to fast.

fan_out.pypython
import time
from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    time.sleep(1)                 # stand-in for a real blocking request
    return len(url)

urls = [f"https://example.com/{i}" for i in range(20)]

t = time.perf_counter()
with ThreadPoolExecutor(max_workers=20) as pool:
    results = list(pool.map(fetch, urls))
print("20 requests in", round(time.perf_counter() - t, 2), "s")
# ~1.0 s instead of ~20 s — the twenty waits overlapped

The second is responsiveness: a GUI event loop or a CLI must keep answering input while a slow operation runs. Run the slow blocking call on a worker thread. Because it releases the GIL while it waits, the main thread keeps getting the baton, and it keeps repainting the window or reading keystrokes. The app stays alive instead of freezing. The classic “UI hangs while a download runs” bug is exactly a blocking call left on the main thread. Moving it to a worker fixes it, precisely because of the release rule you now understand.

stay_alive.pypython
import time, threading

def slow_job():
    time.sleep(3)                     # blocking work, off the main thread

worker = threading.Thread(target=slow_job)
worker.start()

while worker.is_alive():           # the 'UI loop' keeps running
    print("still responsive…", flush=True)
    time.sleep(0.5)
worker.join()
print("done")              # the ticks kept printing the whole 3 seconds

Now the honest boundary, because this chapter promised no half-truths. Threads share the heap — that was section 2. So the moment two threads write the same mutable object, your invariants are back in play, and the GIL will not save them. The GIL protects ob_refcnt. It does not protect your counter, your growing list of results, your “read-then-write.” The disciplined fix is to hand data between threads rather than share it. queue.Queue is thread-safe by construction: workers put results, the main thread gets them, so you never touch one object from two threads at once. When you truly must share, wrap the critical section in a Lock.

SYNTAX · queue.Queue — the hand-off that removes the shared object entirelya lock protects a thing two threads touch; a queue means only one thread ever touches it
import queue q = queue.Queue() -- unbounded FIFO. maxsize=N gives you backpressure. q.put(item) -- producer side. blocks only when maxsize is reached. item = q.get() -- consumer side. BLOCKS until something arrives. q.task_done() -- "that one is finished." exactly one per get(). q.join() -- block until every put() has its task_done() # the producer/consumer skeleton -- THE pattern for real work def consumer(): while True: job = q.get() try: if job is DONE: -- the poison pill: your only shutdown signal return results.put(handle(job)) -- hand the answer back, never a shared object finally: q.task_done() -- in finally, so a raise still counts it for _ in workers: q.put(DONE) -- one pill per worker. they do not share one.
Queue()A FIFO with its own internal lock and two condition variables. Every method is safe; you never write acquire yourself.
get()Blocks the calling thread until an item exists. That block is a kernel wait, so an idle worker costs no CPU and holds no GIL.
task_done()Decrements the unfinished-task counter. Exactly one per get(), and it belongs in a finally.
join()Waits on that counter reaching zero — not on the queue looking empty. The two are different questions.
maxsize=Backpressure. A fast producer blocks on put instead of growing the queue until the machine runs out of memory.
the poison pillPython cannot kill a thread. A sentinel object one worker consumes and returns on is how you ask it to stop.
you type
# ---------- queue_box.py ----------
import queue
import threading
import time

jobs = queue.Queue()          # the hand-off: thread-safe by construction
results = queue.Queue()
DONE = object()               # the poison pill


def consumer():
    while True:
        job = jobs.get()               # blocks until something arrives
        try:
            if job is DONE:            # time to go home
                return
            time.sleep(0.1)            # the blocking part (GIL released)
            results.put((job, job * job))
        finally:
            jobs.task_done()           # one item accounted for


workers = [threading.Thread(target=consumer, name=f"w{i}", daemon=True)
           for i in range(4)]
for w in workers:
    w.start()

t0 = time.perf_counter()
for n in range(12):               # producer: the main thread
    jobs.put(n)

jobs.join()                       # wait until every put() has a task_done()
print(f"12 jobs, 4 workers: {time.perf_counter() - t0:.2f}s")

for _ in workers:                 # one pill per worker, then reap them
    jobs.put(DONE)
for w in workers:
    w.join()

collected = []
while not results.empty():
    collected.append(results.get())
collected.sort()

print("results :", collected[:3], "...", collected[-2:])
print("count   :", len(collected), " threads still alive:", threading.active_count())
you see
$ python queue_box.py
12 jobs, 4 workers: 0.30s
results : [(0, 0), (1, 1), (2, 4)] ... [(10, 100), (11, 121)]
count   : 12  threads still alive: 1

# 12 jobs x 0.1s = 1.2s of waiting, done in 0.30s by 4 workers.
# all 12 results present, and every worker reaped: only MainThread left.
where beginners trip
  • q.get() on an empty queue blocks forever. That is the feature, and it is also why a program with no poison pills hangs at exit.
  • q.join() waits on the unfinished-task counter, not on emptiness. Miss one task_done() and it never returns, silently.
  • One task_done() too many raises ValueError: task_done() called too many times. Put it in a finally, once per get().
  • q.empty() was true when you asked and may be false a microsecond later. Never use it to decide you are finished.
  • The queue is safe; what you put in it is not. Put values in, not a mutable object two threads will keep editing.
  • One pill does not stop four workers. The first one to get() it eats it and leaves; the other three wait forever.
safe_handoff.pypython
import time, queue, threading

results = queue.Queue()          # thread-safe mailbox — no Lock needed

def worker(job):
    time.sleep(0.5)               # the blocking part (GIL released)
    results.put(job * job)         # hand the result back safely

threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
for t in threads: t.start()
for t in threads: t.join()

total = 0
while not results.empty():
    total += results.get()
print("collected", total)      # correct every run — no shared mutable object was raced

Finally, close the loop back to section 1's fork, because the whole chapter is one decision tree. Ask the question: faster network or faster CPU? If the answer is network, the work is I/O-bound. Threads are your tool for moderate fan-out and any blocking library. For very high fan-out, thousands of native thread stacks and their context-switch cost become the ceiling, and async's cooperative single-thread model wins instead (chapter 27). If the answer is CPU, the work is CPU-bound, and threads are the wrong tool entirely. Use processes to get real parallelism, or drop into a C extension that releases the GIL while it computes (chapter 28). Threads are not the general-purpose speed button the folklore sold you. They are the precise instrument for making a program that waits stop waiting one thing at a time — and now you know exactly why.

SYNTAX · the decision rule — when threads help, and when they cost youthreads pay off exactly as far as your program waits; measure cpu/wall before you write a line of it
# the rule. there is only one, and it is not about cores. # threads help exactly as much as your program WAITS. # YES -- the work is a blocking wait. the GIL is released the whole time. def hit(_): time.sleep(0.5) -- socket, disk, db round trip with ThreadPoolExecutor(4) as p: list(p.map(hit, range(4))) -- 4 waits overlap # NO -- the work is pure bytecode. the GIL is held the whole time. def crunch(_): return sum(i * i for i in range(8_000_000)) with ThreadPoolExecutor(4) as p: list(p.map(crunch, range(4))) -- they take turns # the measurement you run BEFORE you commit to either cpu = time.process_time() -- seconds actually charged to your CPU wall = time.perf_counter() -- seconds of reality that elapsed # cpu/wall near 0 -> waiting -> threads (or async, at huge fan-out) # cpu/wall near 1 -> computing -> processes (or a GIL-releasing C extension)
the one questionWould infinitely fast I/O make this finish instantly? Yes means thread it. No means you need real cores.
the yes caseFour half-second sleeps: 3.98× on four workers. Every wait sits in the kernel with the GIL dropped, so they stack.
the no caseFour arithmetic loops: 0.83× on four workers — slower than one. Same total work, plus thousands of handoffs you paid for.
process_time()CPU seconds charged to you. Sleeping does not increase it, which is what makes the ratio a diagnosis and not an opinion.
the real exceptionC code that releases the GIL while it computes — hashlib, zlib, most of NumPy. Those are parallel under threads.
sizing itFor I/O, max_workers is how many waits you keep in flight, bounded by the far end. os.cpu_count() is the wrong number.
you type
# ---------- when_box.py ----------
import time
from concurrent.futures import ThreadPoolExecutor

N = 8_000_000


def wait_one(_):                 # I/O-bound: one blocking wait
    time.sleep(0.5)


def crunch(_):                   # CPU-bound: pure arithmetic, no syscall
    return sum(i * i for i in range(N))


def timed(fn, workers, tasks=4):
    t0 = time.perf_counter()
    with ThreadPoolExecutor(max_workers=workers) as pool:
        list(pool.map(fn, range(tasks)))
    return time.perf_counter() - t0


for fn, label in ((wait_one, "sleep(0.5) x4"), (crunch, "sum(i*i) x4  ")):
    one = timed(fn, 1)
    four = timed(fn, 4)
    print(f"{label}  1 worker {one:5.2f}s   4 workers {four:5.2f}s   speedup {one/four:4.2f}x")
you see
$ python when_box.py
sleep(0.5) x4  1 worker  2.00s   4 workers  0.50s   speedup 3.98x
sum(i*i) x4    1 worker  6.86s   4 workers  8.25s   speedup 0.83x

$ python when_box.py
sleep(0.5) x4  1 worker  2.00s   4 workers  0.51s   speedup 3.95x
sum(i*i) x4    1 worker  7.02s   4 workers  8.19s   speedup 0.86x

# one file, one executor, one machine with 32 idle cores.
# the workload picked the outcome, and it picked opposite ones.
where beginners trip
  • The CPU fingerprint is not "no faster," it is slower: 0.83× here. The handoffs cost real time and bought nothing back.
  • 32 idle cores did not help the crunch by one millisecond. Cores are not the resource threads are short of; the baton is.
  • The exception is genuine and it is already installed. hashlib drops the GIL while it hashes, so four large digests really do run four-wide under threads.
  • More workers stops helping the moment the far end saturates. Past that, extra threads buy you stacks and switches only.
  • Mixed work gets a partial win and Amdahl's law decides how partial. Measure the ratio; do not argue about it.
  • "It was slower with threads" is not a mystery, it is a diagnosis. You threaded bytecode. Reach for ProcessPoolExecutor instead.
Which tool for which bottleneck?Faster network, or faster CPU?networkCPUI/O-bound (waiting)CPU-bound (busy)THREADS (ThreadPool)moderate fan-out / blocking libsasync (ch 27)huge fan-out, one corePROCESSES (ch 28)or a C extensionkeep the UI alivemain thread (responsive)worker thread (blocks)queue.Queue
Fig — Start from the bottleneck: waiting work goes to threads or async by fan-out, busy work goes to processes — and a worker thread plus a queue keeps a UI thread from freezing.
NOW WRITE IT YOURSELFtwo drills — fill in a producer/consumer skeleton until it terminates cleanly, then judge three real workloads
Drill A — fill the skeleton. Here is the job, described the way a colleague would. Fifteen pages need fetching. Each fetch blocks for about 0.15 s and returns a word count. Four workers should share the list. When every page is done, print the elapsed time, the total word count, and how many pages each worker handled. Write it with two queues and nothing else shared: work for the pages and done for the answers. The consumer is an infinite while True that calls work.get(), checks for a sentinel and returns, otherwise puts (worker_name, page, count) onto done, and calls work.task_done() in a finally. The main thread is the producer: start four named threads, put all fifteen pages, then work.join(), then stop the clock. Only after that, send the pills and join the threads. Three things must be true when you run it. Elapsed is near 0.60 s, not 2.25 s. Exactly fifteen rows come back. And threading.active_count() prints 1 at the end, proving you reaped every worker rather than leaving four blocked in get() forever. Get all three and you have written the pattern that carries almost every threaded program you will ever ship. Then break it deliberately, twice. Delete the work.task_done() line and run it: say exactly which line hangs and why the queue is not empty enough to matter. Put it back, then send one pill instead of four and run it again: say which threads are still alive and what each is blocked on. Drill B — threads or not. Three real workloads. For each, answer threads / processes / neither, then justify in one sentence naming the mechanism, not the vibe. (1) A script that polls 200 REST endpoints, each taking roughly 300 ms to respond. (2) A script that computes SHA-256 digests of four 64 MB blobs. (3) A script that sums a 50-million-element Python list of ints. One of these three has an answer that will surprise you, and you can settle it in ten lines with ThreadPoolExecutor and time.perf_counter(). Do not guess it — measure it, then explain the measurement.
show the solution
# ---------- DRILL A: pages.py ----------
"""A producer/consumer word-count over 15 'pages', 4 worker threads."""
import queue
import threading
import time

PAGES = [f"page-{i:02d}" for i in range(15)]

work = queue.Queue()
done = queue.Queue()
STOP = object()


def fetch_and_count(page):
    time.sleep(0.15)                       # the blocking part -- GIL released
    return len(page) * 3                   # pretend word count


def consumer():
    me = threading.current_thread().name
    while True:
        item = work.get()
        try:
            if item is STOP:
                return
            done.put((me, item, fetch_and_count(item)))
        finally:
            work.task_done()               # every get() gets exactly one


workers = [threading.Thread(target=consumer, name=f"w{i}") for i in range(4)]
for w in workers:
    w.start()

t0 = time.perf_counter()
for page in PAGES:                         # the producer is the main thread
    work.put(page)
work.join()                                # every item accounted for
elapsed = time.perf_counter() - t0

for _ in workers:
    work.put(STOP)
for w in workers:
    w.join()

rows = []
while not done.empty():
    rows.append(done.get())

per_worker = {}
for who, _page, _n in rows:
    per_worker[who] = per_worker.get(who, 0) + 1

print(f"15 pages, 4 workers : {elapsed:.2f}s   (sequential would be 2.25s)")
print("total words         :", sum(n for _w, _p, n in rows))
print("pages per worker    :", dict(sorted(per_worker.items())))
print("rows collected      :", len(rows), " threads alive:", threading.active_count())


# ------------- $ python pages.py -------------
15 pages, 4 workers : 0.60s   (sequential would be 2.25s)
total words         : 315
pages per worker    : {'w0': 4, 'w1': 3, 'w2': 4, 'w3': 4}
rows collected      : 15  threads alive: 1

# ------------- $ python pages.py  (again) -------------
15 pages, 4 workers : 0.60s   (sequential would be 2.25s)
total words         : 315
pages per worker    : {'w0': 4, 'w1': 4, 'w2': 3, 'w3': 4}
rows collected      : 15  threads alive: 1

# 0.60s is 15 pages in 4 rounds of 0.15s -- 4 rounds because 15 does not
# divide by 4. The per-worker split moves between runs (3+4+4+4, in some
# order) and that is correct: nothing assigns pages, the workers race for
# them, and whoever finishes a sleep first grabs the next one.


# ---------- the two deliberate breaks ----------
#
# 1. Delete work.task_done(): the program hangs at work.join(), forever.
#    join() does not watch the queue for emptiness -- it watches an
#    unfinished-task counter that put() raises and task_done() lowers.
#    All 15 pages get fetched and all 15 results arrive in `done`, and the
#    counter still reads 15, so join() never returns. The work being finished
#    and the queue reporting it finished are two different facts.
#
# 2. Send one STOP instead of four: the first worker to reach get() takes it
#    and returns. The other three are blocked inside work.get() on an empty
#    queue with nothing left to arrive. Their w.join() calls never return, so
#    main hangs too -- and being non-daemon threads, they would have kept the
#    process alive even if main had finished. One pill per worker, always.


# ---------- DRILL B: threads or not ----------
#
# (1) 200 REST endpoints at ~300ms each  ->  THREADS.
#     Each request is ~0.3s parked in a kernel wait with the GIL released, and
#     essentially no bytecode. cpu/wall is near 0. A pool of, say, 20 workers
#     turns 60s into ~3s. The ceiling is the remote service, not Python. (Past
#     a few thousand in flight, the per-thread stack cost wins and async is the
#     answer instead -- that is chapter 27.)
#
# (2) SHA-256 of four 64MB blobs  ->  THREADS. This is the surprise.
#     It looks purely CPU-bound, and it is -- but hashlib releases the GIL
#     around the digest of any sizeable buffer, because that work happens in C
#     and touches no Python object. So the threads genuinely run on separate
#     cores. Measured, not guessed:
#
#         sha256 x4, 1 worker(s):  0.12s
#         sha256 x4, 4 worker(s):  0.03s        -- 4.0x, real parallelism
#
#     Same story for zlib, and for most of NumPy. The rule survives intact:
#     threads help while the GIL is not held, and a C extension that drops it
#     is just another way of not holding it.
#
# (3) Summing a 50-million-element list of ints  ->  NEITHER (use processes,
#     or do not thread it). Every add is interpreter bytecode, so the GIL is
#     held from the first element to the last. Two threads take turns and cost
#     you handoffs; expect around 0.85x, i.e. slower. ProcessPoolExecutor over
#     slices gives you real cores, at the price of pickling the slices across
#     the process boundary -- which is chapter 28's whole subject.
THE FOUR-WAY DECISION, ONE LAST TIME
I/O-bound + moderate fan-out or blocking libs → threads. I/O-bound + huge fan-out → async (ch27). CPU-bound → processes or a GIL-releasing C extension (ch28). And whenever threads share mutable state, pass it through a queue.Queue or guard it with a Lock — the GIL won't.
Wait — ThreadPoolExecutor(max_workers=20) for twenty requests is fine, but max_workers=20000 for twenty thousand is not — each thread carries a real OS stack (often ~8MB of address space reserved) and every extra thread adds context-switch cost. That ceiling is exactly the wall that motivates chapter 27: async runs tens of thousands of concurrent waits on one thread with one small stack, because it never blocks the OS thread at all — it suspends a coroutine instead.

Threads overlap waits but hit a per-thread memory wall at massive fan-out. Chapter 27 keeps the overlap and removes the wall — one thread, one event loop, thousands of suspended coroutines. That is async. →

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

Chapter 26 takes threads apart down to the refcount. These twelve runnable programs show what threads actually share (one heap, one set of globals), why `counter += 1` can lose updates while `d[k] = v` cannot (count the bytecodes), how a Lock or a Queue buys correctness back, and why the GIL exists at all — to guard the reference counts every object carries. Everything here is deterministic: races are shown either through exact locked totals or through a single hand-scheduled interleaving, never through timing.

Threads share one memory
A thread is not a copy of your program — it runs in the same address space, over the same globals and the same objects. These three show a spawned thread reading the same global, mutating the very object main handed it, and the start()/join() handshake that makes the result safe to read.
counter += 1 is not one step
The danger isn't threads, it's that counter += 1 is a read, a compute, and a write — separate bytecodes a second thread can slip between. d[k] = v, by contrast, is a single STORE_SUBSCR the GIL can't interrupt. The third program forces one bad interleaving by hand so the lost update is reproducible instead of a flaky race.
Serialize to make it correct
Correctness comes back the moment you serialize access. A Lock around the read-modify-write makes 200,000 increments land exactly; a queue.Queue hands items between threads with its own internal locking; a lock around list.append keeps every single entry. These totals are exact every run — the determinism IS the point.
The GIL guards the refcount
The GIL is not about your data — it's about CPython's own bookkeeping. Every object stores an integer reference count that changes every time a name binds or drops; two threads editing that counter at once would corrupt it, so one lock — the GIL — serializes the bytecode. The last program shows the pool that lets you exploit threads without ever touching a lock yourself.
end of chapter 26 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked