What you'll learn
- Run work concurrently in threads with
ThreadPoolExecutorand see when it helps - Explain the GIL: what it protects, what it does not, and why threads speed up I/O but not CPU work
- Protect shared state with a
Lockand hand work between threads with aQueue - Know where Python 3.14's free-threaded build fits
Concept
A program that waits, on a network reply, a disk read, a database query, spends most of its time doing nothing. Threads let it do several waits at once: while one thread is blocked on a response, another runs. concurrent.futures.ThreadPoolExecutor is the high-level way: hand it functions, get back futures, collect results. Ten downloads that take one second each finish in about one second instead of ten. That is the case threads are for, and it is common.

The Global Interpreter Lock is the reason threads do not speed up computation in the default build of CPython. Only one thread executes Python bytecode at a time; the interpreter switches between threads, and releases the lock while a thread waits on I/O, which is exactly why I/O-bound work overlaps. For CPU-bound work, two threads take turns on one core and finish no faster than one, sometimes slower. The GIL is not a design flaw you can work around from Python code; it is the reason you reach for processes (next lesson) when the work is computation.
| Work is mostly... | Threads help? | Reach for |
|---|---|---|
| waiting on network / disk / DB | yes, a lot | ThreadPoolExecutor, or asyncio (later) |
| pure Python computation | no (GIL) | ProcessPoolExecutor (next lesson) |
| C-extension computation (numpy, hashing) | often yes | threads; the extension releases the GIL |
| many thousands of connections | threads get heavy | asyncio |
The GIL protects the interpreter, not your data. Two threads that both do counter += 1 on a shared variable can still lose updates, because the read, add, and write are separate steps and the switch can happen between them. Any shared mutable state touched by more than one thread needs a threading.Lock, used as a context manager around the critical section. Better still is to not share: give each thread its own work and collect results through return values or a queue.Queue, which is thread-safe by design and is how producer-consumer pipelines are built.
Python 3.14 ships an official free-threaded build, installed separately as python3.14t, in which the GIL is disabled and threads run Python code in parallel on multiple cores. It is supported but still optional: some extensions are not yet compatible, single-threaded code is a little slower, and the default build keeps the GIL. The rules above still hold on the free-threaded build, and matter more: with real parallelism, an unprotected counter += 1 loses updates far more often. Design for correctness with locks and queues either way, and treat free-threading as a deployment choice you test for.

Code
I/O-bound work in a pool: five simulated downloads of 0.3 seconds finish together. The map preserves input order; as_completed yields in finishing order.
# io_bound.py
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
def download(name):
time.sleep(0.3) # stands in for waiting on the network
return f"{name}: 120 KB"
names = ["a", "b", "c", "d", "e"]
start = time.perf_counter()
sequential = [download(n) for n in names]
seq_time = time.perf_counter() - start
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
threaded = list(pool.map(download, names))
futures = [pool.submit(download, n) for n in names]
finished = [f.result()[0] for f in as_completed(futures)]
thr_time = time.perf_counter() - start
print(sequential == threaded, threaded[0])
print(sorted(finished))
print(f"sequential ≥ 1.5s: {seq_time >= 1.5}, threaded < 1.0s: {thr_time < 1.0}")
True a: 120 KB
['a', 'b', 'c', 'd', 'e']
sequential ≥ 1.5s: True, threaded < 1.0s: TrueCPU-bound work in threads does not get faster on the default build. Four threads counting to a few million take about as long as one, which is the GIL at work.
# cpu_bound.py
import sys
import time
from concurrent.futures import ThreadPoolExecutor
def count(n):
total = 0
for i in range(n):
total += i
return total
N = 3_000_000
start = time.perf_counter()
one = [count(N) for _ in range(4)]
single = time.perf_counter() - start
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
many = list(pool.map(count, [N] * 4))
threaded = time.perf_counter() - start
print(one == many)
gil_enabled = getattr(sys, "_is_gil_enabled", lambda: True)()
print("GIL enabled:", gil_enabled)
print("threads no faster than sequential:", threaded > single * 0.7 if gil_enabled else "n/a on free-threaded build")
True
GIL enabled: True
threads no faster than sequential: TrueA lost-update race made reliable with a Barrier, so every thread reads before any writes, exactly the interleaving that happens by chance in real code. The unprotected counter loses three of four increments; the locked one is exact.
# race.py
import threading
THREADS = 4
barrier = threading.Barrier(THREADS)
class Unsafe:
def __init__(self):
self.count = 0
def bump(self):
value = self.count # read
barrier.wait() # every thread has read the same old value...
self.count = value + 1 # ...so every write stores the same new value
class Safe:
def __init__(self):
self.count = 0
self.lock = threading.Lock()
def bump(self):
with self.lock: # read + add + write happen as one step
self.count += 1
def run(counter):
threads = [threading.Thread(target=counter.bump) for _ in range(THREADS)]
for t in threads:
t.start()
for t in threads:
t.join()
return counter.count
print("expected", THREADS)
print("unsafe result:", run(Unsafe()), "(updates lost)")
print("safe result: ", run(Safe()))
expected 4
unsafe result: 1 (updates lost)
safe result: 4Producer and consumer with a Queue: no shared variables, no locks in your code. The sentinel None tells the consumer to stop.
# pipeline.py
import queue
import threading
import time
tasks = queue.Queue()
results = []
def producer():
for i in range(6):
tasks.put(f"job-{i}")
time.sleep(0.01)
tasks.put(None)
def consumer():
while True:
job = tasks.get()
if job is None:
break
time.sleep(0.02)
results.append(job.upper())
tasks.task_done()
threads = [threading.Thread(target=producer), threading.Thread(target=consumer)]
for t in threads:
t.start()
for t in threads:
t.join()
print(results)
print(tasks.empty())
['JOB-0', 'JOB-1', 'JOB-2', 'JOB-3', 'JOB-4', 'JOB-5']
TrueTry it yourself
Write fetch_many.py that calls the local http.server from the HTTP lesson for twenty URLs, first sequentially and then with a ThreadPoolExecutor, printing both times. Add a shared dict of status-code counts updated from the worker threads and protect it with a Lock. Then run the CPU-bound example with max_workers=2 and 8 and confirm the time does not improve. If you can install the free-threaded python3.14t, run cpu_bound.py under it and compare.
Common mistakes
- Threads for computation. On the default build they cannot run Python code in parallel; use processes for CPU-bound work.
- Sharing state without a lock.
+=on a shared counter is three steps; the switch can happen between them and updates vanish. - Raw
threading.Threadwhen an executor would do. The pool manages lifetimes, collects results and exceptions, and reads better.
What's next
- Threads overlap waiting;
ThreadPoolExecutorruns I/O-bound tasks concurrently withmap,submit, andas_completed. - The GIL lets one thread run Python code at a time, so threads do not speed up CPU work; it does not protect your data, so shared state needs a
Lock, or aQueueinstead of sharing. - Python 3.14's free-threaded build is an optional build without the GIL; correct locking matters on both builds.
Threads handle waiting. Next you will use processes for computation, and see the third option 3.14 adds: multiple interpreters.