CPython has the Global Interpreter Lock — a mutex that allows only one thread to execute Python bytecode at a time. Two CPU-bound threads can't run Python in parallel; they take turns. The GIL is released during I/O, so threading is still useful for I/O-heavy work. For CPU-heavy work, you need multiprocessing (next lesson) or the new free-threaded mode in 3.13+ (experimental).
When to use threading
(1) When you need concurrent I/O but a third-party library doesn't support async (a sync database driver, a sync HTTP client). (2) When you have many blocking calls and asyncio refactor isn't viable. (3) When integrating with code that must run in a thread (some GUI frameworks, some C extensions). For modern async-aware code, threading is rarely the right first choice.
The threading API — basics
threading.Thread(target=fn, args=(...)) creates a thread. .start() runs it. .join() waits for it. threading.Lock(), threading.RLock(), threading.Event(), threading.Condition() are the synchronization primitives. queue.Queue is thread-safe and is the right way to pass data between threads.
concurrent.futures — the higher-level interface
For most threading work, concurrent.futures.ThreadPoolExecutor is the right tool. Submit tasks, get back Future objects, collect results. Cleaner than managing threads directly. ProcessPoolExecutor works the same way for processes — same API, different concurrency model.
Code
Basic threading — and the GIL constraint·python
import threading
import time
def worker(name, delay):
print(f"{name}: starting")
time.sleep(delay) # I/O-style — releases GIL
print(f"{name}: done")
start = time.perf_counter()
threads = [
threading.Thread(target=worker, args=(f"t{i}", 1))
for i in range(3)
]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"elapsed: {time.perf_counter() - start:.2f}s")
# elapsed: ~1s (parallel I/O — GIL released during sleep)
# Now CPU-bound — GIL serializes
def cpu_work(n):
total = 0
for _ in range(n):
total += 1
return total
start = time.perf_counter()
threads = [threading.Thread(target=cpu_work, args=(10_000_000,)) for _ in range(3)]
for t in threads: t.start()
for t in threads: t.join()
print(f"cpu elapsed: {time.perf_counter() - start:.2f}s")
# Roughly 3x the single-thread time — GIL prevents parallelism
Lock — protecting shared state·python
import threading
counter = 0
lock = threading.Lock()
def increment(times):
global counter
for _ in range(times):
with lock: # atomic
counter += 1
threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 400000 — without lock, you'd see less
queue.Queue — thread-safe pipe·python
import threading
import queue
q = queue.Queue(maxsize=10)
def producer():
for i in range(5):
q.put(f"item-{i}")
q.put(None) # done signal
def consumer():
while True:
item = q.get()
if item is None:
break
print("got", item)
q.task_done()
t_p = threading.Thread(target=producer)
t_c = threading.Thread(target=consumer)
t_p.start(); t_c.start()
t_p.join(); t_c.join()
ThreadPoolExecutor — easier than raw threads·python
from concurrent.futures import ThreadPoolExecutor
import time
def fetch(url):
time.sleep(0.5) # simulate I/O
return f"got {url}"
urls = ["a", "b", "c", "d", "e"]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
results = list(pool.map(fetch, urls))
print(results)
print(f"{time.perf_counter() - start:.2f}s")
# All 5 ran in parallel (I/O bound) — ~0.5s total
Use ThreadPoolExecutor to fetch (simulate with time.sleep) a list of 8 URLs in parallel, with max_workers=4. Use pool.submit and collect futures, then iterate as_completed to print each result as it finishes. Verify total time is ~2 seconds (8 URLs, 4 at a time, each takes 1 second), not 8 seconds.
Progress
Progress is local-only — sign in to sync across devices.