Under the hood: internals and concurrency · about 50 min

Threads and the GIL

Why this matters

Programs often spend most of their time waiting: for a web server to respond, a disk to deliver data, a user to click. Threads let one program wait for many things at once, so ten 300 ms downloads take 300 ms instead of 3 seconds. Threads also bring the hardest class of bugs in programming, race conditions, and CPython adds its own twist, the Global Interpreter Lock. This lesson shows when threads help, when they do not, and how to share data between them safely.

What a thread is

A process is a running program with its own memory. A thread is a path of execution within a process: its own instruction position and call stack, but sharing the process's memory (all its objects) with the other threads. The operating system schedules threads onto CPU cores and can switch between them at almost any moment.

one process
+-----------------------------------------------+
|  shared heap: all Python objects, modules     |
|                                               |
|  thread 1: stack, current position            |
|  thread 2: stack, current position            |
|  thread 3: stack, current position            |
+-----------------------------------------------+

Python threads are real OS threads (pthreads on Linux), created with the threading module:

import threading
import time

def greet(name, delay):
    time.sleep(delay)
    print("hello from", name)

t1 = threading.Thread(target=greet, args=("A", 0.2))
t2 = threading.Thread(target=greet, args=("B", 0.1))
t1.start()            # begins running greet in a new thread
t2.start()
t1.join()             # wait until t1 has finished
t2.join()
print("both done")
hello from B
hello from A
both done

Thread(target=f, args=(...)) describes the work; start() launches it and returns immediately; join() blocks until that thread finishes. B printed first because its sleep was shorter: threads run independently, and their order of completion depends on timing. Never assume an order you did not enforce.

A thread's function cannot return a value to you directly. Common patterns are writing into a shared list at a known index, putting results on a queue.Queue, or using concurrent.futures (next lesson), which does that bookkeeping for you.

The Global Interpreter Lock

CPython's standard build has a Global Interpreter Lock (GIL): a mutex that a thread must hold to execute Python bytecode. Only one thread runs bytecode at any instant. The running thread is asked to release the GIL every 5 ms (the switch interval, sys.getswitchinterval()) so others get a turn, and it releases it whenever it does a blocking operation such as time.sleep, reading a socket or file, or waiting on a lock.

Why does it exist? Reference counting (previous lesson) updates counters on every object constantly. Without a global lock, every one of those updates would need to be atomic or individually locked, slowing down single-threaded code, which is most code. The GIL makes the interpreter's internals simple and single-threaded code fast, and makes C extensions easy to write.

The consequence:

Kind of workExampleThreads help?
I/O-bound (waiting)downloads, database queries, reading many filesYes: the GIL is released while waiting, so waits overlap
CPU-bound (pure Python)loops of arithmetic, parsing in PythonNo: threads take turns on one core
CPU-bound in C that releases the GILhashlib on big data, zlib, many NumPy operationsYes, partly

Measure it yourself:

import threading, time

def spin(n):
    while n > 0:
        n -= 1

start = time.perf_counter()
spin(20_000_000)
print(f"one thread:  {time.perf_counter() - start:.2f} s")

start = time.perf_counter()
ts = [threading.Thread(target=spin, args=(10_000_000,)) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print(f"two threads: {time.perf_counter() - start:.2f} s")

On a multi-core machine both lines show roughly the same time. (The learnprog sandbox gives you one CPU anyway.)

The free-threaded build

Python 3.13 ships an optional, experimental free-threaded build (the executable is usually called python3.13t) in which the GIL is removed. It uses techniques such as biased reference counting and per-object locks so threads can execute bytecode truly in parallel. Single-threaded code runs somewhat slower on it, and some C extensions are not yet compatible. Check with sys._is_gil_enabled(). It is the likely future of CPython, but the standard build still has the GIL, and everything in this lesson about correctness applies to both: the GIL never made your own code thread-safe.

Race conditions

Consider a shared counter:

counter = 0

def work():
    global counter
    for _ in range(100_000):
        counter += 1

counter += 1 looks like one step, but it compiles to several bytecode instructions: load counter, load 1, add, store counter. A thread can be switched out between the load and the store:

counter is 41
thread A: load counter (41)
                         thread B: load counter (41)
                         thread B: add 1, store 42
thread A: add 1, store 42        <- B's increment is lost

That is a race condition: the result depends on the timing of thread switches. On recent CPython versions with the GIL, this particular example rarely loses updates because switches happen only at certain points, so you may run it a hundred times and see the right answer, then lose updates under different load, on another version, or on the free-threaded build. Races are dangerous precisely because they hide in testing.

Locks

A lock (mutex) ensures only one thread at a time runs a critical section:

import threading

counter = 0
lock = threading.Lock()

def work():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)
400000

with lock: calls lock.acquire() (waiting if another thread holds it) and guarantees lock.release() when the block ends, even on an exception. Every access to the shared data, reads included, must happen under the same lock.

Locks have costs and dangers:

Often the best approach is not to share mutable data at all: give each thread its own inputs and collect results through a queue.Queue, which is internally locked and designed for producer/consumer handoffs.

Common mistakes

Recap

Going further: read the threading and queue module docs, and PEP 703 ("Making the Global Interpreter Lock Optional in CPython") for the reasoning behind the free-threaded build.

Exercises

Exercise 1 · quiz

CPU-bound work in threads

On a standard CPython 3.13 build and a 4-core machine, a pure-Python function that crunches numbers for 4 seconds is run in 4 threads at once, each thread on a quarter of the data. Roughly how long does the whole job take?

Answer
  • About 4 seconds (or a bit more): only one thread executes Python bytecode at a time

The Global Interpreter Lock lets only one thread run Python bytecode at any moment, so CPU-bound pure-Python threads take turns rather than running in parallel; switching even adds a little overhead. For CPU-bound work use processes (next lesson), or code that releases the GIL (C extensions, the free-threaded build). Threads shine when the work is mostly waiting.

Exercise 2 · write code

Overlap the waiting

The starter "downloads" 8 pages one after another. Each fetch(n) sleeps 0.3 s (pretend it waits for the network) and returns page n: <n*n> bytes. Sequentially that takes about 2.4 s.

Rewrite the program to run all 8 fetches in threads (with threading.Thread, one per page), wait for all of them with join(), and then print the results in page order 0 to 7, one per line, exactly as the starter does:

page 0: 0 bytes
page 1: 1 bytes
...
page 7: 49 bytes

The check requires the whole program to finish in under 1.2 seconds. Store each result in a pre-sized list (results[n] = ...) so the order does not depend on which thread finishes first.

File: main.py

Starting point

import time


def fetch(n):
    time.sleep(0.3)
    return f"page {n}: {n * n} bytes"


results = [None] * 8
for n in range(8):
    results[n] = fetch(n)

for line in results:
    print(line)

Example

expected output

page 0: 0 bytes
page 1: 1 bytes
page 2: 4 bytes
page 3: 9 bytes
page 4: 16 bytes
page 5: 25 bytes
page 6: 36 bytes
page 7: 49 bytes
Hint 1

Write a small worker(n) function that does results[n] = fetch(n).

Hint 2

Create threading.Thread(target=worker, args=(n,)) for each n; note the comma making (n,) a tuple.

Hint 3

Start all threads first, then join them all. Starting and joining one at a time is still sequential.

Reference solution
import threading
import time


def fetch(n):
    time.sleep(0.3)
    return f"page {n}: {n * n} bytes"


results = [None] * 8


def worker(n):
    results[n] = fetch(n)


threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
for t in threads:
    t.start()
for t in threads:
    t.join()

for line in results:
    print(line)

Exercise 3 · write code

A thread-safe counter

In solution.py, complete the Counter class so it can be shared by many threads safely:

Then write count_with_threads(n_threads, n_increments) that creates one Counter, starts n_threads threads that each call increment() n_increments times, joins them all, and returns the final value. count_with_threads(4, 10000) must return 40000 every time.

File: solution.py

Starting point

import threading


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

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


def count_with_threads(n_threads, n_increments):
    return 0
Hint 1

self.value += 1 is a read, an add and a write: three steps another thread can interleave with.

Hint 2

with self._lock: acquires the lock and always releases it, even if an exception occurs.

Hint 3

In count_with_threads, define an inner work() that loops n_increments times calling counter.increment().

Reference solution
import threading


class Counter:
    def __init__(self):
        self.value = 0
        self._lock = threading.Lock()

    def increment(self):
        with self._lock:
            self.value += 1


def count_with_threads(n_threads, n_increments):
    counter = Counter()

    def work():
        for _ in range(n_increments):
            counter.increment()

    threads = [threading.Thread(target=work) for _ in range(n_threads)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return counter.value