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 work | Example | Threads help? |
|---|---|---|
| I/O-bound (waiting) | downloads, database queries, reading many files | Yes: the GIL is released while waiting, so waits overlap |
| CPU-bound (pure Python) | loops of arithmetic, parsing in Python | No: threads take turns on one core |
| CPU-bound in C that releases the GIL | hashlib on big data, zlib, many NumPy operations | Yes, 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:
- Contention: threads waiting for a lock do no work. Keep critical sections short.
- Deadlock: thread 1 holds lock A and waits for B; thread 2 holds B and waits for A. Both wait forever. Avoid it by always acquiring multiple locks in the same order, or by not needing two at once.
threading.RLockcan be re-acquired by the thread that holds it; a plainLockdeadlocks if the same thread acquires it twice.
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
- Calling the function instead of passing it:
Thread(target=fetch(3))runsfetchimmediately in the main thread and passes its result as the target. Writetarget=fetch, args=(3,). args=(3): that is just3, not a tuple, givingTypeError: ... argument after * must be an iterable, not int. Write(3,).- Join inside the start loop:
for t in ts: t.start(); t.join()runs threads one at a time. - Forgetting to join: the main program may print results before the threads have produced them.
- Assuming the GIL makes your code thread-safe: it protects the interpreter's internals, not your multi-step operations.
Recap
- Threads share one process's memory;
Thread(target=..., args=...),start(),join(). - The GIL lets only one thread execute Python bytecode at a time, but is released during blocking I/O and sleeps, so threads speed up waiting, not pure-Python computation.
- Python 3.13 has an experimental free-threaded build without the GIL.
- Unsynchronized read-modify-write on shared data is a race condition; protect it with
threading.Lockandwith lock:, or avoid sharing by using queues.
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.