PYTH-250 · Lesson 4 of 12 · 24 min

Threads and the global interpreter lock

▶ Video coming soon A video version of this lesson is on the way.

After the indexing fix, the month-end report meets its goal. Theo's Monday shipping quotes do not. His script asks the shipping company's server for a quote for each order in turn, and each request spends about half a second waiting for the reply. For 200 orders that is 100 seconds, and almost all of it is waiting: the computer sends a request, then sits idle until the answer arrives. A faster loop cannot fix waiting. What can fix it is doing the waiting for many orders at the same time, which is called concurrency.

This lesson introduces the first tool for concurrency in Python: threads. It explains what a thread is, how to start and join threads with the threading module, why threads speed up programs that wait for input and output but not programs that compute, which comes down to the global interpreter lock, and how threads that share data can corrupt it through race conditions unless they use locks. Because browsers do not let Python start real threads, the thread examples here are listings shown with their real output, and the exercises simulate what threads do so you can run them.

Concurrency and parallelism

Two words are often confused. Concurrency means making progress on several tasks over the same period, by switching between them: while one task waits for a reply, another runs. Parallelism means literally running several tasks at the same instant on different processor cores. A single cook who puts the kettle on, toasts bread while the kettle heats, and butters the toast while the coffee brews is working concurrently. Three cooks each making one breakfast at the same time are working in parallel. Concurrency helps whenever tasks spend time waiting; parallelism helps when there is more computing to do than one core can manage.

Three timelines for three shipping quotes, each made of a short compute block and a long wait block. Sequential: the three run one after another, total 1.5 seconds. Concurrent on one core: the compute blocks run one at a time but the waits overlap, total about 0.55 seconds. Parallel on three cores: all three run side by side, also about 0.5 seconds, with no benefit over concurrency because the work is mostly waiting.
For tasks that are mostly waiting, overlapping the waits gives nearly all of the benefit

Programs are often described as I/O-bound or CPU-bound. An I/O-bound program spends most of its time waiting for input and output: network replies, disk reads, a database, a printer. A CPU-bound program spends most of its time computing: crunching numbers, parsing large files, compressing images. Theo's quotes are I/O-bound. Kai's roast-curve analysis, which fits curves to hundreds of thousands of temperature readings, is CPU-bound. The two need different cures, and telling them apart is the first step.

Comparing processor time with wall-clock time tells I/O-bound from CPU-bound work
import time

def cpu_task():
    total = 0
    for i in range(300_000):
        total += i * i
    return total

def io_task():
    time.sleep(0.2)            # stands in for waiting on a network reply

for task in (cpu_task, io_task):
    wall0, cpu0 = time.perf_counter(), time.process_time()
    task()
    wall = time.perf_counter() - wall0
    cpu = time.process_time() - cpu0
    kind = "CPU-bound" if cpu / wall > 0.5 else "I/O-bound"
    print(f"{task.__name__}: wall {wall:.2f} s, processor {cpu:.2f} s -> {kind}")
Output
cpu_task: wall 0.03 s, processor 0.01 s -> I/O-bound
io_task: wall 0.20 s, processor 0.00 s -> I/O-bound

time.process_time() counts only the time the processor spent running your program, while perf_counter counts real elapsed time. When the two are close, the program was computing the whole time. When processor time is a small fraction of wall time, the program was mostly waiting.

Try it yourself

CPU-bound or I/O-bound?

Write classify(cpu_seconds, wall_seconds) returning "CPU-bound" when processor time is more than half of wall-clock time, and "I/O-bound" otherwise.

Then write waiting_share(cpu_seconds, wall_seconds) returning the fraction of the wall time spent not computing, rounded to 2 decimal places.

def classify(cpu_seconds, wall_seconds):
    return 'CPU-bound'

def waiting_share(cpu_seconds, wall_seconds):
    return 0

print(classify(0.05, 100.0))

Starting threads

A thread is a separate flow of execution inside the same program. All threads of a program share its memory: the same global variables, the same objects. Python's threading module creates them. You give a Thread a target function and its arguments, call start() to begin running it alongside the main program, and call join() to wait until it finishes. Here Theo's quote requests are simulated with time.sleep, which, like a real network call, lets other threads run while it waits.

Eight half-second waits: four seconds in turn, about half a second with threads
import threading, time

def get_quote(order_id, results):
    time.sleep(0.5)                          # waiting for the shipping server
    results[order_id] = 12.40 + len(order_id) / 10

orders = [f"A-10{n:02d}" for n in range(8)]

start = time.perf_counter()
results = {}
for o in orders:                             # one at a time
    get_quote(o, results)
print(f"sequential: {time.perf_counter() - start:.1f} s for {len(results)} quotes")

start = time.perf_counter()
results = {}
threads = [threading.Thread(target=get_quote, args=(o, results)) for o in orders]
for t in threads:
    t.start()                                # all eight begin waiting together
for t in threads:
    t.join()                                 # wait for every thread to finish
print(f"threaded:   {time.perf_counter() - start:.1f} s for {len(results)} quotes")
Output
sequential: 4.0 s for 8 quotes
threaded:   0.5 s for 8 quotes

The threaded version finishes in a little over half a second instead of four, because the eight waits overlap. Each thread writes its own key in the shared results dictionary, which is safe because each key is written by only one thread. For 200 real orders the same idea takes Theo's quotes from 100 seconds to a few seconds, limited by how many requests the shipping company allows at once. In practice you would not start 200 bare threads by hand; lesson 6 shows the thread pool, which manages a fixed number of worker threads for you.

The global interpreter lock

The standard CPython interpreter has a global interpreter lock, the GIL: a lock that only one thread can hold at a time, and a thread must hold it to run Python bytecode. The GIL makes the interpreter's internals, such as the reference counts on every object, simple and safe, and it makes single-threaded code fast. Its cost is that two threads cannot run Python code at the same time, even on a machine with many cores. Threads take turns.

Threads still help I/O-bound programs, because a thread releases the GIL while it waits: during time.sleep, a socket read, a file read or a database query, another thread can run. Many C extensions, including NumPy for large array operations, also release the GIL while they compute. But for pure Python computation, threads add switching overhead without adding speed, as this experiment shows.

CPU-bound work does not speed up with threads under the GIL (times vary)
import threading, time

def crunch(n):
    total = 0
    for i in range(n):
        total += i % 7
    return total

N = 2_000_000
start = time.perf_counter()
crunch(N); crunch(N)
seq = time.perf_counter() - start

start = time.perf_counter()
ts = [threading.Thread(target=crunch, args=(N,)) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
par = time.perf_counter() - start
print(f"two tasks in turn: {seq:.2f} s; two threads: {par:.2f} s")
print("threads were much faster:", par < seq * 0.7)
Output
two tasks in turn: 0.16 s; two threads: 0.30 s
threads were much faster: False
Two thread timelines under the GIL. For CPU-bound work, thread 1 and thread 2 alternate in short slices and never run at the same time, so the total is the same as running them one after the other. For I/O-bound work, while thread 1 waits on the network with the GIL released, thread 2 runs, so the waits overlap and the total is shorter.
Under the GIL, threads take turns running Python code but can wait at the same time

Python is changing here. Python 3.13 introduced an optional free-threaded build of CPython without the GIL, described in PEP 703, and in Python 3.14 that build became officially supported, though it is still a separate installation and the standard build keeps the GIL. In the free-threaded build, CPU-bound threads can genuinely run in parallel, at the cost of some single-threaded speed and with extension libraries needing to be updated. For now, the safe rule for everyday Python is: threads for I/O-bound work, processes (lesson 5) for CPU-bound work.

Race conditions

Shared memory makes threads convenient and dangerous. A race condition happens when the result of a program depends on the unpredictable order in which threads run. The classic example is a counter. counter += 1 looks like one step, but it is three: read the current value, add one, write the result back. If two threads both read 41 before either writes, both write 42, and one increment is lost. The program is correct most of the time and wrong occasionally, which makes race conditions some of the hardest bugs to find.

Replaying a bad interleaving step by step: one increment is lost
# One possible interleaving of two threads each doing counter += 1
counter = 41
steps = [("T1", "read"), ("T2", "read"), ("T1", "add"), ("T2", "add"), ("T1", "write"), ("T2", "write")]
local = {}
for thread, op in steps:
    if op == "read":
        local[thread] = counter
    elif op == "add":
        local[thread] += 1
    else:
        counter = local[thread]
    print(f"{thread} {op:5} -> local={local[thread]}, counter={counter}")
print("two increments, but counter is", counter)
Output
T1 read  -> local=41, counter=41
T2 read  -> local=41, counter=41
T1 add   -> local=42, counter=41
T2 add   -> local=42, counter=41
T1 write -> local=42, counter=42
T2 write -> local=42, counter=42
two increments, but counter is 42

Because the GIL can switch threads between any two bytecodes, even code that looks atomic in Python source may not be. Some operations happen to be atomic in CPython, such as list.append or setting a single dictionary key, but relying on that is fragile. The reliable rule is: when threads share data that any of them changes, protect every access with a lock, or avoid sharing by giving each thread its own data and combining the results afterwards.

Try it yourself

Replay an interleaving

Write replay(start, steps) that simulates threads each doing counter += 1. steps is a list of (thread, op) pairs where op is "read" (copy counter into that thread's local value), "add" (add 1 to its local value) or "write" (copy its local value into counter). Return the final counter.

Then write lost_updates(start, steps) returning how many increments were lost: the number of "write" steps minus the actual increase of the counter.

def replay(start, steps):
    return start + sum(1 for t, op in steps if op == 'write')

def lost_updates(start, steps):
    return 0

BAD = [('T1', 'read'), ('T2', 'read'), ('T1', 'add'), ('T2', 'add'), ('T1', 'write'), ('T2', 'write')]
print(replay(41, BAD))

Locks

A lock, threading.Lock, is an object that only one thread can hold at a time. A thread calls acquire() to take it, waiting if another thread has it, and release() to give it back. Code between the two is a critical section: only one thread can be inside it at once. The with statement acquires and releases the lock for you, and releases it even if an exception is raised, so it is the form to use.

A lock makes the read-add-write a critical section
import threading

counter = 0
lock = threading.Lock()

def add_many(n):
    global counter
    for _ in range(n):
        with lock:                  # read, add and write happen as one step
            counter += 1

threads = [threading.Thread(target=add_many, args=(100_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print("counter:", counter, "(always 400000 with the lock)")
Output
counter: 400000 (always 400000 with the lock)

Locks fix races but bring their own problems. Holding a lock while doing slow work makes other threads wait, so keep critical sections short. And if two threads each hold one lock and wait for the other's, neither can ever continue: a deadlock. The standard defence is to always acquire multiple locks in the same order, or better, design the program so that threads rarely share mutable data. The queue.Queue class, a thread-safe queue with its own internal locking, is the usual way for threads to hand work to each other safely.

Worker threads take jobs from a thread-safe queue.Queue
import queue, threading

jobs = queue.Queue()
done = []
done_lock = threading.Lock()

def worker():
    while True:
        order = jobs.get()
        if order is None:            # sentinel: no more work
            break
        with done_lock:
            done.append(f"label printed for {order}")
        jobs.task_done()

workers = [threading.Thread(target=worker) for _ in range(3)]
for w in workers: w.start()
for o in ["A-1001", "A-1002", "A-1003", "A-1004", "A-1005"]:
    jobs.put(o)
jobs.join()                          # wait until every job is marked done
for _ in workers: jobs.put(None)
for w in workers: w.join()
print(sorted(done))
Output
['label printed for A-1001', 'label printed for A-1002', 'label printed for A-1003', 'label printed for A-1004', 'label printed for A-1005']

Try it yourself

A thread-safe counter class

Write a class SafeCounter with an attribute lock holding a threading.Lock() and a value starting at 0. Its increment(by=1) method must update value inside with self.lock:, and get() returns the value.

The browser cannot start threads, so the tests call the methods directly and check that the lock is used and released.

import threading

class SafeCounter:
    def __init__(self):
        self.value = 0

    def increment(self, by=1):
        self.value += by

    def get(self):
        return self.value

c = SafeCounter()
c.increment(); c.increment(5)
print(c.get())

When threads are the right tool

Threads suit programs that do many independent waits: downloading files, calling web APIs, querying databases, reading from devices such as Kai's roaster or the label printer. They are simple to add to existing blocking code, because the code inside each thread stays ordinary sequential Python. They are the wrong tool for speeding up pure Python computation under the GIL, and every shared mutable object they touch needs care. In the next lessons, processes take over for CPU-bound work, concurrent.futures gives both tools a tidy interface, and asyncio offers a different way to handle thousands of waits with a single thread.

Common mistakes

  • MistakeUsing threads to speed up pure Python number crunching.

    InsteadUnder the GIL threads take turns; use processes or vectorized libraries for CPU-bound work.

  • MistakeAssuming counter += 1 is a single safe step.

    InsteadIt is a read, an add and a write; protect shared updates with a lock.

  • MistakeForgetting to join threads.

    InsteadCall join() on each thread before using its results.

  • MistakeCalling acquire() without a matching release() when an exception occurs.

    InsteadUse with lock: so the lock is always released.

  • MistakeHolding a lock during slow I/O.

    InsteadKeep critical sections short; do the slow work outside the lock.

  • MistakeAcquiring two locks in different orders in different threads.

    InsteadAlways take multiple locks in the same order to avoid deadlock.

Summary and key terms

  • Concurrency overlaps tasks over time; parallelism runs them at the same instant on several cores.
  • I/O-bound programs mostly wait; CPU-bound programs mostly compute. Compare process_time with wall time to tell them apart.
  • threading.Thread runs a function in a new thread; start() begins it and join() waits for it.
  • CPython's global interpreter lock lets only one thread run Python bytecode at a time.
  • Threads speed up I/O-bound work because waiting releases the GIL; they do not speed up pure Python computation.
  • Race conditions corrupt shared data; locks create critical sections that one thread at a time can enter.
  • queue.Queue passes work between threads safely; deadlocks are avoided by consistent lock ordering.
concurrency
Making progress on several tasks over the same period by switching between them.
parallelism
Running several tasks at the same instant on different processor cores.
I/O-bound
Limited by waiting for input and output such as networks and disks.
CPU-bound
Limited by the amount of computation.
thread
A separate flow of execution that shares memory with the rest of the program.
global interpreter lock (GIL)
A CPython lock that allows only one thread to run Python bytecode at a time.
race condition
A bug where the result depends on the unpredictable order in which threads run.
lock
An object that only one thread can hold, used to protect a critical section.
critical section
Code that only one thread at a time may execute.
deadlock
A state where threads wait for each other forever.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Downloading 50 supplier price files takes 50 seconds, with processor time 0.4 seconds. Will threads help?

    Show answer

    Yes: the program is I/O-bound, so overlapping the waits can cut the time dramatically.

  2. Why do two CPU-bound threads take about as long as running the tasks in turn?

    Show answer

    The GIL lets only one thread run Python bytecode at a time.

  3. What are the three steps hidden inside counter += 1?

    Show answer

    Read the value, add one, write it back.

  4. Why is with lock: better than calling acquire() and release()?

    Show answer

    It releases the lock even if an exception is raised.

  5. What is a sentinel in a worker queue?

    Show answer

    A special value, such as None, that tells a worker there is no more work.

  6. Name the optional CPython build that removes the GIL.

    Show answer

    The free-threaded build, introduced in Python 3.13 (PEP 703).

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz