Concurrency models in Python

Advanced35 minPython 3.14+

Tell I/O-bound work from CPU-bound work, see what the GIL does and does not stop, and choose between threads, processes, asyncio, interpreters and the free-threaded build.

What you will learn
  • Tell concurrency apart from parallelism, and I/O-bound work from CPU-bound work
  • Explain what the global interpreter lock prevents and what it allows
  • Name Python's four ways to run work at the same time and the cost of each
  • State the support status of the free-threaded build in 3.14 and 3.15 accurately
  • Choose a model for a workload and justify the choice
On this page

A program that waits for a web page, then a database, then a file, spends most of its life doing nothing. A program that resizes ten thousand photos keeps one core busy while the others sit idle. Both get faster by doing several things at once, but they need different tools. This lesson is the map of Python’s tools; the rest of the module teaches each one.

Before you start: you should know functions and closures, context managers and exception groups, and be able to profile a program to see where its time goes.

Concurrency and parallelism

Two words that are often mixed up:

  • Concurrency means several tasks are in progress at the same time. They may take turns on one CPU core. A waiter serving six tables is concurrent.
  • Parallelism means several tasks execute at the same instant, on different cores. Six waiters are parallel.

You can have concurrency without parallelism, and that is often all you need.

Where the time goes

Ask what your program is waiting for.

  • I/O-bound work waits for something outside the CPU: the network, a disk, a database, a user. Making the CPU faster barely helps. Overlapping the waits helps a lot, and concurrency alone achieves that.
  • CPU-bound work keeps the processor busy: parsing, compressing, number crunching in pure Python. Only real parallelism, more cores working at once, makes it faster.

Most programs are a mix, so measure first, as in Profiling.

The global interpreter lock

The standard build of CPython has a global interpreter lock (GIL), a lock that ensures only one thread executes Python bytecode at a time (glossary). Two consequences follow:

  1. Threads in one interpreter cannot run Python code in parallel, so threads do not speed up CPU-bound pure-Python code.
  2. A thread releases the GIL while it waits for I/O or sleeps, and many C extensions release it during long computations. So threads do overlap waiting.

You can see the second point without a stopwatch. A threading.Barrier(3) only lets threads past when three of them are waiting on it at the same moment. If the three calls overlap, they all get through. If they run one after another, the first one gives up after its timeout:

wait_together.py
import threading
from concurrent.futures import ThreadPoolExecutor
def wait_for_reply(barrier):
barrier.wait(timeout=0.5)
return "reply"
for workers in (3, 1):
barrier = threading.Barrier(3)
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(wait_for_reply, barrier) for _ in range(3)]
try:
print(f"workers={workers}:", [f.result() for f in futures])
except threading.BrokenBarrierError:
print(f"workers={workers}: the three waits never overlapped")
Output
workers=3: ['reply', 'reply', 'reply']
workers=1: the three waits never overlapped

With three worker threads, all three waits were in progress at once, even with a GIL. With one worker they ran in turn. ThreadPoolExecutor and futures get a proper introduction in the next lesson.

Python’s four tools

Model What runs at once Parallel CPU work? Main cost
Threads (threading) OS threads sharing all objects Not on the default build Races on shared data
Processes (multiprocessing) Separate Python processes Yes Start-up time, data is copied
asyncio Coroutines taking turns in one thread No Every library call must cooperate
Interpreters (concurrent.interpreters) Isolated interpreters in one process, each with its own GIL Yes Data is copied, some extensions unsupported

The fourth row is new in Python 3.14 (PEP 734). Each interpreter has its own GIL, so code in one interpreter runs on one core while code in another runs on a different core, without blocking each other. Multiple interpreters covers them.

Three of the four share one interface, concurrent.futures. Swapping the executor class switches the model:

same_interface.py
from concurrent.futures import (
InterpreterPoolExecutor,
ProcessPoolExecutor,
ThreadPoolExecutor,
)
def sum_of_squares(n):
return sum(i * i for i in range(n))
if __name__ == "__main__":
sizes = [10, 100, 1000]
for executor_class in (
ThreadPoolExecutor,
ProcessPoolExecutor,
InterpreterPoolExecutor,
):
with executor_class(max_workers=3) as pool:
results = list(pool.map(sum_of_squares, sizes))
print(f"{executor_class.__name__:24} {results}")
Output
ThreadPoolExecutor [285, 328350, 332833500]
ProcessPoolExecutor [285, 328350, 332833500]
InterpreterPoolExecutor [285, 328350, 332833500]

The answers are the same. What differs is where sum_of_squares runs: a thread in this interpreter, a child process, or a thread running its own interpreter. The if __name__ == "__main__": guard matters for the last two, and Processes explains why. asyncio is the odd one out: it needs functions written with async def, which asyncio fundamentals introduces and structured concurrency makes safe.

The free-threaded build

There is a fifth option, and it is a different build of Python rather than a module. The free-threaded build is compiled without the GIL, so plain threads run Python code in parallel. Its status:

  • Python 3.13 introduced it as experimental (PEP 703).
  • In 3.14 it became officially supported and no longer experimental: “phase II”, in which free-threaded Python is officially supported but still optional (PEP 779, What’s New in 3.14).
  • Requires version 3.15 or later Python 3.15 keeps it supported and optional. It adds a stable ABI for free-threaded extensions (abi3t, PEP 803), and the official macOS installer now installs the free-threaded build by default, alongside the regular one.
  • It is not the default build. Whether it ever becomes the default (phase III) “is still undecided”.

So “the GIL is gone” is wrong. The GIL is optional, in a separate build that you install on purpose. You can check which build is running:

build_check.py
import sys
import sysconfig
free_threaded_build = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
print("free-threaded build:", free_threaded_build)
print("GIL enabled now:", sys._is_gil_enabled())
Terminal window
python3.15 build_check.py
Output
free-threaded build: False
GIL enabled now: True
Terminal window
python3.15t build_check.py
Output
free-threaded build: True
GIL enabled now: False

The free-threaded executable carries a t suffix. Free-threaded Python covers installing it, and what changes for your code.

Choosing

Work through these questions in order:

  1. Mostly waiting on the network, with many connections? Use asyncio, if the libraries you need have async versions.
  2. Waiting on I/O through ordinary blocking libraries? Use a thread pool.
  3. CPU-bound pure Python? Use processes. They work everywhere and isolate crashes. Try interpreters when start-up cost or memory matters and your extensions support them. Try the free-threaded build when you want threads with shared memory and your dependencies support it.
  4. CPU-bound inside NumPy or another C extension that releases the GIL? Threads may already run in parallel. Measure first.

Common pitfalls

Reaching for threads to speed up a CPU-bound loop. On the default build the threads take turns, so the program gets no faster, and often slightly slower.

Saying “Python can’t do parallelism”. Processes always could. Subinterpreters have had their own GIL since 3.12, and the free-threaded build is officially supported.

Treating the free-threaded build as the default. It is a separate download with a t suffix, and third-party extensions may not support it yet.

ExerciseClassify eight workloads

For each workload, write one line in the form number, I/O or CPU, tool, reason, for example 9, I/O, thread pool, the vendor library only has blocking calls. The second column has one right answer for workloads 1 to 6. For the tool, more than one answer can often be defended, so compare your reasons with the model answer.

  1. Download 500 web pages.
  2. Resize 2,000 JPEG files in pure Python.
  3. A chat server holding 10,000 open connections.
  4. Compute SHA-256 hashes of 50 large files with hashlib.
  5. Call a slow vendor SDK that only has a blocking API, 40 times.
  6. Run a Monte Carlo simulation written in pure Python on 8 cores.
  7. Run untrusted plugins so a crash cannot take down the main program.
  8. Parse 300 JSON files, each small, from a local SSD.
Show solution
  1. I/O, asyncio with an async HTTP client, or a thread pool with a blocking one.
  2. CPU, a process pool. Interpreters work too if the image code is pure Python.
  3. I/O, asyncio: thousands of coroutines are cheap, thousands of threads are not.
  4. CPU, a thread pool: hashlib releases the GIL while it hashes large inputs, so threads can already use several cores. Measure.
  5. I/O, a thread pool, or asyncio.to_thread from async code, because the API only blocks.
  6. CPU, processes, interpreters, or threads on the free-threaded build.
  7. CPU or I/O depending on the plugin, separate processes: interpreters share one process, so a crash in C code takes everything down.
  8. I/O on paper, but no tool at all: the whole job takes milliseconds. Keep it sequential unless measuring shows otherwise.
ExercisePredict the barrier

Change wait_together.py so the loop runs for workers in (2,): and the barrier still needs 3 parties. Predict the single line it prints, and why, then run it.

Show solution
workers=2: the three waits never overlapped

Two workers start two waits. The third call is queued until a worker is free, but neither worker becomes free, because both are stuck at the barrier waiting for a third party. After 0.5 seconds the first wait times out and breaks the barrier. The other waits then fail straight away with BrokenBarrierError. The program prints the except branch.

ExerciseWaiting in two pools

Write io_pools.py. Its function wait_for_reply(n) calls time.sleep(0.2), standing in for a network call, and returns n. Run it over range(6) in a ThreadPoolExecutor and then a ProcessPoolExecutor, each with max_workers=3. Time each with block with time.perf_counter(), and print the class name, the results, and whether the time was at least 0.4 s and under 1.0 s. Then explain why both pools take about 0.4 s rather than 1.2 s, and why asyncio cannot run this function concurrently as written.

ThreadPoolExecutor [0, 1, 2, 3, 4, 5] True
ProcessPoolExecutor [0, 1, 2, 3, 4, 5] True
Show solution
io_pools.py
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def wait_for_reply(n):
time.sleep(0.2) # blocking I/O, such as a network call
return n
if __name__ == "__main__":
for executor_class in (ThreadPoolExecutor, ProcessPoolExecutor):
start = time.perf_counter()
with executor_class(max_workers=3) as pool:
results = list(pool.map(wait_for_reply, range(6)))
elapsed = time.perf_counter() - start
print(f"{executor_class.__name__:20} {results}", 0.4 <= elapsed < 1.0)
Output
ThreadPoolExecutor [0, 1, 2, 3, 4, 5] True
ProcessPoolExecutor [0, 1, 2, 3, 4, 5] True

Six jobs on three workers make two rounds of 0.2 s. A sleeping thread releases the GIL, so three threads wait at once, and three processes wait at once anyway. Typical times are about 0.4 s for the threads and 0.5 s for the processes, which pay to start. For waiting, both work, and threads are cheaper. time.sleep blocks the thread it runs in, so under asyncio the six calls would run one after another, 1.2 s in all. They would need await asyncio.sleep(0.2) or asyncio.to_thread.

Summary

  • Concurrency is many tasks in progress. Parallelism is many tasks executing at the same instant.
  • I/O-bound work needs concurrency. CPU-bound work needs parallelism.
  • On the default build, the GIL lets one thread run Python bytecode at a time, but threads release it while they wait.
  • Threads, processes, asyncio and interpreters are the four models. Three share the concurrent.futures executor interface.
  • The free-threaded build is officially supported but optional. It is not the default, and phase III is undecided.
  • Next: threads, the first of the four models in depth.