Concurrency
⚡ Concurrency
threading and multiprocessing — and, critically, when each one actually helps given the GIL's constraint.
🔒 The GIL Explained
The Global Interpreter Lock (GIL) is a lock built into the standard Python interpreter (CPython) that allows only one thread to execute Python bytecode at a time — even on a machine with 8 CPU cores, only one core is ever running Python code at any given instant.
The GIL exists because CPython manages memory with simple reference counting (every object tracks how many references point to it; when that count hits zero, it's freed). Reference counting isn't thread-safe on its own — two threads incrementing the same count simultaneously could corrupt it. The GIL sidesteps that entire class of bug by simply never letting two threads run Python code at once, which is far simpler than the fine-grained locking a fully thread-safe interpreter would need.
🧵 threading
This is where threading genuinely helps despite the GIL: time.sleep() (and real I/O like network requests or file reads) releases the GIL while waiting, letting another thread run Python code during that wait. Three 2-second downloads run in about 2 seconds total, not 6 — the threads overlap during their idle waiting time, even though only one is ever actively running Python bytecode at once.
Even with the GIL, operations that take multiple steps internally (like counter += 1, which reads, adds, then writes back) aren't guaranteed atomic — two threads can interleave mid-operation and lose an update, a classic race condition. threading.Lock() (used as a context manager, tying directly back to Chapter 2's with coverage) protects a shared resource by ensuring only one thread touches it at a time: with lock: counter += 1.
🖥️ multiprocessing
multiprocessing sidesteps the GIL entirely by running each task in a genuinely separate operating system process — each with its own Python interpreter, its own memory space, and crucially, its own GIL. This is how Python achieves real parallelism for CPU-heavy work (like the squaring above, or any computation-bound loop), something threading alone cannot do because of the single shared GIL.
🎯 When to Use Which
I/O-Bound vs CPU-Bound Work
| Work type | Bottleneck | Right tool |
|---|---|---|
| I/O-bound | Waiting on network, disk, or a database — the CPU is mostly idle | threading |
| CPU-bound | Heavy computation keeping the CPU genuinely busy | multiprocessing |
threading is "cheap" (threads share memory, start fast) but limited to one running at a time by the GIL — perfect for waiting, useless for parallel computation. multiprocessing is "expensive" (each process needs its own memory, starts slower, and data must be explicitly passed between processes) but gets real parallel CPU work done. Picking the wrong one for the job either wastes overhead (multiprocessing for I/O waits) or does nothing useful (threading for CPU-bound math).
Concurrency Models: Go vs Kotlin vs Python
| Language | Approach |
|---|---|
| Go | Goroutines are extremely lightweight and scheduled M:N onto real OS threads by the Go runtime — no GIL equivalent, so goroutines genuinely run in parallel across cores for both I/O and CPU work. |
| Kotlin | Coroutines run on real JVM threads with cooperative scheduling — no GIL either, so CPU-bound coroutine work can genuinely use multiple cores, unlike Python threads. |
| Python | threading for I/O-bound concurrency only (GIL-limited); multiprocessing required for real CPU-bound parallelism. |
The GIL is the single biggest structural difference between Python's concurrency story and Go's or Kotlin's — both of those languages simply don't have this constraint, so a single lightweight concurrency primitive covers both I/O-bound and CPU-bound cases, where Python genuinely needs two different tools for the two different problems.
The GIL
Only one thread runs Python bytecode at a time; exists to keep reference counting simple.
threading
Good for I/O-bound work — the GIL releases during waits, letting threads overlap.
multiprocessing
Separate processes, separate GILs each — real parallelism for CPU-bound work.
Locks
threading.Lock() protects shared state from race conditions between threads.
💻 Coding Challenges
Challenge 1: Timed Sequential vs Threaded Downloads
Using this chapter's download(name) function (with time.sleep(2)), time how long it takes to call it 3 times sequentially in a plain loop, versus running the same 3 calls via threading.Thread. Print both elapsed times.
Goal: See the I/O-bound threading speedup firsthand, not just read about it.
Challenge 2: A Race Condition, Then Fixed
Write a shared counter = 0 incremented 100,000 times across 2 threads (50,000 increments each, via counter += 1 with no lock) — print the final count (it likely won't be 100,000). Then fix it using threading.Lock() and show the count is now correct every time.
Goal: Observe a real race condition, then apply this chapter's Lock fix directly.
Challenge 3: Parallel Sum of Squares
Using multiprocessing.Pool, compute the square of every number from 0 to 19 in parallel across 4 processes, then sum the results and print the total.
Goal: Practice Pool.map() for genuinely CPU-parallel work.
🎯 What's Next
Next chapter: Async Python — asyncio, async/await, and the event loop.