Beginner Examples
Examples 1-28 build the everyday vocabulary of Python concurrency: the concurrency-vs-parallelism distinction and the GIL, threads vs. processes and when each helps, the anatomy of a race condition (and proof, via dis, of exactly where the unsafe gap is), the core synchronization primitives (Lock, RLock, Semaphore, Event, Barrier, Condition), queue.Queue as a thread-safe hand-off channel, and the two highest-level concurrency tools -- thread/process pools and asyncio coroutines. Every example is a complete, self-contained, pyright --strict-clean example.py colocated under learning/code/, run for real on CPython 3.14 to capture its documented output, plus a companion test_example.py that asserts the same behavior under pytest.
Example 1: Concurrency vs. Parallelism, Illustrated
ex-01 · exercises co-01
Two coroutines run on one thread, interleaving their steps in a fixed order; two OS processes run on two cores, genuinely overlapping in time. This example runs both shapes back to back so the difference between "dealing with many things" and "doing many things at once" is visible, not just definitional.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
flowchart LR
subgraph Concurrency["Concurrency (interleaved)"]
direction LR
A1["A0"]:::blue --> B1["B0"]:::orange --> A2["A1"]:::blue --> B2["B1"]:::orange
end
subgraph Parallelism["Parallelism (simultaneous)"]
direction LR
C1["Process A: A0 A1 A2"]:::teal
C2["Process B: B0 B1 B2"]:::purple
end
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-01-concurrency-vs-parallelism-illustration/example.py
"""Example 1: Concurrency vs. Parallelism, Illustrated.""" # => co-01: interleaving one thing at a time vs truly simultaneous
import asyncio # => single-threaded cooperative concurrency (co-01)
import multiprocessing as mp # => true OS-level parallelism, separate processes (co-01)
import time # => used to measure wall-clock overlap for the parallelism half
async def worker(name: str, steps: int, log: list[str]) -> None: # => one "concurrent" task
for i in range(steps): # => each task takes several small steps
log.append(f"{name}{i}") # => records the step BEFORE yielding control
await asyncio.sleep(0) # => yields to the event loop -- co-27's "voluntary" handoff
# => control may now run the OTHER task's next step before this one resumes
async def concurrency_demo() -> list[str]: # => runs two coroutines on ONE thread
log: list[str] = [] # => shared list -- both coroutines append to it, in whatever order runs
await asyncio.gather( # => schedules BOTH coroutines on the same event loop
worker("A", 3, log), # => task A: 3 steps
worker("B", 3, log), # => task B: 3 steps -- interleaves with A, never truly simultaneous
) # => gather() returns once BOTH coroutines have fully completed
return log # => an INTERLEAVED sequence: A0, B0, A1, B1, ... (order proves cooperative handoff)
def cpu_burn(seconds: float) -> None: # => a process-local CPU-bound loop (used for parallelism)
end = time.monotonic() + seconds # => end is a wall-clock deadline this loop runs until
total = 0 # => a throwaway accumulator -- forces real CPU work, not an idle sleep
while time.monotonic() < end: # => keeps looping until the deadline passes
total += 1 # => pure CPU work, no I/O -- this is what a THREAD cannot parallelize (co-03)
_ = total # => silences "unused variable" -- the loop's side effect (time spent) is the point
def parallelism_demo() -> float: # => runs two OS processes SIMULTANEOUSLY on separate cores
duration = 0.3 # => each process burns CPU for this many seconds
start = time.monotonic() # => start is the wall-clock time before either process launches
procs = [mp.Process(target=cpu_burn, args=(duration,)) for _ in range(2)] # => two child processes
for p in procs: # => launches both -- each gets its OWN interpreter, OWN GIL (co-02)
p.start() # => start() forks/spawns a real OS process, not a lightweight thread
for p in procs: # => waits for both to finish
p.join() # => join() blocks until that specific process exits
elapsed = time.monotonic() - start # => elapsed is the WALL-CLOCK time for BOTH combined
return elapsed # => if truly parallel, elapsed is close to `duration`, not `2 * duration`
if __name__ == "__main__": # => required on macOS/Windows: multiprocessing re-imports this module
interleaved_log = asyncio.run(concurrency_demo()) # => runs the coroutine demo to completion
print(interleaved_log) # => Output: ['A0', 'B0', 'A1', 'B1', 'A2', 'B2']
parallel_elapsed = parallelism_demo() # => runs the two-process demo, returns wall time
print(f"parallel_elapsed < 0.6s: {parallel_elapsed < 0.6}") # => Output: parallel_elapsed < 0.6s: True
expected_log = ["A0", "B0", "A1", "B1", "A2", "B2"] # => the exact interleave order to require
assert interleaved_log == expected_log # => confirms A and B alternate, never both "at once"
assert parallel_elapsed < 0.6 # => confirms the two 0.3s CPU processes overlapped (not 0.6s serial)
print("ex-01 OK") # => Output: ex-01 OKRun: python3 example.py
Output:
['A0', 'B0', 'A1', 'B1', 'A2', 'B2']
parallel_elapsed < 0.6s: True
ex-01 OKlearning/code/ex-01-concurrency-vs-parallelism-illustration/test_example.py
"""Example 1: pytest verification for Concurrency vs. Parallelism, Illustrated."""
import asyncio
from example import concurrency_demo, parallelism_demo
def test_coroutines_interleave_on_one_thread() -> None:
# => two coroutines sharing one thread must ALTERNATE, never run "at once"
log: list[str] = asyncio.run(concurrency_demo())
assert log == ["A0", "B0", "A1", "B1", "A2", "B2"] # => strict interleave order
def test_two_processes_overlap_in_wall_time() -> None:
# => two 0.3s CPU-bound processes should finish close to 0.3s, not 0.6s, if truly parallel
elapsed = parallelism_demo()
assert elapsed < 0.6 # => generous margin: proves overlap without depending on exact timing
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Concurrency is about structure -- interleaving many things on one worker; parallelism is about hardware -- genuinely overlapping many things on multiple workers.
Why it matters: Every later example in this topic is one or the other, and confusing them leads to the wrong tool: reaching for multiprocessing to fix an I/O-bound stall (that's concurrency's job, asyncio or threads) or expecting threading to speed up CPU-bound math on CPython (that needs real parallelism, which the GIL blocks -- see ex-03). Naming the distinction first makes every later choice of tool a deliberate one, not a guess.
Example 2: Process vs. Thread Address Space
ex-02 · exercises co-02
A global variable mutated inside a new thread is visible from the main thread afterward, because threads share one address space. The identical mutation inside a child process is invisible to the parent, because multiprocessing gives the child its own private copy of everything.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
flowchart TB
G["counter = 0<br/>(parent process memory)"]:::blue
G -->|"shared address space"| T["Thread: counter += 1"]:::orange
G -.->|"copied at fork/spawn"| P["Child Process: counter += 1<br/>(its OWN private copy)"]:::teal
T -->|"parent sees counter = 1"| R1["Result: visible"]:::orange
P -.->|"parent's counter still 0"| R2["Result: invisible without IPC"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-02-process-vs-thread-address-space/example.py
"""Example 2: Process vs. Thread Address Space.""" # => co-02: threads share memory; processes each get their own
import multiprocessing as mp # => separate address space per process (co-02)
import threading # => shared address space per thread (co-02)
counter = 0 # => a module-level global -- lives in THIS process's address space only
def bump_in_thread() -> None: # => runs on a new THREAD, inside the SAME process
global counter # => refers to the one and only `counter` this process has
counter += 1 # => mutates the SHARED global -- visible to every thread in this process
def bump_in_process(q: "mp.Queue[int]") -> None: # => runs in a CHILD process (own memory copy)
global counter # => refers to the CHILD's OWN copy of `counter`, not the parent's
counter += 1 # => mutates only the child's private copy -- the parent never sees this
q.put(counter) # => the ONLY way to get a result back out is explicit IPC (a Queue, co-20)
if __name__ == "__main__": # => required: multiprocessing re-imports this module in the child
thread = threading.Thread(target=bump_in_thread) # => builds a thread sharing this process
thread.start() # => runs bump_in_thread() concurrently
thread.join() # => waits for it to finish
thread_saw = counter # => thread_saw is 1 -- the thread DID mutate the shared global
queue: "mp.Queue[int]" = mp.Queue() # => an IPC channel -- processes share NOTHING else
proc = mp.Process(target=bump_in_process, args=(queue,)) # => builds a child process
proc.start() # => forks/spawns a full copy of this process's memory, own `counter = 0`
proc.join() # => waits for the child to exit
child_saw = queue.get() # => child_saw is 1 -- the CHILD's own private counter incremented
parent_after_process = counter # => parent_after_process is STILL thread_saw (1), unchanged
print(f"thread_saw={thread_saw} child_saw={child_saw} parent_after={parent_after_process}") # => Output: thread_saw=1 child_saw=1 parent_after=1
# => "isolated" is the whole point of a process boundary: no accidental sharing, ever.
assert thread_saw == 1 # => confirms the thread mutated the PARENT's shared global
assert child_saw == 1 # => confirms the child incremented its OWN isolated copy to 1
assert parent_after_process == thread_saw # => confirms the child's mutation never crossed back
print("ex-02 OK") # => Output: ex-02 OKRun: python3 example.py
Output:
thread_saw=1 child_saw=1 parent_after=1
ex-02 OKlearning/code/ex-02-process-vs-thread-address-space/test_example.py
"""Example 2: pytest verification for Process vs. Thread Address Space."""
import multiprocessing as mp
import threading
import example
def test_thread_mutates_shared_global() -> None:
example.counter = 0 # => reset the module global before this test's own measurement
t = threading.Thread(target=example.bump_in_thread)
t.start()
t.join()
assert example.counter == 1 # => the thread's mutation IS visible in this process
def test_process_does_not_mutate_parent_global() -> None:
example.counter = 5 # => set a distinctive parent value the child must never see change
q: "mp.Queue[int]" = mp.Queue()
p = mp.Process(target=example.bump_in_process, args=(q,))
p.start()
p.join()
child_value = q.get()
assert child_value == 1 # => child's OWN copy started fresh at 0 (module reloaded), now 1
assert example.counter == 5 # => parent's global is untouched by the child process
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Threads are cheap and dangerous because they share memory by default; processes are expensive and safe because they don't -- crossing a process boundary always requires explicit IPC (a Queue, a pipe, shared memory).
Why it matters: This single fact explains almost every tradeoff in the rest of the topic: threads need locks (ex-11) because they share state; processes need multiprocessing.Queue (ex-46) or shared_memory (ex-72) because they don't. Reaching for the wrong one -- threads for CPU work under the GIL, or processes for tightly-coupled shared state -- is one of the most common concurrency design mistakes, and it traces directly back to this isolation boundary.
Example 3: The GIL Serializes CPU-Bound Threads
ex-03 · exercises co-03, co-05
Two CPU-bound threads, each spinning a pure-Python loop, take roughly as long together as one thread alone would -- because CPython's Global Interpreter Lock (GIL) only ever lets one thread execute Python bytecode at a time, on this build. Running the same work as one thread versus two threads and comparing wall-clock time makes the serialization directly observable.
learning/code/ex-03-gil-serializes-cpu-threads/example.py
"""Example 3: The GIL Serializes CPU-Bound Threads."""
import threading # => co-06 threads, but this example is about their CPU-bound cost
import time # => measures wall-clock time to reveal the GIL's serialization
ITERATIONS = 4_000_000 # => tuned so one call takes a small, measurable fraction of a second
def cpu_task(n: int) -> int: # => pure arithmetic, no I/O -- exactly what the GIL cannot parallelize
total = 0 # => total is 0 -- accumulator, forces the interpreter to do real bytecode work
for i in range(n): # => a tight Python loop -- every iteration executes under the GIL (co-03)
total += i # => each `total += i` is itself a non-atomic load/add/store (co-10)
return total # => returns the checksum -- the VALUE doesn't matter, only the TIME spent
def run_serial() -> float: # => baseline: run cpu_task twice, one after another, on ONE thread
start = time.perf_counter() # => start is the wall-clock time before any work begins
cpu_task(ITERATIONS) # => first call: runs to completion before the second starts
cpu_task(ITERATIONS) # => second call: runs only after the first returns
return time.perf_counter() - start # => total wall time for BOTH calls, back to back
def run_threaded() -> float: # => same total work, but split across TWO threads
start = time.perf_counter() # => start is the wall-clock time before either thread launches
threads = [threading.Thread(target=cpu_task, args=(ITERATIONS,)) for _ in range(2)]
# => builds two Thread objects, each targeting cpu_task with the SAME iteration count
for t in threads: # => launches both threads
t.start() # => start() schedules the thread -- but only ONE runs Python bytecode at a time
for t in threads: # => waits for both to finish
t.join() # => join() blocks until that specific thread's cpu_task() call returns
return time.perf_counter() - start # => total wall time for both threads combined
if __name__ == "__main__": # => module entry point
serial_time = run_serial() # => serial_time: two cpu_task calls run one after another
threaded_time = run_threaded() # => threaded_time: the SAME two calls, split across 2 threads
print(f"serial={serial_time:.3f}s threaded={threaded_time:.3f}s") # => Output: serial=...s threaded=...s
# => If threads gave real parallelism, threaded_time would be near HALF of serial_time.
# => Instead, the GIL lets only one thread run Python bytecode at a time (co-03), so
# => threaded_time stays close to serial_time -- at best a little faster from OS scheduling
# => noise, never anywhere near a 2x speedup.
ratio = threaded_time / serial_time # => ratio near 1.0 means "no real speedup" from threading
print(f"ratio={ratio:.2f}") # => Output: ratio=~0.9-1.1 (never anywhere near 0.5)
assert ratio > 0.8 # => confirms threading did NOT deliver anything close to a 2x speedup
print("ex-03 OK") # => Output: ex-03 OKRun: python3 example.py
Output:
serial=0.234s threaded=0.246s
ratio=1.05
ex-03 OKlearning/code/ex-03-gil-serializes-cpu-threads/test_example.py
"""Example 3: pytest verification for The GIL Serializes CPU-Bound Threads."""
from example import run_serial, run_threaded
def test_threading_gives_no_real_speedup_on_cpu_bound_work() -> None:
# => on a GIL-enabled build, two CPU-bound threads should NOT run near 2x faster
serial_time = run_serial()
threaded_time = run_threaded()
ratio = threaded_time / serial_time
assert ratio > 0.8 # => generous floor: real parallelism would push this near 0.5
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: Threads do not parallelize CPU-bound Python work on the standard (GIL-enabled) build -- the GIL time-slices between threads, but never runs two of them simultaneously.
Why it matters: This is the single most consequential fact about Python concurrency: it is why threading is the right tool for I/O-bound work (ex-05) but the wrong tool for CPU-bound work, where multiprocessing (ex-25) or a free-threaded build (ex-04, ex-58) is required instead. Every benchmark later in this topic that contrasts threads vs. processes on CPU work (ex-49, ex-77) is a direct consequence of what this example demonstrates first.
Example 4: Detecting a Free-Threaded (No-GIL) Build
ex-04 · exercises co-04
sys._is_gil_enabled() reports whether the CURRENTLY running interpreter has the GIL active; on the standard build it is always True, and on a free-threaded python3.14t build it is False. This example checks the running interpreter's actual status and asserts the branch that matches it, so it is genuinely correct on either build, not just documentation about one.
learning/code/ex-04-free-threaded-build-check/example.py
"""Example 4: Detecting a Free-Threaded (No-GIL) Build."""
import sys # => sys carries the runtime introspection hooks this example uses
import sysconfig # => sysconfig exposes the BUILD-time flag, independent of runtime state
def gil_is_enabled() -> bool: # => wraps the version-gated check so callers don't repeat it
if hasattr(sys, "_is_gil_enabled"): # => `sys._is_gil_enabled` only exists on Python 3.13+
return sys._is_gil_enabled() # pyright: ignore[reportPrivateUsage]
# => leading underscore is CPython's naming, not a privacy signal -- this IS the documented
# => runtime check (co-04); True on a normal build, False on a running 3.14t build
return True # => pre-3.13: no free-threaded option existed, so the GIL was always enabled
def built_free_threaded() -> bool: # => was THIS interpreter binary compiled with PEP 703 support?
flag = sysconfig.get_config_var("Py_GIL_DISABLED") # => 1 if compiled --disable-gil, else 0/None
return bool(flag) # => True only for a `python3.14t`-style build, regardless of runtime state
if __name__ == "__main__": # => module entry point
runtime_gil_enabled = gil_is_enabled() # => runtime_gil_enabled: is the GIL ON right now?
compiled_free_threaded = built_free_threaded() # => compiled_free_threaded: was this a `t` build?
print(f"runtime_gil_enabled={runtime_gil_enabled}") # => Output: runtime_gil_enabled=True
print(f"compiled_free_threaded={compiled_free_threaded}") # => Output: compiled_free_threaded=False
# => On STANDARD CPython 3.13/3.14 (what this file was verified against), both print exactly
# => this: the GIL is enabled and this binary was not built free-threaded. A reader who
# => installs `python3.14t` (PEP 703/779; "[experimental]" checkbox in the macOS installer,
# => even though free-threading is officially "supported"/Phase II as of 3.14) would instead
# => see runtime_gil_enabled=False and compiled_free_threaded=True on this exact script.
assert runtime_gil_enabled is True # => confirms the GIL is serializing bytecode on THIS build
assert compiled_free_threaded is False # => confirms this is a normal (non-`t`) CPython build
print("ex-04 OK") # => Output: ex-04 OKRun: python3 example.py
Output:
runtime_gil_enabled=True
compiled_free_threaded=False
ex-04 OKlearning/code/ex-04-free-threaded-build-check/test_example.py
"""Example 4: pytest verification for Detecting a Free-Threaded (No-GIL) Build."""
from example import built_free_threaded, gil_is_enabled
def test_standard_build_reports_gil_enabled() -> None:
# => this suite runs on standard CPython, never the `python3.14t` free-threaded build
assert gil_is_enabled() is True
def test_standard_build_was_not_compiled_free_threaded() -> None:
assert built_free_threaded() is False
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: sys._is_gil_enabled() is the one-line way to detect, at runtime, whether a script is running under the free-threaded (PEP 703/779) build or the standard GIL-enabled build.
Why it matters: PEP 779 made free-threaded CPython officially "supported" as of Python 3.14, but it is not yet the default build anywhere -- a script that assumes one or the other will silently misbehave. Checking gil_is_enabled() explicitly is how library code adapts (e.g. deciding whether extra locking is needed) instead of assuming a GIL that might not be there. Note: on macOS, the official installer's free-threaded option is still labeled [experimental] even though PEP 779 calls the build "supported" -- the two statements describe different things (packaging polish vs. language-level guarantee) and both are simultaneously true.
Example 5: I/O-Bound Threads Actually Help
ex-05 · exercises co-05, co-06
Two threads that each time.sleep() (standing in for a blocking network call) run in less total wall-clock time than the same two sleeps run one after another, because a sleeping thread releases the GIL and lets the other proceed. This is the mirror image of ex-03: I/O-bound work, unlike CPU-bound work, genuinely benefits from threads even on the standard build.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
flowchart TD
subgraph Serial["Serial: 0.4s total"]
direction LR
S1["sleep A (0.2s)"]:::blue --> S2["sleep B (0.2s)"]:::orange
end
subgraph Threaded["Threaded: ~0.2s total"]
direction LR
T1["sleep A (0.2s)"]:::teal
T2["sleep B (0.2s)"]:::purple
T1 -.->|"GIL released during sleep -- B runs too"| T2
end
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-05-io-bound-threads-help/example.py
"""Example 5: I/O-Bound Threads Actually Help."""
import threading # => co-06 threads applied to I/O-bound work, unlike ex-03's CPU-bound case
import time # => `time.sleep` stands in for any blocking I/O call (network, disk, a DB driver)
SLEEP_SECONDS = 0.2 # => each "request" pretends to block on I/O for this long
def fake_io_call() -> None: # => simulates one blocking network/disk call
time.sleep(SLEEP_SECONDS) # => releases the GIL while blocked (co-05) -- OTHER threads can run
def run_serial(count: int) -> float: # => baseline: `count` I/O calls, one after another
start = time.perf_counter() # => start is the wall-clock time before any call begins
for _ in range(count): # => runs each fake_io_call() to completion before starting the next
fake_io_call() # => blocks THIS thread for SLEEP_SECONDS, back to back
return time.perf_counter() - start # => total wall time is close to count * SLEEP_SECONDS
def run_threaded(count: int) -> float: # => same `count` I/O calls, but each on its own thread
start = time.perf_counter() # => start is the wall-clock time before any thread launches
threads = [threading.Thread(target=fake_io_call) for _ in range(count)]
# => builds `count` Thread objects, each independently sleeping
for t in threads: # => launches every thread
t.start() # => start() -- while one thread sleeps, the GIL is free for the next (co-05)
for t in threads: # => waits for every thread to finish
t.join() # => join() blocks until that specific thread's sleep call returns
return time.perf_counter() - start # => total wall time is close to ONE SLEEP_SECONDS, not count*
if __name__ == "__main__": # => module entry point
calls = 4 # => how many "I/O calls" this demo makes, both ways
serial_time = run_serial(calls) # => serial_time: ~4 * 0.2s = ~0.8s, back to back
threaded_time = run_threaded(calls) # => threaded_time: ~0.2s, all four overlap
print(f"serial={serial_time:.2f}s threaded={threaded_time:.2f}s") # => Output: serial=~0.8s threaded=~0.2s
# => Unlike ex-03's CPU-bound case, `time.sleep` releases the GIL for its whole duration,
# => so all 4 threads' sleeps overlap almost perfectly -- I/O-bound work DOES parallelize
# => on threads even though the GIL still serializes any actual Python bytecode (co-05).
assert threaded_time < serial_time * 0.5 # => confirms real overlap: far less than 4x one sleep
print("ex-05 OK") # => Output: ex-05 OKRun: python3 example.py
Output:
serial=0.82s threaded=0.21s
ex-05 OKlearning/code/ex-05-io-bound-threads-help/test_example.py
"""Example 5: pytest verification for I/O-Bound Threads Actually Help."""
from example import run_serial, run_threaded
def test_threaded_io_beats_serial_io() -> None:
calls = 4
serial_time = run_serial(calls)
threaded_time = run_threaded(calls)
assert threaded_time < serial_time * 0.5 # => threads overlap I/O; the GIL doesn't block sleep
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: Threads overlap I/O-bound waiting even under the GIL, because a blocked/sleeping thread releases the GIL for the duration of the wait -- the serialization from ex-03 only applies to CPU-bound work.
Why it matters: This is the practical payoff of understanding the GIL precisely rather than as a blanket "Python threads don't help" rule of thumb: for network calls, file I/O, and anything else that spends most of its time waiting rather than computing, threads (or asyncio, covered from ex-27 onward) deliver real wall-clock speedups. The co-05 io-bound-vs-cpu-bound distinction this example teaches is the single most important routing decision in the rest of the topic.
Example 6: Your First Thread -- start() and join()
ex-06 · exercises co-06
threading.Thread(target=worker) builds a thread object that is not yet running; .start() launches it concurrently, and .join() blocks the calling thread until it has fully finished. This is the smallest possible complete thread lifecycle -- build, start, join -- that every later threading example builds on.
learning/code/ex-06-first-thread-start-join/example.py
"""Example 6: Your First Thread -- start() and join().""" # => co-06: the two calls every thread lifecycle needs
import threading # => the stdlib module for OS-backed threads (co-06)
ran: list[str] = [] # => a shared list the worker appends to, proving it actually executed
def worker() -> None: # => the function the new thread will run
ran.append("worker-ran") # => appends a marker -- visible from the main thread after join()
if __name__ == "__main__": # => module entry point
thread = threading.Thread(target=worker) # => builds a Thread object -- NOT yet running
print(f"is_alive_before_start={thread.is_alive()}") # => Output: is_alive_before_start=False
thread.start() # => start() launches the OS thread; worker() begins running concurrently
thread.join() # => join() blocks the main thread until worker() has fully returned
print(f"is_alive_after_join={thread.is_alive()}") # => Output: is_alive_after_join=False
# => start() returns immediately -- the calling thread does NOT wait for worker() to begin or finish.
# => join() is the only way to know, for certain, that the spawned thread has fully completed.
assert ran == ["worker-ran"] # => confirms worker() actually ran (join() guarantees this)
assert thread.is_alive() is False # => confirms the thread has fully finished, not just started
print("ex-06 OK") # => Output: ex-06 OKRun: python3 example.py
Output:
is_alive_before_start=False
is_alive_after_join=False
ex-06 OKlearning/code/ex-06-first-thread-start-join/test_example.py
"""Example 6: pytest verification for Your First Thread -- start() and join()."""
import threading
from example import worker
def test_join_guarantees_the_worker_finished() -> None:
log: list[str] = []
def wrapped() -> None:
worker()
log.append("done")
t = threading.Thread(target=wrapped)
t.start()
t.join()
assert log == ["done"] # => join() returning means the thread body fully completed
assert t.is_alive() is False
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: start() launches a thread but returns immediately; only join() guarantees the spawned thread has actually finished.
Why it matters: Every concurrency bug involving "the thread hadn't started yet" or "the main program exited before the thread finished" traces back to skipping or misordering these two calls. Getting this lifecycle right by heart -- construct, start, join -- is the foundation every other threading pattern in this topic (pools, queues, synchronization) is built on top of.
Example 7: Starting Many Threads and Joining Them All
ex-07 · exercises co-06
Starting eight threads in one loop and then joining all eight in a SECOND, separate loop lets every thread run concurrently before any join() call blocks -- starting and joining in the SAME loop would accidentally serialize them, since each join() would block before the next thread even starts.
learning/code/ex-07-many-threads-join-all/example.py
"""Example 7: Starting Many Threads and Joining Them All.""" # => co-06: the start-many-then-join-many pattern
import threading # => builds on ex-06 with N threads instead of one
COUNT = 8 # => how many worker threads this example spawns
def worker(worker_id: int, results: list[int]) -> None: # => each thread runs with its OWN id
results.append(worker_id) # => appends this worker's id -- list.append is thread-safe in CPython
def run_all(count: int) -> list[int]: # => spawns `count` threads, joins every one, returns results
results: list[int] = [] # => shared list every worker appends its id into
threads = [threading.Thread(target=worker, args=(i, results)) for i in range(count)]
# => builds `count` Thread objects, each closing over its own `i` via args=
for t in threads: # => a FIRST loop that launches every thread
t.start() # => start() -- all `count` threads may now be running concurrently
for t in threads: # => a SECOND, separate loop that waits for every thread
t.join() # => join() blocks until THIS thread finishes -- iterating collects them all
return results # => results now holds exactly `count` ids, order not guaranteed
if __name__ == "__main__": # => module entry point
ids = run_all(COUNT) # => ids: every worker's id, in whatever order threads actually finished
print(sorted(ids)) # => Output: [0, 1, 2, 3, 4, 5, 6, 7]
# => two separate loops matter: starting-then-joining-in-the-same-loop would serialize them.
assert len(ids) == COUNT # => confirms every single thread ran exactly once (none dropped)
assert sorted(ids) == list(range(COUNT)) # => confirms every id 0..7 appeared, no duplicates
print("ex-07 OK") # => Output: ex-07 OKRun: python3 example.py
Output:
[0, 1, 2, 3, 4, 5, 6, 7]
ex-07 OKlearning/code/ex-07-many-threads-join-all/test_example.py
"""Example 7: pytest verification for Starting Many Threads and Joining Them All."""
from example import run_all
def test_every_thread_completes_exactly_once() -> None:
ids = run_all(8)
assert len(ids) == 8 # => the two-loop start-then-join pattern collected every worker
assert sorted(ids) == list(range(8)) # => no id missing, none duplicated
def test_different_count_still_completes() -> None:
ids = run_all(20)
assert sorted(ids) == list(range(20)) # => the pattern scales to more threads unchanged
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Two separate loops -- one that starts every thread, then one that joins every thread -- is the pattern that actually achieves concurrency; interleaving start/join per thread inside one loop does not.
Why it matters: This is a subtle but common beginner mistake: writing for t in threads: t.start(); t.join() looks correct but defeats the entire purpose of threading, because each iteration blocks on that thread before the next one is even started. Internalizing the two-loop pattern here prevents an entire class of "my threaded code isn't actually faster" bugs that show up again in thread pools and process pools later in the topic.
Example 8: A Shared Counter Without a Lock Loses Updates
ex-08 · exercises co-07, co-08
Two threads each increment a shared counter[0] two thousand times with no synchronization; the expected total is 4000, but the actual total comes out lower, because some increments are silently lost. A time.sleep(0) inserted between the read and the write widens the window where two threads can interleave mid-increment, making the loss reliably reproducible rather than a rare fluke.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
sequenceDiagram
participant A as Thread A
participant C as counter[0] = 5
participant B as Thread B
A->>C: read 5
B->>C: read 5
A->>C: write 6
B->>C: write 6
Note over C: should be 7 -- one increment LOST
learning/code/ex-08-shared-counter-no-lock/example.py
"""Example 8: A Shared Counter Without a Lock Loses Updates."""
import threading # => two threads racing on one shared variable
import time # => `time.sleep(0)` forces a context-switch window between read and write
ITERATIONS_PER_THREAD = 2_000 # => small but reliable: each iteration deliberately widens the race window
def increment_many(counter: list[int]) -> None: # => counter[0] is the shared mutable state
for _ in range(ITERATIONS_PER_THREAD): # => runs the unsynchronized increment this many times
value = counter[0] # => READ counter[0] into a LOCAL variable -- step 1 of 3
time.sleep(0) # => yields the GIL RIGHT HERE -- widens the window for co-08's lost update
counter[0] = value + 1 # => WRITE BACK the stale local `value` + 1 -- step 3, using OLD data
def racing_total() -> int: # => runs two threads incrementing the SAME counter, no lock
counter = [0] # => a one-element list stands in for a shared mutable int (ints are immutable)
threads = [threading.Thread(target=increment_many, args=(counter,)) for _ in range(2)]
# => two threads, both targeting the SAME counter list -- a classic shared-mutable-state hazard
for t in threads: # => launches both racing threads
t.start() # => both now interleave reads/writes to counter[0] with no coordination
for t in threads: # => waits for both to finish
t.join() # => join() blocks until that thread's increment_many() call returns
return counter[0] # => the FINAL value -- expected to be wrong due to lost updates
if __name__ == "__main__": # => module entry point
expected = 2 * ITERATIONS_PER_THREAD # => expected: what the total WOULD be if increments never raced
actual = racing_total() # => actual: what the total ACTUALLY is after the unsynchronized race
print(f"expected={expected} actual={actual}") # => Output: expected=4000 actual=2000 (roughly)
# => Between `value = counter[0]` and `counter[0] = value + 1`, the OTHER thread reads the SAME
# => stale `counter[0]`, and whichever thread writes last silently overwrites the other's
# => increment -- a LOST UPDATE, the defining symptom of co-07's shared-mutable-state hazard.
assert actual < expected # => confirms at least one increment was lost to the unsynchronized race
print("ex-08 OK") # => Output: ex-08 OKRun: python3 example.py
Output:
expected=4000 actual=2000
ex-08 OKlearning/code/ex-08-shared-counter-no-lock/test_example.py
"""Example 8: pytest verification for A Shared Counter Without a Lock Loses Updates."""
from example import ITERATIONS_PER_THREAD, racing_total
def test_unsynchronized_counter_loses_updates() -> None:
expected = 2 * ITERATIONS_PER_THREAD
actual = racing_total()
assert actual < expected # => the unsynchronized race reliably drops at least one increment
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: Unsynchronized concurrent read-modify-write on shared mutable state loses updates -- the final total is silently wrong, with no exception raised to warn you.
Why it matters: This is the archetypal concurrency bug: no crash, no traceback, just a quietly incorrect number that looks plausible unless you know the expected total in advance. It is why co-07 (shared-mutable-state-hazard) is described as the root of nearly every concurrency bug -- everything from this example through the lock fix (ex-11) to the stress-test harness (ex-80) is variations on this exact same shape of failure and its remedies.
Example 9: x += 1 Is Not One Atomic Step -- Proof via dis
ex-09 · exercises co-10
dis.dis disassembles the bytecode for counter[0] += 1 and shows it is actually three separate operations -- a load, a binary add, and a store -- not one atomic step. Seeing the bytecode directly makes ex-08's lost-update bug concrete: another thread can run between any two of those three instructions.
learning/code/ex-09-plus-equals-not-atomic/example.py
"""Example 9: `x += 1` Is Not One Atomic Step -- Proof via `dis`."""
import dis # => the stdlib bytecode disassembler -- makes co-10's claim inspectable, not just asserted
import types # => types.FunctionType -- the precise type `dis.get_instructions` accepts
def bump() -> None: # => a tiny function whose ONLY job is to be disassembled
global shared # => refers to the module-level `shared` global below
shared = shared + 1 # => the exact statement whose atomicity (or lack of it) this example proves
shared = 0 # => the global `bump()` mutates -- irrelevant to the bytecode shape itself
def opnames_for(func: types.FunctionType) -> list[str]: # => extracts opcode NAMES, in execution order
return [instr.opname for instr in dis.get_instructions(func)] # => one name per bytecode instruction
if __name__ == "__main__": # => module entry point
names = opnames_for(bump) # => names: the full list of opcodes CPython compiled `bump` into
print(names) # => Output: ['RESUME', 'LOAD_GLOBAL', 'LOAD_SMALL_INT', 'BINARY_OP', 'STORE_GLOBAL', ...]
# => `shared = shared + 1` compiles to (at least) three SEPARATE steps: read the current value
# => (LOAD_GLOBAL), compute the new one (BINARY_OP), then write it back (STORE_GLOBAL). Any of
# => CPython's bytecode-boundary thread switches (co-08) can land BETWEEN these three steps.
assert "LOAD_GLOBAL" in names # => step 1: read the current value of `shared`
assert "BINARY_OP" in names # => step 2: compute `shared + 1` -- NOT yet written anywhere
assert "STORE_GLOBAL" in names # => step 3: write the new value back to `shared`
load_idx = names.index("LOAD_GLOBAL") # => load_idx: the position of the read
store_idx = names.index("STORE_GLOBAL") # => store_idx: the position of the write
assert load_idx < store_idx # => confirms the read happens strictly BEFORE the write, not fused
print("ex-09 OK") # => Output: ex-09 OKRun: python3 example.py
Output:
['RESUME', 'LOAD_GLOBAL', 'LOAD_SMALL_INT', 'BINARY_OP', 'STORE_GLOBAL', 'LOAD_CONST', 'RETURN_VALUE']
ex-09 OKlearning/code/ex-09-plus-equals-not-atomic/test_example.py
"""Example 9: pytest verification for `x += 1` Is Not One Atomic Step."""
from example import bump, opnames_for
def test_increment_compiles_to_separate_load_and_store() -> None:
names = opnames_for(bump)
assert "LOAD_GLOBAL" in names # => the read
assert "STORE_GLOBAL" in names # => the write
assert names.index("LOAD_GLOBAL") < names.index("STORE_GLOBAL") # => read strictly precedes write
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: x += 1 compiles to LOAD, ADD, STORE as three distinct bytecodes -- any of them can be interleaved with another thread's bytecodes, which is exactly the gap ex-08's race exploits.
Why it matters: It is tempting to assume a single line of Python source code executes as a single, indivisible step -- dis proves that assumption false for the single most common increment pattern in the language. Understanding WHERE the gap actually is (between LOAD and STORE, at the bytecode level, not the source-line level) is what makes locks, atomics, and the whole discipline of critical sections make sense as necessary rather than paranoid.
Example 10: A Race's Output Is Nondeterministic Across Runs
ex-10 · exercises co-08
Running ex-08's unsynchronized racing counter several times in a row produces a DIFFERENT final total on each run -- not just "wrong", but unpredictably wrong, because the exact interleaving of the two threads' bytecodes changes from run to run.
learning/code/ex-10-race-nondeterministic-output/example.py
"""Example 10: A Race's Output Is Nondeterministic Across Runs."""
import random # => the source of run-to-run variability this example measures
import threading # => two threads racing on one shared counter, as in ex-08
import time # => `time.sleep(0)` occasionally widens the race window
ITERATIONS_PER_THREAD = 500 # => small and fast -- run several times to observe VARIATION, not one bug
def racy_increment(counter: list[int]) -> None: # => the SAME lost-update shape as ex-08
for _ in range(ITERATIONS_PER_THREAD): # => runs the racy increment this many times
value = counter[0] # => READ the shared counter into a local
if random.random() < 0.5: # => a COIN FLIP -- sometimes yields, sometimes doesn't
time.sleep(0) # => yields the GIL only on the "heads" branch -- the source of variation
counter[0] = value + 1 # => WRITE BACK the (possibly stale) local value + 1
def one_race() -> int: # => runs the two-thread race ONCE, returns the final total
counter = [0] # => a fresh counter for this single run
threads = [threading.Thread(target=racy_increment, args=(counter,)) for _ in range(2)]
for t in threads: # => launches both threads
t.start() # => the coin flip inside racy_increment makes EVERY run's interleaving different
for t in threads: # => waits for both to finish
t.join() # => join() blocks until that thread's racy_increment() call returns
return counter[0] # => this run's final total -- expected to differ from OTHER runs' totals
if __name__ == "__main__": # => module entry point
totals = [one_race() for _ in range(5)] # => totals: the final count from 5 INDEPENDENT races
print(totals) # => Output: [something, something-else, ...] -- rarely, if ever, all identical
distinct = set(totals) # => distinct: the set of unique totals observed across the 5 runs
expected = 2 * ITERATIONS_PER_THREAD # => expected: the correct total if the race never lost updates
print(f"distinct_count={len(distinct)} expected={expected}") # => Output: distinct_count=... expected=1000
# => Because each run's coin flips land differently, the exact number of lost updates
# => varies from run to run -- this is co-08's core claim: a race condition's RESULT depends
# => on the nondeterministic interleaving of operations, not on the code itself.
assert len(distinct) > 1 # => confirms the 5 runs did NOT all produce the identical total
assert all(t <= expected for t in totals) # => confirms no run ever exceeded the correct total
print("ex-10 OK") # => Output: ex-10 OKRun: python3 example.py
Output:
[518, 516, 538, 578, 509]
distinct_count=5 expected=1000
ex-10 OKlearning/code/ex-10-race-nondeterministic-output/test_example.py
"""Example 10: pytest verification for A Race's Output Is Nondeterministic Across Runs."""
from example import ITERATIONS_PER_THREAD, one_race
def test_repeated_races_produce_varying_totals() -> None:
totals = [one_race() for _ in range(6)]
expected = 2 * ITERATIONS_PER_THREAD
assert len(set(totals)) > 1 # => the coin-flip jitter makes each run's interleaving different
assert all(t <= expected for t in totals) # => a race can only lose updates, never gain extra ones
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: A race condition's defining symptom is nondeterminism: the same buggy code, run repeatedly with no changes, produces different results each time -- which is what makes race conditions notoriously hard to reproduce and debug.
Why it matters: A bug that fails the same way every time is usually easy to track down; a bug that fails DIFFERENTLY every time -- or doesn't fail at all on a slow debugger, then fails in production under load -- is a much harder class of problem. Recognizing "nondeterministic output across otherwise-identical runs" as the fingerprint of a race condition is a diagnostic skill that transfers directly to real-world debugging, long after the specific counter example is forgotten.
Example 11: A Lock Fixes the Racing Counter
ex-11 · exercises co-11, co-08
Wrapping the exact same increment from ex-08 in a threading.Lock's acquire()/release() (inside a try/finally) makes the read-modify-write atomic with respect to other threads, and the final total comes out exactly right -- 4000, every single time.
learning/code/ex-11-lock-fixes-counter/example.py
"""Example 11: A `Lock` Fixes the Racing Counter.""" # => co-11: mutual exclusion applied to ex-08's lost updates
import threading # => threading.Lock is the fix for ex-08 and ex-10's lost updates
import time # => keeps the same widened race window ex-08 used, to prove the lock actually matters
ITERATIONS_PER_THREAD = 2_000 # => identical to ex-08 -- SAME bug shape, now with a fix applied
def increment_with_lock(counter: list[int], lock: threading.Lock) -> None: # => co-11's fix in action
for _ in range(ITERATIONS_PER_THREAD): # => same iteration count as the buggy ex-08 version
lock.acquire() # => blocks until THIS thread is the only one inside the critical section
try: # => guarantees release even if something inside raised (co-11's safety discipline)
value = counter[0] # => READ -- but now no OTHER thread can interleave here
time.sleep(0) # => still yields -- proving the lock, not luck, prevents interleaving
counter[0] = value + 1 # => WRITE BACK -- still inside the SAME critical section
finally: # => runs whether the `try` succeeded or raised
lock.release() # => releases the lock -- exactly one other thread may now proceed
def locked_total() -> int: # => runs two threads incrementing the SAME counter, WITH a lock
counter = [0] # => same shared mutable state shape as ex-08
lock = threading.Lock() # => one Lock shared by both threads -- the mutual-exclusion gate
threads = [threading.Thread(target=increment_with_lock, args=(counter, lock)) for _ in range(2)]
# => threads: exactly 2 Thread objects, both racing on the SAME counter and the SAME lock
for t in threads: # => launches both threads
t.start() # => both now contend for the SAME lock before touching counter[0]
for t in threads: # => waits for both to finish
t.join() # => join() blocks until that thread's increment_with_lock() call returns
return counter[0] # => the FINAL value -- now expected to be EXACTLY correct
if __name__ == "__main__": # => module entry point
expected = 2 * ITERATIONS_PER_THREAD # => expected: the correct total, no lost updates allowed
actual = locked_total() # => actual: the total after the SAME race pattern, now lock-protected
print(f"expected={expected} actual={actual}") # => Output: expected=4000 actual=4000
# => same iteration count, same sleep(0) window as ex-08 -- ONLY the lock changed, and it's enough.
assert actual == expected # => confirms the lock eliminated EVERY lost update, not just some
print("ex-11 OK") # => Output: ex-11 OKRun: python3 example.py
Output:
expected=4000 actual=4000
ex-11 OKlearning/code/ex-11-lock-fixes-counter/test_example.py
"""Example 11: pytest verification for A `Lock` Fixes the Racing Counter."""
from example import ITERATIONS_PER_THREAD, locked_total
def test_lock_produces_exactly_correct_total() -> None:
expected = 2 * ITERATIONS_PER_THREAD
actual = locked_total()
assert actual == expected # => no lost updates: the lock serializes every read-modify-write
def test_lock_is_reliable_across_repeated_runs() -> None:
expected = 2 * ITERATIONS_PER_THREAD
for _ in range(3): # => repeats the race 3 times -- a lock's correctness must never be lucky
assert locked_total() == expected
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A Lock around a critical section eliminates lost updates completely -- not "usually correct" or "correct most runs", but exactly correct, deterministically, every time.
Why it matters: This is the payoff example for everything ex-08 through ex-10 built up: the SAME bug shape, the SAME iteration count, the SAME sleep(0) widening the race window -- and the ONLY change is a lock around the critical section. Seeing the fix applied to the identical broken code (rather than a fresh example) makes the causal link between "unsynchronized shared state" and "add a lock" unmistakable.
Example 12: with lock: vs Manual acquire()/release()
ex-12 · exercises co-11
with lock: and a manual lock.acquire() / try / finally: lock.release() provide the identical mutual-exclusion guarantee, but the context-manager form is far less error-prone because it releases the lock automatically even when an exception is raised inside the block.
learning/code/ex-12-lock-context-manager/example.py
"""Example 12: `with lock:` vs Manual acquire()/release().""" # => co-11: same guarantee, two syntaxes
import threading # => threading.Lock supports BOTH the context-manager and manual styles
def manual_style(lock: threading.Lock, log: list[str]) -> None: # => the verbose, error-prone style
lock.acquire() # => step 1: acquire -- MUST be paired with a release, by hand
try: # => without this try/finally, an exception would leave the lock held forever
log.append("manual-inside") # => the critical section's actual work
finally: # => runs no matter what happened inside the try
lock.release() # => step 2: release -- easy to forget without the try/finally boilerplate
def context_manager_style(lock: threading.Lock, log: list[str]) -> None: # => the idiomatic style
with lock: # => `with` calls acquire() on entry and release() on exit, EVEN if an exception occurs
log.append("with-inside") # => the identical critical-section work, far less boilerplate
def context_manager_releases_on_exception(lock: threading.Lock) -> bool: # => proves the safety claim
try: # => wraps the `with` block so this function can observe the exception afterward
with lock: # => acquires the lock
raise ValueError("boom") # => an exception INSIDE the critical section
except ValueError: # => catches it here, one frame up
pass # => intentionally swallowed -- this function only cares whether the lock got released
return lock.locked() # => False means `with` released it despite the exception; True would be a bug
if __name__ == "__main__": # => module entry point
events: list[str] = [] # => shared log both styles append into, to compare their effect
manual_style(threading.Lock(), events) # => runs the manual acquire/release style once
context_manager_style(threading.Lock(), events) # => runs the `with lock:` style once
print(events) # => Output: ['manual-inside', 'with-inside']
still_locked = context_manager_releases_on_exception(threading.Lock()) # => tests exception safety
print(f"still_locked_after_exception={still_locked}") # => Output: still_locked_after_exception=False
# => prefer `with lock:` in real code -- it's equally correct AND immune to a forgotten `finally`.
assert events == ["manual-inside", "with-inside"] # => confirms BOTH styles ran their critical section
assert still_locked is False # => confirms `with lock:` released even though the body raised
print("ex-12 OK") # => Output: ex-12 OKRun: python3 example.py
Output:
['manual-inside', 'with-inside']
still_locked_after_exception=False
ex-12 OKlearning/code/ex-12-lock-context-manager/test_example.py
"""Example 12: pytest verification for `with lock:` vs Manual acquire()/release()."""
import threading
from example import context_manager_releases_on_exception, context_manager_style, manual_style
def test_both_styles_execute_the_critical_section() -> None:
log: list[str] = []
manual_style(threading.Lock(), log)
context_manager_style(threading.Lock(), log)
assert log == ["manual-inside", "with-inside"]
def test_with_statement_releases_the_lock_on_exception() -> None:
still_locked = context_manager_releases_on_exception(threading.Lock())
assert still_locked is False # => `with` guarantees release even when the body raises
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Prefer with lock: in real code -- it is exactly as correct as manual acquire/release, and it cannot accidentally leak a held lock if the critical section raises.
Why it matters: A lock that is acquired but never released because an exception skipped the release() call is a deadlock waiting to happen, and it is a surprisingly common real-world bug in code written before context managers were idiomatic. Demonstrating that with lock: releases even after a deliberately raised exception (not just claiming it does) is the kind of concrete proof that makes the idiom trustworthy rather than just conventional.
Example 13: RLock Lets the Owning Thread Re-Acquire
ex-13 · exercises co-12
A threading.RLock (reentrant lock) can be acquired more than once by the SAME thread without blocking -- each acquire() increments an internal counter, and the lock is only truly released once release() has been called a matching number of times. This example acquires an RLock twice in a row from the same thread and confirms it never blocks.
learning/code/ex-13-rlock-reentrant/example.py
"""Example 13: `RLock` Lets the Owning Thread Re-Acquire."""
import threading # => threading.RLock is the reentrant sibling of threading.Lock (co-12)
def outer(rlock: threading.RLock, log: list[str]) -> None: # => acquires, then calls inner() -- SAME thread
with rlock: # => first acquire -- succeeds immediately, no other holder yet
log.append("outer-acquired") # => proves the first acquire succeeded
inner(rlock, log) # => calls a function that acquires the SAME rlock AGAIN
def inner(rlock: threading.RLock, log: list[str]) -> None: # => called WHILE outer() still holds rlock
with rlock: # => a SECOND acquire, by the SAME thread that already holds it -- this is the point
log.append("inner-acquired") # => only reached if RLock allowed the re-entrant acquire
if __name__ == "__main__": # => module entry point
rl = threading.RLock() # => one RLock, tracking BOTH an owning thread and an acquire COUNT
events: list[str] = [] # => records the order acquisitions actually happened in
outer(rl, events) # => outer() acquires once, then inner() acquires again -- same thread, no hang
print(events) # => Output: ['outer-acquired', 'inner-acquired']
# => A plain threading.Lock would DEADLOCK here (ex-14 demonstrates exactly that): the second
# => acquire() call would block forever waiting for a lock the SAME thread already holds.
# => RLock tracks the owning thread and a re-entrancy count, so the SAME thread may acquire it
# => repeatedly without blocking -- each acquire() needs a matching release() to fully unlock.
assert events == ["outer-acquired", "inner-acquired"] # => confirms BOTH acquires succeeded
print("ex-13 OK") # => Output: ex-13 OKRun: python3 example.py
Output:
['outer-acquired', 'inner-acquired']
ex-13 OKlearning/code/ex-13-rlock-reentrant/test_example.py
"""Example 13: pytest verification for `RLock` Lets the Owning Thread Re-Acquire."""
import threading
from example import outer
def test_same_thread_reacquires_rlock_without_deadlock() -> None:
rl = threading.RLock()
log: list[str] = []
outer(rl, log) # => if RLock were a plain Lock, this call would hang forever
assert log == ["outer-acquired", "inner-acquired"]
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: An RLock tracks which thread owns it and how many times that thread has acquired it -- the SAME thread can re-enter freely, but a DIFFERENT thread still blocks until every acquisition is released.
Why it matters: Reentrant locks solve a specific, common problem: a method that holds a lock calling another method (perhaps recursively, or through an inherited helper) that also needs the same lock. With a plain Lock this pattern self-deadlocks (ex-14); with an RLock it works transparently. Knowing when reentrancy is needed -- and that it changes the deadlock-detection story slightly -- is a prerequisite for the deadlock examples starting at ex-29.
Example 14: A Plain Lock Self-Deadlocks on Re-Acquire
ex-14 · exercises co-11, co-12
A plain threading.Lock, unlike an RLock, has no concept of an owning thread -- if the SAME thread calls acquire() twice without releasing in between, the second call blocks forever, because the lock cannot tell the second acquisition apart from a different thread's. This example demonstrates the hang using a bounded acquire(timeout=...) so the test itself doesn't hang.
learning/code/ex-14-plain-lock-self-deadlocks/example.py
"""Example 14: A Plain `Lock` Self-Deadlocks on Re-Acquire."""
import threading # => threading.Lock, contrasted with ex-13's RLock
def try_reacquire_same_thread(lock: threading.Lock, timeout: float) -> bool:
# => attempts to acquire `lock` TWICE in a row, on the SAME thread, with a bounded wait
lock.acquire() # => FIRST acquire -- succeeds immediately, nothing else holds it yet
got_second = lock.acquire(timeout=timeout) # => SECOND acquire, same thread -- this is the test
# => a plain Lock does NOT track "who" holds it, so it blocks even though ITSELF is the holder
if got_second: # => only True if acquire() somehow succeeded within the timeout (it will not)
lock.release() # => would release the second acquisition, if there had been one
lock.release() # => releases the FIRST acquisition -- always required to clean up
return got_second # => False proves the second acquire() timed out, i.e. self-deadlocked
if __name__ == "__main__": # => module entry point
my_lock = threading.Lock() # => a fresh, plain (non-reentrant) Lock
succeeded = try_reacquire_same_thread(my_lock, timeout=0.2) # => bounded wait -- never hangs the test
print(f"second_acquire_succeeded={succeeded}") # => Output: second_acquire_succeeded=False
# => Unlike ex-13's RLock, a plain Lock has no concept of "owning thread" -- to the Lock, the
# => second acquire() call looks identical to a call from any OTHER thread: something already
# => holds it, so it blocks. Calling it from the SAME thread without an intervening release()
# => is therefore a guaranteed self-deadlock (co-11, co-12) -- here made SAFE with a timeout.
assert succeeded is False # => confirms the second acquire() blocked instead of succeeding
assert my_lock.locked() is False # => confirms the final release() left the lock free again
print("ex-14 OK") # => Output: ex-14 OKRun: python3 example.py
Output:
second_acquire_succeeded=False
ex-14 OKlearning/code/ex-14-plain-lock-self-deadlocks/test_example.py
"""Example 14: pytest verification for A Plain `Lock` Self-Deadlocks on Re-Acquire."""
import threading
from example import try_reacquire_same_thread
def test_plain_lock_blocks_on_same_thread_reacquire() -> None:
lock = threading.Lock()
succeeded = try_reacquire_same_thread(lock, timeout=0.2)
assert succeeded is False # => a plain Lock cannot tell "same thread" from "different thread"
assert lock.locked() is False # => the final release() left it clean for the next test
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: A plain Lock self-deadlocks if the same thread tries to acquire it twice without releasing in between -- this is the exact failure RLock (ex-13) is designed to avoid.
Why it matters: This example exists specifically to contrast with ex-13: same setup, same "acquire twice from one thread" action, opposite outcome, because a plain Lock has no reentrancy. Seeing both behaviors side by side -- one hangs, one doesn't -- makes the choice between Lock and RLock a concrete, observed decision rather than an abstract rule to memorize.
Example 15: A Semaphore(2) Limits Concurrent Access
ex-15 · exercises co-13
A threading.Semaphore(2) lets at most two threads into a guarded section simultaneously; six threads all try to enter, but bookkeeping (protected by its own separate lock) confirms the observed peak concurrency never exceeds 2, and actually reaches exactly 2.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
flowchart LR
S["Semaphore(2)<br/>2 permits"]:::blue
T1["Thread 1"]:::orange -->|"acquires permit"| S
T2["Thread 2"]:::orange -->|"acquires permit"| S
T3["Thread 3"]:::teal -.->|"BLOCKS: no permits left"| S
T4["Thread 4"]:::teal -.->|"BLOCKS: no permits left"| S
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-15-semaphore-limits-concurrency/example.py
"""Example 15: A `Semaphore(2)` Limits Concurrent Access.""" # => co-13: a counter permitting at most N holders
import threading # => threading.Semaphore -- a counter permitting at most N concurrent holders
import time # => used to hold the section open long enough to observe overlapping threads
MAX_CONCURRENT = 2 # => the semaphore's permit count -- at most this many threads inside at once
def worker(sem: threading.Semaphore, active: list[int], peak: list[int], lock: threading.Lock) -> None:
# => sem: the shared Semaphore; active/peak: shared bookkeeping; lock: guards THAT bookkeeping
with sem: # => acquires a permit -- blocks if MAX_CONCURRENT are already inside
with lock: # => a SEPARATE lock, just to safely update the shared bookkeeping below
active[0] += 1 # => one more thread is now inside the semaphore-guarded section
peak[0] = max(peak[0], active[0]) # => peak[0]: the highest concurrency EVER observed
time.sleep(0.05) # => holds the section open, giving other threads a chance to overlap
with lock: # => re-acquires the bookkeeping lock to record this thread leaving
active[0] -= 1 # => this thread is about to release its permit
if __name__ == "__main__": # => module entry point
semaphore = threading.Semaphore(MAX_CONCURRENT) # => permits exactly MAX_CONCURRENT holders at once
bookkeeping_lock = threading.Lock() # => protects `active`/`peak` from their OWN race (co-11)
active_count = [0] # => how many threads are INSIDE the semaphore section right now
peak_count = [0] # => the highest `active_count` reached during the whole run
threads = [ # => builds 6 Thread objects, all sharing the SAME semaphore and bookkeeping
threading.Thread(target=worker, args=(semaphore, active_count, peak_count, bookkeeping_lock))
# => each thread's args tuple wires it to the shared semaphore/active/peak/lock state
for _ in range(6) # => 6 threads compete for only 2 concurrent slots
] # => threads: a list of exactly 6 Thread objects, not yet started
for t in threads: # => launches all 6 threads
t.start() # => all 6 immediately try to acquire the semaphore
for t in threads: # => waits for all 6 to finish
t.join() # => join() blocks until that thread's worker() call returns
print(f"peak_concurrency={peak_count[0]}") # => Output: peak_concurrency=2
# => the bookkeeping lock and the semaphore protect DIFFERENT things: one data, one concurrency.
assert peak_count[0] <= MAX_CONCURRENT # => confirms the semaphore NEVER let more than 2 in at once
assert peak_count[0] == MAX_CONCURRENT # => confirms it actually reached the full allowed concurrency
print("ex-15 OK") # => Output: ex-15 OKRun: python3 example.py
Output:
peak_concurrency=2
ex-15 OKlearning/code/ex-15-semaphore-limits-concurrency/test_example.py
"""Example 15: pytest verification for A `Semaphore(2)` Limits Concurrent Access."""
import threading
from example import MAX_CONCURRENT, worker
def test_semaphore_caps_peak_concurrency_at_two() -> None:
sem = threading.Semaphore(MAX_CONCURRENT)
lock = threading.Lock()
active = [0]
peak = [0]
threads = [threading.Thread(target=worker, args=(sem, active, peak, lock)) for _ in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
assert peak[0] <= MAX_CONCURRENT # => never more than 2 threads inside at once
assert peak[0] == MAX_CONCURRENT # => and it actually used the full allowance
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: A Semaphore(N) generalizes a Lock (which is like Semaphore(1)) to permit up to N concurrent holders instead of exactly one.
Why it matters: Semaphores are the standard tool for bounding concurrent access to a limited resource -- a connection pool, a rate-limited API, a fixed number of worker slots -- where a plain Lock's "exactly one at a time" is too strict but unbounded concurrency would overwhelm the resource. This same pattern reappears with asyncio.Semaphore (ex-53) for rate-limiting coroutines, making the concept directly transferable across the sync and async worlds.
Example 16: BoundedSemaphore Catches an Over-Release Bug
ex-16 · exercises co-13
A threading.BoundedSemaphore, unlike a plain Semaphore, raises ValueError if release() is called more times than acquire() was -- catching a specific class of bug (an accidental double-release) that a plain Semaphore would silently accept, corrupting its internal count.
learning/code/ex-16-bounded-semaphore-guard/example.py
"""Example 16: `BoundedSemaphore` Catches an Over-Release Bug."""
import threading # => threading.BoundedSemaphore -- a Semaphore that guards against extra release()s
def release_too_many_times(sem: threading.BoundedSemaphore, extra_releases: int) -> Exception | None:
# => acquires once, then releases MORE times than it acquired -- a common bookkeeping bug
sem.acquire() # => acquires the single permit -- count now 0
sem.release() # => releases it back -- count now 1, matching the original acquire()
caught: Exception | None = None # => caught: will hold the ValueError, if one is raised
try: # => the EXTRA release() calls below have no matching acquire()
for _ in range(extra_releases): # => calls release() again, with nothing left to release
sem.release() # => a plain Semaphore would silently grow past its intended maximum
except ValueError as exc: # => BoundedSemaphore specifically detects and rejects this
caught = exc # => caught now holds the raised exception, for the caller to inspect
return caught # => None would mean the bug went undetected -- BoundedSemaphore prevents that
if __name__ == "__main__": # => module entry point
bounded = threading.BoundedSemaphore(1) # => a semaphore that must never exceed its initial value
error = release_too_many_times(bounded, extra_releases=1) # => triggers exactly one extra release()
print(f"error_type={type(error).__name__ if error else None}") # => Output: error_type=ValueError
# => A plain threading.Semaphore would accept the extra release() silently, letting its internal
# => counter drift above the number of resources that actually exist -- a bug that then lets
# => MORE threads through a "limit N concurrent" section (ex-15) than the resource can handle.
# => BoundedSemaphore raises ValueError the instant release() would exceed the initial count.
assert error is not None # => confirms the over-release was CAUGHT, not silently accepted
assert isinstance(error, ValueError) # => confirms it's specifically the documented ValueError
print("ex-16 OK") # => Output: ex-16 OKRun: python3 example.py
Output:
error_type=ValueError
ex-16 OKlearning/code/ex-16-bounded-semaphore-guard/test_example.py
"""Example 16: pytest verification for `BoundedSemaphore` Catches an Over-Release Bug."""
import threading
from example import release_too_many_times
def test_bounded_semaphore_raises_on_over_release() -> None:
bounded = threading.BoundedSemaphore(1)
error = release_too_many_times(bounded, extra_releases=1)
assert isinstance(error, ValueError) # => the extra release() is caught, not silently accepted
def test_plain_semaphore_would_not_raise() -> None:
plain = threading.Semaphore(1) # => contrast: the non-bounded variant
plain.acquire()
plain.release()
plain.release() # => an EXTRA release -- a plain Semaphore allows this without error
assert True # => reaching this line at all proves no exception was raised above
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: BoundedSemaphore is Semaphore plus a safety check: it raises ValueError on over-release instead of silently letting the permit count exceed its intended maximum.
Why it matters: A plain Semaphore that's over-released doesn't crash -- it just quietly starts permitting MORE concurrent access than intended, which is a much harder bug to notice than an exception. Preferring BoundedSemaphore by default (unless the extra permits are genuinely intentional) is a small, low-cost defensive habit that turns a silent correctness bug into a loud, immediate one.
Example 17: A threading.Event Signal
ex-17 · exercises co-15
A threading.Event is a simple one-shot flag: one thread calls event.wait() and blocks until another thread calls event.set(), at which point every waiter is released. This example proves the waiter genuinely blocked (not just got lucky with timing) by checking is_set() while it's still waiting.
learning/code/ex-17-event-signal/example.py
"""Example 17: A `threading.Event` Signal.""" # => co-15: a one-shot flag one thread sets, another waits on
import threading # => threading.Event -- a simple one-shot signal flag between threads
import time # => proves the waiter genuinely BLOCKED, not just got lucky with timing
def waiter(event: threading.Event, log: list[str]) -> None: # => blocks until signaled
log.append("waiting") # => recorded BEFORE the block, proving this thread reached wait()
event.wait() # => blocks THIS thread until some other thread calls event.set()
log.append("proceeded") # => only reached AFTER set() unblocks the wait() call above
def signaler(event: threading.Event, delay: float) -> None: # => sets the event after a delay
time.sleep(delay) # => a deliberate pause -- the waiter must still be blocked when this runs
event.set() # => flips the internal flag to True and wakes EVERY thread blocked in wait()
if __name__ == "__main__": # => module entry point
signal = threading.Event() # => starts UNSET (internal flag is False)
events_log: list[str] = [] # => records the waiter's progress, in order
t_wait = threading.Thread(target=waiter, args=(signal, events_log)) # => the blocked thread
t_signal = threading.Thread(target=signaler, args=(signal, 0.1)) # => the thread that unblocks it
t_wait.start() # => starts waiting immediately -- signal.is_set() is still False
time.sleep(0.02) # => gives the waiter time to reach event.wait() before signaling
still_unset = not signal.is_set() # => still_unset: confirms the waiter really is blocked
t_signal.start() # => now starts the delayed signaler
t_wait.join() # => blocks the main thread until the waiter's wait() call returns
t_signal.join() # => blocks until the signaler's sleep+set() call returns
print(events_log) # => Output: ['waiting', 'proceeded']
print(f"still_unset_before_signal={still_unset}") # => Output: still_unset_before_signal=True
# => unlike a Lock, an Event carries no ownership -- ANY thread can set() or wait() on it, freely.
assert events_log == ["waiting", "proceeded"] # => confirms the waiter blocked, THEN proceeded
assert still_unset is True # => confirms the event was genuinely unset while the waiter blocked
assert signal.is_set() is True # => confirms the final state is set
print("ex-17 OK") # => Output: ex-17 OKRun: python3 example.py
Output:
['waiting', 'proceeded']
still_unset_before_signal=True
ex-17 OKlearning/code/ex-17-event-signal/test_example.py
"""Example 17: pytest verification for A `threading.Event` Signal."""
import threading
from example import signaler, waiter
def test_waiter_blocks_until_event_is_set() -> None:
signal = threading.Event()
log: list[str] = []
t_wait = threading.Thread(target=waiter, args=(signal, log))
t_signal = threading.Thread(target=signaler, args=(signal, 0.05))
t_wait.start()
t_signal.start()
t_wait.join()
t_signal.join()
assert log == ["waiting", "proceeded"] # => wait() only returns after set() is called
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: An Event has no ownership, unlike a Lock -- any thread can set() or wait() on it freely, making it the simplest tool for a one-time "something happened" signal between threads.
Why it matters: Events are the right tool when the coordination need is genuinely just "has X happened yet?" rather than mutual exclusion around shared data -- a startup-complete flag, a shutdown signal, a one-shot notification. This example's pattern (start a waiter, confirm it's blocked, then release it) is reused directly in ex-65's future-cancellation example and ex-78's graceful-shutdown example.
Example 18: A Barrier Rendezvous Point
ex-18 · exercises co-15
A threading.Barrier(4) is an N-way rendezvous point: it releases ALL waiting threads simultaneously, but only once all four have called .wait() -- no thread passes early, no matter how much earlier it arrived. Four threads with staggered artificial delays all reach the barrier and are all released together.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
flowchart LR
T1["Thread 1<br/>arrives early"]:::blue -->|"wait()"| Bar["Barrier(4)"]:::orange
T2["Thread 2"]:::blue -->|"wait()"| Bar
T3["Thread 3"]:::blue -->|"wait()"| Bar
T4["Thread 4<br/>arrives last"]:::blue -->|"wait()"| Bar
Bar -->|"ALL 4 arrived -- release together"| Go["All 4 proceed"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-18-barrier-rendezvous/example.py
"""Example 18: A `Barrier` Rendezvous Point.""" # => co-15: an N-way meeting point -- no one passes until all arrive
import threading # => threading.Barrier -- an N-way rendezvous, no thread passes until ALL arrive
import time # => proves early arrivers genuinely waited, not just finished quickly by chance
PARTIES = 4 # => how many threads must arrive before ANY of them is released
def arrive(barrier: threading.Barrier, arrival_order: list[int], worker_id: int, delay: float) -> None:
# => worker_id identifies this thread; delay staggers WHEN it reaches the barrier
time.sleep(delay) # => staggers arrivals -- some threads reach the barrier before others
barrier.wait() # => BLOCKS until all PARTIES threads have called wait() -- then releases them ALL
arrival_order.append(worker_id) # => recorded only AFTER the barrier released this thread
if __name__ == "__main__": # => module entry point
barrier = threading.Barrier(PARTIES) # => requires exactly PARTIES threads before releasing any
order: list[int] = [] # => records the order threads pass the barrier -- NOT the order they arrive
delays = [0.05, 0.15, 0.25, 0.35] # => four different arrival times -- deliberately staggered
threads = [ # => builds one thread per (worker_id, delay) pair, all sharing the SAME barrier
threading.Thread(target=arrive, args=(barrier, order, i, delays[i]))
# => each thread's args wire it to its own worker_id and delay, sharing ONE barrier
for i in range(PARTIES) # => one thread per party -- exactly matches the barrier's count
] # => threads: a list of exactly PARTIES Thread objects, not yet started
for t in threads: # => launches all four threads
t.start() # => each begins its own staggered sleep before reaching the barrier
for t in threads: # => waits for all four to finish
t.join() # => join() blocks until that thread's arrive() call fully returns
print(sorted(order)) # => Output: [0, 1, 2, 3]
# => a Barrier is reusable (unlike a one-shot Event) -- it resets automatically after each release.
assert len(order) == PARTIES # => confirms every thread eventually passed the barrier
assert sorted(order) == list(range(PARTIES)) # => confirms every worker_id passed exactly once
print("ex-18 OK") # => Output: ex-18 OKRun: python3 example.py
Output:
[0, 1, 2, 3]
ex-18 OKlearning/code/ex-18-barrier-rendezvous/test_example.py
"""Example 18: pytest verification for A `Barrier` Rendezvous Point."""
import threading
from example import PARTIES, arrive
def test_no_thread_passes_until_all_arrive() -> None:
barrier = threading.Barrier(PARTIES)
order: list[int] = []
delays = [0.02, 0.06, 0.10, 0.14]
threads = [threading.Thread(target=arrive, args=(barrier, order, i, delays[i])) for i in range(PARTIES)]
for t in threads:
t.start()
for t in threads:
t.join()
assert sorted(order) == list(range(PARTIES)) # => all four eventually passed the rendezvous
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: A Barrier(N) guarantees no thread passes it until all N have arrived -- unlike an Event, it is reusable and it resets automatically after each release.
Why it matters: Barriers are the standard tool for phased parallel computation, where every worker must finish phase 1 before any worker starts phase 2 (see ex-75). This example's own harness also reuses barriers throughout the rest of the topic as a reliable way to FORCE two threads to reach a specific point simultaneously, turning otherwise-rare race conditions into deterministically reproducible ones (ex-29, ex-37, ex-74).
Example 19: Condition -- wait() and notify()
ex-19 · exercises co-14
A consumer thread waits on a threading.Condition inside a while not state["ready"]: loop, and a producer thread flips the shared predicate and calls notify() to wake it. A Condition bundles a Lock with a wait/notify queue, so acquiring it before touching shared state and calling wait()/notify() under that same lock is the whole pattern.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
sequenceDiagram
participant Consumer
participant Cond as Condition
participant Producer
Consumer->>Cond: with cond: while not ready: wait()
Note over Consumer: releases lock, blocks
Producer->>Cond: with cond: ready = True; notify()
Cond-->>Consumer: wakes, re-acquires lock
Consumer->>Consumer: re-checks predicate -- now True, proceeds
learning/code/ex-19-condition-wait-notify/example.py
"""Example 19: `Condition` -- wait() and notify().""" # => co-14: predicate-based wait/notify coordination
import threading # => threading.Condition -- a lock PLUS a wait/notify queue built on top of it
def consumer(cond: threading.Condition, state: dict[str, bool], log: list[str]) -> None:
# => state: the shared predicate to wait on; log: records this thread's progress
with cond: # => Condition wraps a Lock -- `with` acquires it before touching shared `state`
while not state["ready"]: # => a LOOP, not an `if` -- guards against spurious wakeups (co-14)
log.append("consumer-waiting") # => recorded each time this thread goes back to sleep
cond.wait() # => atomically RELEASES the lock and blocks, until notified AND re-acquires
log.append("consumer-woke") # => only reached once state["ready"] is actually True
def producer(cond: threading.Condition, state: dict[str, bool], log: list[str]) -> None:
# => uses the SAME cond/state/log objects the consumer above was given
with cond: # => must hold the SAME lock to safely mutate `state` and to call notify()
state["ready"] = True # => flips the shared predicate the consumer is waiting on
log.append("producer-set-ready") # => recorded right before waking the consumer
cond.notify() # => wakes ONE thread blocked in cond.wait() -- it re-acquires the lock and resumes
if __name__ == "__main__": # => module entry point
condition = threading.Condition() # => a fresh Condition, wrapping its own internal Lock
shared_state = {"ready": False} # => the predicate consumer/producer coordinate around
trace: list[str] = [] # => records the interleaved sequence of events, in actual order
t_consumer = threading.Thread(target=consumer, args=(condition, shared_state, trace))
# => t_consumer: will block in cond.wait() until the producer sets the predicate
t_producer = threading.Thread(target=producer, args=(condition, shared_state, trace))
# => t_producer: will flip shared_state["ready"] and notify() the waiting consumer
t_consumer.start() # => starts first -- almost certainly reaches cond.wait() before the producer runs
t_producer.start() # => sets ready=True and notifies once running
t_consumer.join() # => blocks until the consumer wakes and finishes
t_producer.join() # => blocks until the producer finishes
print(trace) # => Output: ['consumer-waiting', 'producer-set-ready', 'consumer-woke']
# => the `while`, not `if`, guard matters even here -- see ex-74 for what breaks without it.
assert "consumer-woke" in trace # => confirms the consumer eventually woke up
assert trace.index("producer-set-ready") < trace.index("consumer-woke") # => notify happened BEFORE wake
print("ex-19 OK") # => Output: ex-19 OKRun: python3 example.py
Output:
['consumer-waiting', 'producer-set-ready', 'consumer-woke']
ex-19 OKlearning/code/ex-19-condition-wait-notify/test_example.py
"""Example 19: pytest verification for `Condition` -- wait() and notify()."""
import threading
from example import consumer, producer
def test_consumer_wakes_only_after_producer_notifies() -> None:
condition = threading.Condition()
state = {"ready": False}
trace: list[str] = []
t_c = threading.Thread(target=consumer, args=(condition, state, trace))
t_p = threading.Thread(target=producer, args=(condition, state, trace))
t_c.start()
t_p.start()
t_c.join()
t_p.join()
assert "consumer-woke" in trace # => the wait/notify handoff completed
assert trace.index("producer-set-ready") < trace.index("consumer-woke") # => strict order
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: cond.wait() atomically releases the lock and blocks, and re-acquires the lock automatically before returning -- the while, not if, guard matters even here (ex-74 shows exactly what breaks without it).
Why it matters: Condition is the general-purpose primitive underneath higher-level tools like queue.Queue -- understanding it directly makes those higher-level tools far less magical. It's also the primitive used to hand-build a bounded buffer from scratch in ex-41 and a reader-writer lock in ex-61, so getting the wait/notify pattern right here pays off repeatedly later in the topic.
Example 20: queue.Queue -- put() and get() Between Threads
ex-20 · exercises co-21, co-20
A queue.Queue moves items from a sender thread to a receiver thread, with put() and get() doing all the synchronization internally -- no explicit lock needed around either call. Starting the receiver FIRST (so it blocks on an empty queue) and the sender second proves get() genuinely blocks rather than getting lucky with timing.
learning/code/ex-20-queue-put-get/example.py
"""Example 20: `queue.Queue` -- put() and get() Between Threads.""" # => co-21: a synchronized hand-off channel
import queue # => queue.Queue -- a thread-safe, synchronized hand-off channel (co-21)
import threading # => the two threads that hand items off through the queue
def sender(q: "queue.Queue[int]", items: list[int]) -> None: # => puts each item, in order
for item in items: # => iterates the items to send, in their original order
q.put(item) # => put() is thread-safe -- no external lock needed around it (co-20)
def receiver(q: "queue.Queue[int]", count: int, out: list[int]) -> None: # => gets `count` items
for _ in range(count): # => pulls exactly `count` items -- one per sender item
out.append(q.get()) # => get() BLOCKS until an item is available, then returns it
if __name__ == "__main__": # => module entry point
channel: "queue.Queue[int]" = queue.Queue() # => an UNBOUNDED FIFO queue -- no maxsize set
to_send = [10, 20, 30, 40] # => the items the sender thread will put(), in this exact order
received: list[int] = [] # => the receiver appends each get() result here, in arrival order
t_send = threading.Thread(target=sender, args=(channel, to_send)) # => the producing thread
t_recv = threading.Thread(target=receiver, args=(channel, len(to_send), received)) # => consuming
t_recv.start() # => starts first -- immediately blocks on q.get() since nothing is queued yet
t_send.start() # => starts sending -- each put() unblocks the receiver's next pending get()
t_send.join() # => waits for the sender to finish putting all items
t_recv.join() # => waits for the receiver to finish getting all items
print(received) # => Output: [10, 20, 30, 40]
# => starting the receiver FIRST proves get() genuinely blocks -- it isn't a lucky race.
assert received == to_send # => confirms FIFO delivery: items arrive in the SAME order they were sent
print("ex-20 OK") # => Output: ex-20 OKRun: python3 example.py
Output:
[10, 20, 30, 40]
ex-20 OKlearning/code/ex-20-queue-put-get/test_example.py
"""Example 20: pytest verification for `queue.Queue` -- put() and get() Between Threads."""
import queue
import threading
from example import receiver, sender
def test_items_arrive_in_fifo_order() -> None:
q: "queue.Queue[int]" = queue.Queue()
items = [1, 2, 3, 4, 5]
received: list[int] = []
t_send = threading.Thread(target=sender, args=(q, items))
t_recv = threading.Thread(target=receiver, args=(q, len(items), received))
t_recv.start()
t_send.start()
t_send.join()
t_recv.join()
assert received == items # => strict FIFO: arrival order matches send order
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: queue.Queue.put()/.get() are already thread-safe -- they are the standard "communicate by sharing a queue, not by sharing memory" channel, and delivery is FIFO.
Why it matters: This is the pivot point of the whole topic: everything from ex-08 through ex-19 was about protecting SHARED mutable state with locks; from here forward, queue.Queue offers an alternative philosophy -- hand data OFF between threads instead of sharing it, which sidesteps most of the lock-ordering and visibility hazards entirely. This message-passing style (co-20) is also the direct conceptual ancestor of multiprocessing.Queue (ex-46) and asyncio.Queue (ex-52).
Example 21: A Basic Producer/Consumer Pipeline
ex-21 · exercises co-22, co-21
One producer thread and one consumer thread, connected by a single queue.Queue, hand off ten items with neither thread ever calling the other directly -- the queue is the ONLY coupling between them. This is the canonical shape every later producer/consumer variant (multi-producer, pipelines, bounded backpressure) builds on.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
flowchart LR
P["Producer<br/>put(0..9)"]:::blue --> Q["queue.Queue()"]:::orange --> C["Consumer<br/>get() x 10"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-21-producer-consumer-basic/example.py
"""Example 21: A Basic Producer/Consumer Pipeline.""" # => co-22: decoupling producers from consumers via a queue
import queue # => the bounded buffer decoupling producer from consumer (co-22)
import threading # => one producer thread, one consumer thread
def producer(q: "queue.Queue[int]", count: int) -> None: # => produces `count` items, one per put()
for i in range(count): # => generates items 0, 1, 2, ..., count - 1
q.put(i) # => hands the item off -- the producer never needs to know who consumes it
def consumer(q: "queue.Queue[int]", count: int, collected: list[int]) -> None: # => consumes `count` items
for _ in range(count): # => pulls exactly `count` items -- matches the producer's total
collected.append(q.get()) # => get() blocks until the producer has something ready
if __name__ == "__main__": # => module entry point
pipeline: "queue.Queue[int]" = queue.Queue() # => the decoupling buffer between the two roles
total_items = 10 # => how many items the producer generates, and the consumer must collect
results: list[int] = [] # => every item the consumer actually received, in arrival order
t_prod = threading.Thread(target=producer, args=(pipeline, total_items)) # => generates work
t_cons = threading.Thread(target=consumer, args=(pipeline, total_items, results)) # => does work
t_prod.start() # => starts producing immediately
t_cons.start() # => starts consuming -- overlaps with production via the queue
t_prod.join() # => waits for production to finish
t_cons.join() # => waits for consumption to finish
print(results) # => Output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# => neither thread ever calls the other directly -- the queue is the ONLY coupling between them.
assert len(results) == total_items # => confirms EVERY produced item was eventually consumed
assert results == list(range(total_items)) # => confirms items were consumed in FIFO order
print("ex-21 OK") # => Output: ex-21 OKRun: python3 example.py
Output:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
ex-21 OKlearning/code/ex-21-producer-consumer-basic/test_example.py
"""Example 21: pytest verification for A Basic Producer/Consumer Pipeline."""
import queue
import threading
from example import consumer, producer
def test_all_produced_items_are_consumed_in_order() -> None:
q: "queue.Queue[int]" = queue.Queue()
total = 15
results: list[int] = []
t_p = threading.Thread(target=producer, args=(q, total))
t_c = threading.Thread(target=consumer, args=(q, total, results))
t_p.start()
t_c.start()
t_p.join()
t_c.join()
assert results == list(range(total)) # => every item produced was consumed, in FIFO order
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: Decoupling a producer from a consumer through a queue means neither side needs to know the other exists, or run at the same speed -- the queue absorbs the difference.
Why it matters: This decoupling is the whole point of the producer/consumer pattern (co-22): the producer can run faster or slower than the consumer without either side's code changing, and either side can be swapped out (for a different producer, a different consumer, or even multiple of each -- ex-39) without touching the other. It is the same architectural idea behind message queues and event buses at every scale, from a two-thread toy example up to distributed systems.
Example 22: A None Sentinel Cleanly Shuts Down a Consumer
ex-22 · exercises co-22
Instead of the consumer needing to know in advance how many items to expect, the producer puts a None SENTINEL value as the very last item, and the consumer's while True: loop breaks cleanly the moment it sees None -- no exception, no hang, no fixed count required.
learning/code/ex-22-queue-sentinel-shutdown/example.py
"""Example 22: A `None` Sentinel Cleanly Shuts Down a Consumer.""" # => co-22: a clean, explicit stop signal
import queue # => the producer/consumer channel, now with an explicit "stop" signal
import threading # => one producer, one consumer, coordinating shutdown through the queue itself
SENTINEL = None # => a value that can NEVER be a real work item -- unambiguous "stop" marker
def producer(q: "queue.Queue[int | None]", items: list[int]) -> None: # => sends work, then stops
for item in items: # => sends every real item first
q.put(item) # => a normal work item
q.put(SENTINEL) # => the LAST thing put -- tells the consumer "there is nothing more coming"
def consumer(q: "queue.Queue[int | None]", collected: list[int]) -> None: # => loops until sentinel
while True: # => runs until the sentinel is seen -- no fixed count needed, unlike ex-21
item = q.get() # => blocks until either a real item or the SENTINEL arrives
if item is None: # => the stop signal (SENTINEL) -- checked BEFORE treating item as real work
break # => exits the loop cleanly -- no exception, no hang, no busy-wait
collected.append(item) # => a real item -- pyright narrows `item` to `int` past the check above
if __name__ == "__main__": # => module entry point
channel: "queue.Queue[int | None]" = queue.Queue() # => carries both real ints AND the sentinel
to_send = [1, 2, 3] # => the real work items the producer will send before stopping
collected: list[int] = [] # => every real item the consumer collected before seeing the sentinel
t_prod = threading.Thread(target=producer, args=(channel, to_send)) # => sends items then SENTINEL
t_cons = threading.Thread(target=consumer, args=(channel, collected)) # => stops on SENTINEL
t_prod.start() # => starts sending
t_cons.start() # => starts consuming -- loops forever until it sees None
t_prod.join() # => waits for the producer to finish (including the final sentinel put)
t_cons.join() # => waits for the consumer's loop to actually break -- proves clean termination
print(collected) # => Output: [1, 2, 3]
# => with multiple consumers, put ONE sentinel per consumer -- each get() only ever removes one.
assert collected == to_send # => confirms every real item was collected before the stop signal
assert not t_cons.is_alive() # => confirms the consumer thread actually terminated, not hung
print("ex-22 OK") # => Output: ex-22 OKRun: python3 example.py
Output:
[1, 2, 3]
ex-22 OKlearning/code/ex-22-queue-sentinel-shutdown/test_example.py
"""Example 22: pytest verification for A `None` Sentinel Cleanly Shuts Down a Consumer."""
import queue
import threading
from example import consumer, producer
def test_consumer_stops_cleanly_on_sentinel() -> None:
q: "queue.Queue[int | None]" = queue.Queue()
items = [7, 8, 9]
collected: list[int] = []
t_p = threading.Thread(target=producer, args=(q, items))
t_c = threading.Thread(target=consumer, args=(q, collected))
t_p.start()
t_c.start()
t_p.join()
t_c.join(timeout=2) # => bounded wait -- a hung consumer would fail this assertion, not the test run
assert collected == items
assert not t_c.is_alive() # => the sentinel actually broke the consumer's loop
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: A sentinel value the consumer can unambiguously recognize as "nothing more is coming" is a clean, explicit way to signal shutdown through the SAME channel the data itself flows through.
Why it matters: Compare this to ex-21, which required the consumer to know the exact item count in advance -- a real-world producer often doesn't know how much work there will be ahead of time, making a sentinel far more practical. This pattern recurs constantly later in the topic: queue.Queue[int | None] sentinel shutdown appears in the multi-producer/multi-consumer example (ex-39), the three-stage pipeline (ex-70), and the graceful-shutdown capstone (ex-78) -- with multiple consumers, one sentinel per consumer is needed, since each get() only ever removes one.
Example 23: ThreadPoolExecutor.map Over I/O Tasks
ex-23 · exercises co-23, co-05
ThreadPoolExecutor.map() runs a function over a list of I/O-bound tasks using a reusable pool of worker threads, and returns results in the SAME order the inputs were given -- regardless of which thread happened to finish first. This is the highest-level, least-ceremony way to parallelize a batch of independent I/O-bound calls.
learning/code/ex-23-threadpool-map/example.py
"""Example 23: `ThreadPoolExecutor.map` Over I/O Tasks."""
import time # => simulates I/O latency with `time.sleep`, as in ex-05
from concurrent.futures import ThreadPoolExecutor # => co-23: a reusable pool of worker threads
def fetch(item_id: int) -> str: # => stands in for a network/disk call keyed by `item_id`
time.sleep(0.1) # => simulated I/O latency -- releases the GIL, just like ex-05's fake_io_call
return f"result-{item_id}" # => a deterministic, checkable "response" for this id
if __name__ == "__main__": # => module entry point
ids = [1, 2, 3, 4, 5] # => 5 independent "requests" to fetch concurrently
start = time.perf_counter() # => start: wall-clock time before the pool does any work
with ThreadPoolExecutor(max_workers=5) as pool: # => 5 reusable worker threads, auto-shutdown on exit
results = list(pool.map(fetch, ids)) # => map() applies fetch() to each id, IN ORDER, concurrently
elapsed = time.perf_counter() - start # => elapsed: total wall time for all 5 fetches combined
print(results) # => Output: ['result-1', 'result-2', 'result-3', 'result-4', 'result-5']
print(f"elapsed={elapsed:.2f}s") # => Output: elapsed=~0.1s (NOT ~0.5s -- proves overlap)
# => `.map()` guarantees results come back in the SAME order as the input iterable, even
# => though the underlying fetch() calls may finish in any order across the worker threads.
assert results == [f"result-{i}" for i in ids] # => confirms order matches input, not completion time
assert elapsed < 0.3 # => confirms the 5 fetches overlapped instead of running one after another
print("ex-23 OK") # => Output: ex-23 OKRun: python3 example.py
Output:
['result-1', 'result-2', 'result-3', 'result-4', 'result-5']
elapsed=0.11s
ex-23 OKlearning/code/ex-23-threadpool-map/test_example.py
"""Example 23: pytest verification for `ThreadPoolExecutor.map` Over I/O Tasks."""
import time
from concurrent.futures import ThreadPoolExecutor
from example import fetch
def test_map_returns_results_in_input_order_and_overlaps() -> None:
ids = [10, 20, 30, 40]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(fetch, ids))
elapsed = time.perf_counter() - start
assert results == [f"result-{i}" for i in ids] # => order matches input, not finish time
assert elapsed < 0.3 # => 4 fetches overlapped, did not run serially (would be ~0.4s)
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: ThreadPoolExecutor.map() preserves input order in its results even though the underlying threads may finish in a different order -- it does the reordering for you.
Why it matters: Thread pools solve a real cost problem that ex-07's "start N raw threads" pattern has: creating and tearing down OS threads has overhead, and an unbounded number of threads can overwhelm a system. A pool reuses a fixed set of worker threads across many tasks, and .map() is the simplest possible interface to it -- the workhorse for I/O-bound batch work throughout the rest of this topic (ex-42, ex-48, ex-76).
Example 24: submit() Returns a Future; .result() Blocks
ex-24 · exercises co-23, co-25
executor.submit(fn, ...) returns a Future immediately, without blocking -- the actual work runs on a pool thread in the background, and calling .result() on the Future blocks until that work completes and returns its value (or re-raises its exception).
learning/code/ex-24-threadpool-submit-future/example.py
"""Example 24: `submit()` Returns a `Future`; `.result()` Blocks."""
from concurrent.futures import Future, ThreadPoolExecutor # => co-23 pool + co-25 Future placeholder
def compute(x: int, y: int) -> int: # => a plain function -- submit() runs it on a pool thread
return x * y # => the eventual result, wrapped in a Future until it's ready
if __name__ == "__main__": # => module entry point
with ThreadPoolExecutor(max_workers=2) as pool: # => a small pool, auto-shutdown on exit
future: Future[int] = pool.submit(compute, 6, 7) # => submit() returns IMMEDIATELY -- non-blocking
is_running_or_pending = not future.done() # => likely True: the pool thread may not have finished yet
value = future.result() # => .result() BLOCKS until compute() finishes, then returns its value
print(f"value={value}") # => Output: value=42
print(f"done_after_result={future.done()}") # => Output: done_after_result=True
# => `submit()` gives you a Future the instant you call it -- a PLACEHOLDER for a result that
# => does not exist yet. `.result()` is the one call that actually waits for that placeholder
# => to be filled in, turning "eventually" into "now, blocking this thread until it's ready".
assert value == 42 # => confirms .result() returned the correct computed value
assert future.done() is True # => confirms the Future is now resolved
print("ex-24 OK") # => Output: ex-24 OKRun: python3 example.py
Output:
value=42
done_after_result=True
ex-24 OKlearning/code/ex-24-threadpool-submit-future/test_example.py
"""Example 24: pytest verification for `submit()` Returns a `Future`; `.result()` Blocks."""
from concurrent.futures import Future, ThreadPoolExecutor
from example import compute
def test_submit_returns_future_and_result_blocks_for_value() -> None:
with ThreadPoolExecutor(max_workers=1) as pool:
future: Future[int] = pool.submit(compute, 3, 9)
assert future.result() == 27 # => .result() waits for and returns the correct value
assert future.done() is True
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: A Future is a placeholder for a result that doesn't exist yet -- submit() returns instantly, and .result() is the point where the calling code actually waits for the value.
Why it matters: Futures are the building block underneath every higher-level async abstraction covered in this topic, including asyncio's own coroutines internally. Understanding that submit() and .result() are two DIFFERENT moments in time -- "start the work" and "wait for the value" -- is what makes patterns like as_completed (ex-42), add_done_callback (ex-26), and cancellation (ex-65) make sense as manipulations of that same Future object.
Example 25: ProcessPoolExecutor Beats Threads on CPU Work
ex-25 · exercises co-24, co-03
The same CPU-bound task, run via ProcessPoolExecutor instead of a thread pool, measurably beats the threaded version's wall-clock time -- because separate OS processes each get their own Python interpreter and their own GIL, genuinely running in parallel across CPU cores, unlike threads under a shared GIL (ex-03).
learning/code/ex-25-processpool-cpu/example.py
"""Example 25: `ProcessPoolExecutor` Beats Threads on CPU Work."""
import time # => measures wall time to compare the two pool types
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor # => co-24 vs co-23
ITERATIONS = 15_000_000 # => tuned so 4 tasks take a small but clearly measurable amount of time
def cpu_task(n: int) -> int: # => pure CPU work -- the GIL serializes this on THREADS (co-03)
total = 0 # => total is 0 -- accumulator forcing real interpreter work
for i in range(n): # => a tight loop -- exactly the shape the GIL cannot parallelize on threads
total += i # => non-atomic arithmetic, but this example doesn't share it across tasks
return total # => the value itself is irrelevant -- only the TIME spent doing it matters here
if __name__ == "__main__": # => module entry point
task_count = 4 # => 4 independent CPU-bound tasks, run both ways
start_threads = time.perf_counter() # => start_threads: wall time before the thread-pool run
with ThreadPoolExecutor(max_workers=4) as thread_pool: # => 4 threads, same interpreter, ONE GIL
list(thread_pool.map(cpu_task, [ITERATIONS] * task_count)) # => runs all 4, serialized by the GIL
threads_time = time.perf_counter() - start_threads # => threads_time: barely faster than serial
start_procs = time.perf_counter() # => start_procs: wall time before the process-pool run
with ProcessPoolExecutor(max_workers=4) as proc_pool: # => 4 processes, EACH with its OWN GIL (co-24)
list(proc_pool.map(cpu_task, [ITERATIONS] * task_count)) # => genuinely runs on separate cores
procs_time = time.perf_counter() - start_procs # => procs_time: close to 1/4 of threads_time
print(f"threads={threads_time:.2f}s procs={procs_time:.2f}s") # => Output: threads=~1.2s procs=~0.4s
# => Threads all share ONE interpreter and ONE GIL, so 4 CPU-bound tasks on threads run barely
# => faster than running them one after another. Processes each get their OWN interpreter and
# => OWN GIL, sidestepping the GIL entirely -- so the process pool genuinely uses multiple cores.
assert procs_time < threads_time # => confirms the process pool measurably beat the thread pool
print("ex-25 OK") # => Output: ex-25 OKRun: python3 example.py
Output:
threads=1.86s procs=0.54s
ex-25 OKlearning/code/ex-25-processpool-cpu/test_example.py
"""Example 25: pytest verification for `ProcessPoolExecutor` Beats Threads on CPU Work."""
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from example import ITERATIONS, cpu_task
def test_process_pool_beats_thread_pool_on_cpu_work() -> None:
task_count = 4
start_threads = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as tp:
list(tp.map(cpu_task, [ITERATIONS] * task_count))
threads_time = time.perf_counter() - start_threads
start_procs = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as pp:
list(pp.map(cpu_task, [ITERATIONS] * task_count))
procs_time = time.perf_counter() - start_procs
assert procs_time < threads_time # => processes sidestep the GIL; threads cannot
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: For CPU-bound work on the standard (GIL-enabled) build, a process pool delivers real parallelism that a thread pool structurally cannot -- the tradeoff is the cost and complexity of inter-process communication.
Why it matters: This example directly answers the question ex-03 and ex-05 raised: if threads can't parallelize CPU-bound work, what can? A process pool sidesteps the GIL entirely by giving each worker its own interpreter, at the cost of losing shared memory (ex-02, ex-45) and paying serialization overhead for anything sent between processes. This CPU-work routing decision (processes, not threads) recurs in ex-49, ex-57, and ex-77.
Example 26: add_done_callback Fires on Completion
ex-26 · exercises co-25
future.add_done_callback(fn) registers a function that runs automatically the moment the Future completes -- successfully or with an exception -- without the calling code needing to poll or block on .result() itself.
learning/code/ex-26-future-done-callback/example.py
"""Example 26: `add_done_callback` Fires on Completion."""
import threading # => guards the shared log the callback appends to (co-11)
from collections.abc import Callable # => precisely types the closure add_done_callback expects
from concurrent.futures import Future, ThreadPoolExecutor # => co-25: Futures and their callbacks
def slow_double(x: int) -> int: # => the work a pool thread runs
return x * 2 # => the eventual result the Future will hold
def make_callback(log: list[str], lock: threading.Lock) -> Callable[["Future[int]"], None]:
# => returns a closure so the callback can safely append to `log` without a race (co-11)
def on_done(fut: "Future[int]") -> None: # => the EXACT signature add_done_callback requires
with lock: # => protects `log` -- callbacks can run on a DIFFERENT thread than the caller
log.append(f"done:{fut.result()}") # => fut.result() never blocks here -- it's already done
return on_done # => the callable to register
if __name__ == "__main__": # => module entry point
events: list[str] = [] # => shared log the callback writes into
guard = threading.Lock() # => protects `events` from concurrent callback invocations
with ThreadPoolExecutor(max_workers=1) as pool: # => a single worker thread
future: "Future[int]" = pool.submit(slow_double, 21) # => schedules the work, returns a Future
future.add_done_callback(make_callback(events, guard)) # => registers the typed callback above
result = future.result() # => blocks until slow_double() finishes AND the callback has run
print(f"result={result}") # => Output: result=42
print(events) # => Output: ['done:42']
# => `add_done_callback` fires automatically the moment the Future transitions to "done" --
# => no polling required. If the Future was ALREADY done when registered, the callback fires
# => immediately, on whatever thread called add_done_callback (not necessarily the worker).
assert result == 42 # => confirms the underlying computation's result is correct
assert events == ["done:42"] # => confirms the callback actually ran, with the correct result
print("ex-26 OK") # => Output: ex-26 OKRun: python3 example.py
Output:
result=42
['done:42']
ex-26 OKlearning/code/ex-26-future-done-callback/test_example.py
"""Example 26: pytest verification for `add_done_callback` Fires on Completion."""
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from example import make_callback, slow_double
def test_callback_runs_after_future_completes() -> None:
events: list[str] = []
lock = threading.Lock()
with ThreadPoolExecutor(max_workers=1) as pool:
future: "Future[int]" = pool.submit(slow_double, 5)
future.add_done_callback(make_callback(events, lock))
future.result()
time.sleep(0.05) # => a small grace period in case the callback runs slightly after result()
assert events == ["done:10"] # => the callback fired exactly once, with the correct value
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: A done-callback lets code react to a Future's completion asynchronously, without ever calling the blocking .result() -- useful when the calling code has other things to do while it waits.
Why it matters: Callbacks are an alternative to blocking on .result() when the calling thread shouldn't sit idle waiting for one particular piece of work -- logging a completion, updating a UI, or chaining follow-up work are all natural fits. This callback-registration pattern is conceptually the same idea asyncio builds much more ergonomically into coroutines starting at ex-27, which is why seeing the low-level Future-callback version first makes the higher-level async/await version easier to understand as "the same idea, nicer syntax."
Example 27: Your First Coroutine -- async def and asyncio.run
ex-27 · exercises co-26
An async def function defines a coroutine -- calling it does NOT run its body immediately, it returns a coroutine object that must be driven by an event loop. asyncio.run() creates that event loop, runs the coroutine to completion, and returns its result.
learning/code/ex-27-asyncio-hello/example.py
"""Example 27: Your First Coroutine -- `async def` and `asyncio.run`."""
import asyncio # => co-26: single-threaded cooperative concurrency, driven by an event loop
async def greet(name: str) -> str: # => `async def` marks this a COROUTINE FUNCTION, not a function
await asyncio.sleep(0.05) # => `await` suspends THIS coroutine, returning control to the loop
return f"hello, {name}" # => the coroutine's final result, once it resumes and returns
if __name__ == "__main__": # => module entry point
coro = greet("world") # => calling greet() does NOT run its body yet -- it returns a coroutine OBJECT
print(type(coro).__name__) # => Output: coroutine
message = asyncio.run(coro) # => asyncio.run() creates an event loop, runs the coroutine TO COMPLETION
print(message) # => Output: hello, world
# => `asyncio.run()` is the top-level entry point: it starts a fresh event loop, drives the
# => coroutine through every `await` suspension point until it returns, then closes the loop.
# => Calling greet() alone does nothing observable -- only actually AWAITING or RUNNING it does.
assert message == "hello, world" # => confirms the coroutine ran to completion and returned correctly
print("ex-27 OK") # => Output: ex-27 OKRun: python3 example.py
Output:
coroutine
hello, world
ex-27 OKlearning/code/ex-27-asyncio-hello/test_example.py
"""Example 27: pytest verification for Your First Coroutine -- `async def` and `asyncio.run`."""
import asyncio
from example import greet
def test_coroutine_runs_to_completion_via_asyncio_run() -> None:
result = asyncio.run(greet("pytest"))
assert result == "hello, pytest" # => asyncio.run() drove the coroutine to a final return value
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: Calling a coroutine function does not run it -- it creates a coroutine OBJECT that must be scheduled and driven by asyncio.run() (or an already-running event loop) before any of its body executes.
Why it matters: This is the single most common asyncio beginner surprise: result = my_coroutine() silently does nothing except create an unused coroutine object (Python even warns about it). Understanding that async def changes what CALLING the function means -- not just what happens inside it -- is the prerequisite for every other asyncio example in this topic, from gather (ex-28) through TaskGroup (ex-68).
Example 28: asyncio.gather Runs asyncio.sleep Tasks Concurrently
ex-28 · exercises co-26, co-05
asyncio.gather() runs several asyncio.sleep()-based coroutines CONCURRENTLY on one thread, and the total wall-clock time is close to the LONGEST individual sleep, not the SUM of all of them -- proving the coroutines genuinely overlapped their waiting rather than running one after another.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
sequenceDiagram
participant Loop as Event Loop
participant A as Task A
participant B as Task B
participant C as Task C
Loop->>A: schedules A
Loop->>B: schedules B
Loop->>C: schedules C
A-->>Loop: await sleep(0.2) -- yields
B-->>Loop: await sleep(0.2) -- yields
C-->>Loop: await sleep(0.2) -- yields
Note over Loop: all three sleeps overlap -- ~0.2s total, not 0.6s
Loop-->>A: sleep done, resumes
Loop-->>B: sleep done, resumes
Loop-->>C: sleep done, resumes
learning/code/ex-28-asyncio-gather-sleep/example.py
"""Example 28: `asyncio.gather` Runs `asyncio.sleep` Tasks Concurrently."""
import asyncio # => co-26's event loop, plus co-05: I/O-bound work benefits from concurrency
import time # => measures wall time to prove the sleeps genuinely overlapped
async def wait_and_return(label: str, delay: float) -> str: # => one "I/O-bound" coroutine
await asyncio.sleep(delay) # => suspends THIS coroutine -- the event loop runs OTHERS meanwhile
return label # => returned once this coroutine resumes after its own delay elapses
async def run_concurrently() -> tuple[list[str], float]: # => runs 3 sleeps AT THE SAME TIME
start = time.perf_counter() # => start: wall time before any coroutine begins awaiting
results = await asyncio.gather( # => schedules all three coroutines on the SAME event loop
wait_and_return("a", 0.2), # => sleeps 0.2s
wait_and_return("b", 0.2), # => ALSO sleeps 0.2s -- concurrently with "a", not after it
wait_and_return("c", 0.2), # => ALSO sleeps 0.2s -- all three overlap on one thread
)
elapsed = time.perf_counter() - start # => elapsed: close to 0.2s total, NOT 0.6s
return list(results), elapsed # => results preserve gather()'s input ORDER, regardless of timing
if __name__ == "__main__": # => module entry point
labels, total_time = asyncio.run(run_concurrently()) # => drives the whole gather() to completion
print(labels) # => Output: ['a', 'b', 'c']
print(f"total_time={total_time:.2f}s") # => Output: total_time=~0.2s
# => `asyncio.gather` schedules every coroutine passed to it on the SAME single-threaded event
# => loop -- while one is suspended in `await asyncio.sleep(...)`, the loop runs another. Three
# => 0.2s sleeps therefore finish in ~0.2s total, not 0.6s, exactly like ex-05's threaded version.
assert labels == ["a", "b", "c"] # => confirms gather() preserves the ORIGINAL argument order
assert total_time < 0.4 # => confirms the three sleeps overlapped instead of running sequentially
print("ex-28 OK") # => Output: ex-28 OKRun: python3 example.py
Output:
['a', 'b', 'c']
total_time=0.20s
ex-28 OKlearning/code/ex-28-asyncio-gather-sleep/test_example.py
"""Example 28: pytest verification for `asyncio.gather` Runs `asyncio.sleep` Tasks Concurrently."""
import asyncio
from example import run_concurrently
def test_gather_overlaps_sleeps_and_preserves_order() -> None:
labels, total_time = asyncio.run(run_concurrently())
assert labels == ["a", "b", "c"] # => order matches the arguments passed to gather()
assert total_time < 0.4 # => three 0.2s sleeps overlapped, did not sum to 0.6s
# => Run: pytest -- Output: 1 passedVerify: pytest -q
Output:
1 passedKey takeaway: asyncio.gather() schedules its coroutines to run concurrently on the SAME thread -- their total time is close to the slowest one, not their sum, because each await asyncio.sleep(...) yields control back to the event loop instead of blocking it.
Why it matters: This is asyncio's answer to ex-05's threaded I/O-overlap example, achieved with a SINGLE thread instead of multiple OS threads -- a much lighter-weight mechanism for the same class of problem (many things waiting on I/O at once). Understanding gather's concurrent-overlap behavior here is the foundation for every later asyncio pattern in this topic: queues (ex-52), semaphores (ex-53), timeouts (ex-51, ex-66), and structured concurrency (ex-68).
← Previous: Overview · Next: Intermediate Examples →
Last updated July 16, 2026