Expert · Lesson 72 of 94

Threads and the GIL

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

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.

Threads and the GIL: sequential: fetch A (1s) → fetch B (1s) → fetch C (1s) = 3s → threads: A, B, C waiting at the same time ≈ 1s
Threads and the GIL · Figure 1 · Open for full size

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.

Threads and the GIL: thread 1: read counter (10) → thread 2: read counter (10) → thread 1: write 11 → thread 2: write 11  ← one increment lost → with lock: read, add, write as one step → no lost updates
Threads and the GIL · Figure 2 · Open for full size

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}")
Output
True a: 120 KB
['a', 'b', 'c', 'd', 'e']
sequential ≥ 1.5s: True, threaded < 1.0s: True

CPU-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")
Output
True
GIL enabled: True
threads no faster than sequential: True

A 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()))
Output
expected 4
unsafe result: 1 (updates lost)
safe result:   4

Producer 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())
Output
['JOB-0', 'JOB-1', 'JOB-2', 'JOB-3', 'JOB-4', 'JOB-5']
True

Try 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

What's next

Threads handle waiting. Next you will use processes for computation, and see the third option 3.14 adds: multiple interpreters.

Course overview