threading is the most misunderstood concurrency tool in Python, precisely because of the GIL. This lesson draws the line clearly: threads are excellent for I/O-bound work and nearly useless for CPU-bound work on the standard interpreter ā and shows you the tools (locks, thread safety) needed once you do use them correctly.
1The GIL Draws a Hard Line: I/O-Bound vs CPU-Bound
Python's Global Interpreter Lock (introduced in the What's New in Python 3.13+ lesson) means that, on the standard CPython interpreter, only one thread can execute Python bytecode at any given instant ā regardless of how many CPU cores the machine has. This single fact determines almost everything about when threading helps and when it doesn't.
I/O-bound work ā waiting on a network response, a disk read, a database query ā spends most of its time *not* executing Python bytecode at all; it's blocked, waiting for something external. During that wait, the GIL is released (Python's I/O operations are specifically implemented to release it), letting another thread run. This is exactly why the fetch() example genuinely speeds up: three threads each spend most of their time blocked on requests.get(), and while one thread waits, another can be actively running.
CPU-bound work ā the cpu_heavy() sum-of-squares loop ā spends essentially all its time executing Python bytecode, never releasing the GIL voluntarily. Four threads all competing for the one GIL produce, at best, the same total throughput as one thread running sequentially, plus the real overhead of context-switching between threads ā which is why the benchmark shows no speedup, and can occasionally show a measurable slowdown.
import threading
import requests
def fetch(url):
response = requests.get(url)
print(f"{url}: {response.status_code}")
urls = ["https://a.com", "https://b.com", "https://c.com"]
threads = [threading.Thread(target=fetch, args=(u,)) for u in urls]
for t in threads: t.start()
for t in threads: t.join()
# All three requests happen CONCURRENTLY while waiting on network I/OCPU-bound (math loop): no speedup, same GIL contention
2Race Conditions and Locks: The Real Risk of Shared State
Even though the GIL prevents two threads from executing Python bytecode *simultaneously*, it does not make compound operations atomic. counter += 1 compiles to multiple separate bytecode instructions (load the current value, add one, store the result), and the GIL can switch to a different thread *between* any of those instructions ā meaning two threads can both read the same starting value before either has written back its increment, and one of the two increments is silently lost. This is a genuine race condition, and it's exactly as real a bug in Python's threaded code as in any other language's.
threading.Lock, used as a context manager (with lock:), is the standard fix: it guarantees that only one thread can be inside the protected block at a time, making the read-modify-write sequence effectively atomic from the perspective of other threads competing for the same lock. Every thread attempting to acquire an already-held lock blocks until the holding thread releases it (automatically, when the with block exits) ā this is precisely the guarantee context managers, covered earlier in this module, exist to provide reliably.
The professional discipline this motivates: any time multiple threads read *and write* the same mutable state, that access needs to be protected by a lock (or another synchronization primitive like threading.RLock, Semaphore, or Event) ā 'the GIL protects me' is a common and incorrect assumption that leads directly to intermittent, hard-to-reproduce bugs that only manifest under specific timing conditions.
import threading, time
def cpu_heavy():
total = sum(i * i for i in range(20_000_000))
start = time.time()
threads = [threading.Thread(target=cpu_heavy) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(time.time() - start) # roughly the SAME as running cpu_heavy() 4 times sequentially400000 ā correct, because the increment is now effectively atomic
3When to Actually Reach for threading
The practical decision rule follows directly from the GIL's behavior: use threading specifically for I/O-bound concurrency ā multiple simultaneous network requests, multiple file reads, waiting on several slow external resources at once ā where the wall-clock speedup comes from overlapping *waiting* time, not from genuine parallel computation. For CPU-bound work that needs real parallelism, multiprocessing (the next lesson) or the experimental free-threaded build are the correct tools, not threading on the standard interpreter.
It's also worth noting that threading predates asyncio (covered in the next-but-one lesson) as Python's answer to I/O-bound concurrency, and for new code, asyncio is frequently the more scalable choice ā a single-threaded event loop handling thousands of concurrent I/O operations, versus one OS thread per concurrent operation with threading, which has real memory and context-switching overhead at high concurrency counts. threading remains the right tool specifically when integrating with existing synchronous, blocking libraries that have no async equivalent, or for genuinely simple, small-scale concurrent I/O.
A useful mental checklist before reaching for threading: is the bottleneck waiting on something external (I/O-bound ā threading can help), or is it computing something (CPU-bound ā threading on the standard interpreter will not help, and multiprocessing is the tool to reach for instead)?
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # only one thread inside this block at a time
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 400000 -- correct, thanks to the lockCPU-bound ā multiprocessing
4Step-by-Step Breakdown
Adding threads to a CPU-bound loop can make it SLOWER, not faster. Understanding why is the single most important thing to know before using Python threads at all.
Threads shine when work is I/O-bound: waiting on a network response is 'dead time' where another thread can run.
For CPU-bound work, the GIL means only one thread executes Python bytecode at a time ā threading often provides NO speedup at all.
Checkpoint: Why does running cpu_heavy() on 4 threads take roughly the same time as running it 4 times sequentially?
- āThe GIL only allows one thread to execute Python bytecode at a time, so CPU-bound work does not parallelize across threads
- āThe operating system doesn't allow more than one Python thread to exist
When threads share mutable state, a race condition can corrupt data ā a Lock ensures only one thread modifies it at a time.
Checkpoint: What would likely happen to the final counter value WITHOUT the lock in the increment() example?
- āIt would likely be less than 400000, due to a race condition on the shared counter variable
- āThe program would crash with a TypeError
Threading covers I/O-bound concurrency ā multiprocessing is the tool for the CPU-bound case threads can't help with.
Judge Real Threading Suitability. Finish is_io_bound_speedup_expected(): threading helps I/O-bound work, not CPU-bound work.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Reserve threading for I/O-bound work; never expect it to speed up CPU-bound loops
The GIL guarantees only one thread executes Python bytecode at a time on the standard interpreter ā CPU-bound work needs multiprocessing (or the experimental free-threaded build) for genuine parallelism instead.
Protect every piece of shared mutable state accessed by multiple threads with a Lock
Compound operations like += are not atomic even under the GIL ā unprotected concurrent read-modify-write access reliably produces intermittent, hard-to-reproduce race condition bugs.
Frequent Bugs
Adding threading.Thread to a CPU-bound function expecting a speedup, then being confused when performance is unchanged (or slightly worse due to context-switching overhead).
Profile first to determine whether the bottleneck is I/O-bound (threading can help) or CPU-bound (use multiprocessing instead) before reaching for threads as a performance fix.
Real-World Examples
Concurrently Fetching Multiple API Endpoints
A dashboard needs to call five independent, slow REST API endpoints and combine their results, and doing so sequentially would take the sum of all five response times.
import threading
results = {}
def fetch_and_store(name, url):
results[name] = requests.get(url).json()
threads = [
threading.Thread(target=fetch_and_store, args=("users", USERS_URL)),
threading.Thread(target=fetch_and_store, args=("orders", ORDERS_URL)),
]
for t in threads: t.start()
for t in threads: t.join()
# Total time ~= slowest single request, not the sum of all requests