Part 5 · Advanced Python · Module 37

Concurrency: Threads, Processes & Interpreters

Python 3.14 gives you more ways to do several things at once than any previous release: threads, processes, subinterpreters with their own GIL, and an officially supported free-threaded build with no GIL at all. Each has a different cost model. This module explains the machinery underneath so you can pick the right tool, predict its performance, and avoid the bugs that come with shared state.

Concurrency, parallelism, and the shape of your workload

Concurrency is about structure: a program has several tasks in progress at the same time, and their steps interleave. Parallelism is about execution: several steps literally run at the same instant on different CPU cores. A single-core machine can run a concurrent program (by switching between tasks) but cannot run it in parallel. Rob Pike's summary: concurrency is dealing with lots of things at once; parallelism is doing lots of things at once.

Which one you need depends on where your program spends its time:

I/O-bound

Most time is spent waiting: for the network, a disk, a database, a subprocess. The CPU is idle. Concurrency helps enormously, because while one task waits, another can run. You don't need more cores; you need to stop waiting serially. Tools: threads, asyncio (M38).

CPU-bound

Most time is spent computing: parsing, compressing, numeric loops, image processing. Interleaving tasks on one core gains nothing (it adds switching overhead). You need parallelism: more cores executing Python bytecode simultaneously. Tools: processes, subinterpreters, free-threaded Python, or native code that releases the GIL (NumPy).

Before parallelizing, estimate the ceiling. Amdahl's law says that if a fraction p of the runtime can be parallelized across n workers, the best possible speedup is 1 / ((1 - p) + p / n). The serial part dominates quickly: with 90% parallel code, even infinite cores give at most 10×.

Real speedups are lower still, because parallel workers pay coordination costs: starting workers, copying data to them, waiting for locks, and combining results. A recurring theme of this module is that each tool sits at a different point on the trade-off between isolation (safety, but expensive data transfer) and sharing (cheap data access, but you must synchronize).

The GIL, explained

CPython manages memory with reference counting: every object has a counter of how many references point at it, and the object is freed when it drops to zero. Incrementing and decrementing those counters happens constantly, on every assignment and function call. If two threads updated the same counter at once without synchronization, counts would be corrupted and objects freed while still in use.

The classic solution is the Global Interpreter Lock (GIL): one mutex per interpreter that a thread must hold to execute Python bytecode or touch Python objects. It makes reference counting and the internals of dict, list, and friends safe without fine-grained locks, and makes single-threaded code fast. The cost: in a standard build, only one thread executes Python bytecode at a time, so threads never speed up pure-Python CPU work.

Threads still help with I/O because the GIL is released at the right moments:

That last point is the root of race conditions. The GIL makes individual bytecode instructions atomic, but a line like counter += 1 compiles to several instructions: load, add, store. A switch between the load and the store loses an update.

A thread switch after LOAD_GLOBAL counter but before STORE_GLOBAL counter lets another thread read the same old value. Both store old + 1, and one increment vanishes. In a GIL build this is rare for such a tiny window (and recent CPython versions make it rarer still), which is exactly what makes it dangerous: tests pass, production loses data. In a free-threaded build it happens constantly.

Threads and race conditions

A thread is an independent flow of execution inside one process. All threads share the same memory: the same modules, globals, and objects. That makes communication free and bugs easy. The threading module wraps the operating system's threads (each Python thread is a real OS thread).

The timeline below compares running three I/O waits sequentially with running them in threads. Notice that the threads overlap their waiting, which is where the time goes in I/O-bound work.

A race condition you can watch

Real threads can't run in your browser, but the essence of a race is just interleaving. We can simulate a preemptive scheduler with generators: each yield marks a point where the "OS" may switch to another task. Run it a few times; change the seed; then turn on USE_LOCK.

With the lock off, both withdrawals can be approved against the same balance of 100, which a bank would not enjoy. The lock makes the read-check-write sequence a critical section: only one task may be inside it at a time. Here is the same bug with real threads (the time.sleep(0) forces a switch inside the window to make the race reproducible):

The synchronization toolbox

PrimitiveWhat it doesTypical use
LockMutual exclusion; with lock: acquires and releasesProtect a shared invariant
RLockRe-entrant lock: the owning thread may acquire it again (must release as many times)Methods that call other locked methods of the same object
EventA boolean flag threads can wait() on; set() wakes all waiters"Start now" or "shut down" signals
ConditionA lock plus wait()/notify(); wait_for(predicate) re-checks after waking"Wait until the buffer is non-empty"
Semaphore(n)A counter: at most n holders at once (BoundedSemaphore catches over-release)Limit concurrent connections
Barrier(n)Blocks until n threads arrivePhase-based algorithms
threading.local()Per-thread attribute storagePer-thread DB connection

Remember: threads share memory; any compound operation on shared state needs a critical section. Prefer designs that share less (next section) over designs with more locks.

Producer/consumer with queue.Queue

The safest way to share data between threads is to not share it: pass it. queue.Queue is a thread-safe FIFO with the locking done for you. Producers put() work items; consumers get() them. A maxsize gives back-pressure: when the queue is full, producers block instead of piling up unbounded memory. task_done() and join() let the main thread wait until every item has been processed, and a sentinel (often None) tells consumers to exit.

The API itself works fine without threads, so you can explore it here:

concurrent.futures: executors and futures

Managing threads by hand is verbose. concurrent.futures provides one interface over three kinds of worker pools: ThreadPoolExecutor, ProcessPoolExecutor, and (new in 3.14) InterpreterPoolExecutor. You submit callables and get back futures: placeholder objects for a result that doesn't exist yet.

A Future is just a thread-safe box with callbacks; you can drive one by hand to see its lifecycle:

multiprocessing: true parallelism with isolation

A process has its own memory space and its own interpreter (and so its own GIL). Running Python in several processes gives real CPU parallelism on a standard build. The price is isolation: processes don't share objects, so arguments and results must be serialized with pickle, sent through a pipe, and deserialized on the other side. That means:

Start methods

How a child process is created matters for speed, safety, and what state it inherits:

MethodHowProsConsDefault on
forkCopy the parent process (copy-on-write)Fast start; child inherits everythingUnsafe with threads: locks held by other threads are copied in a locked state; can deadlock or crashnone since 3.14
spawnStart a fresh interpreter, import the main moduleClean, safe, portableSlowest start; everything is pickledWindows, macOS
forkserverA clean single-threaded server process forks children on requestFast-ish and thread-safePOSIX only; still pickles argumentsLinux (and other POSIX except macOS) 3.14

Sharing memory instead of copying it

For big numeric data, pickling every chunk is wasteful. multiprocessing.shared_memory.SharedMemory creates a named block of memory that several processes map; wrap it in a NumPy array and workers read and write the same bytes with no copying. You are back to shared state, so coordinate writes (each worker owns a slice, or use a multiprocessing.Lock). Simpler options: multiprocessing.Value/Array for small typed buffers, and Manager() for proxied dicts and lists (convenient but slow: every access is an IPC round-trip).

Free-threaded Python 3.14: no GIL

PEP 703 introduced an optional CPython build without the GIL in 3.13 (experimental). In 3.14, PEP 779 promoted it to officially supported (phase II): it is a separate build you choose, not the default. In this build, threads run Python bytecode truly in parallel on multiple cores. To make that safe, CPython uses biased reference counting (cheap counts for the owning thread, atomic ones for others), immortal objects (like None and small ints, whose counts never change), per-object locks inside built-in containers, and a thread-safe memory allocator (mimalloc).

The interpreter binary is conventionally called python3.14t (the t stands for "threaded"). You can tell which build you're on, and whether the GIL is actually off right now:

The GIL can come back. If you import a C extension that hasn't declared itself free-threading compatible, the interpreter re-enables the GIL (and warns) to stay safe. You can force the choice with the environment variable PYTHON_GIL=0 or PYTHON_GIL=1, or with -X gil=0. Most of the scientific stack (NumPy, SciPy, and others) publishes free-threaded builds, but check each dependency.

Thread safety without a GIL

Free threading doesn't change Python's semantics, but it removes the accidental protection many programs relied on. What still holds and what doesn't:

OperationFree-threaded behavior
Single operations on built-ins (list.append, dict[k] = v, d.get(k))Safe: internally locked per object; no crashes or corruption
Compound operations (x += 1, if k not in d: d[k] = ..., iterate-while-mutating)Racy, and now races happen often. Use a lock or an atomic design.
Sharing one iterator or generator between threadsNot safe; give each thread its own
queue.Queue, threading primitives, concurrent.futuresDesigned for this; your best tools
Your own classes with mutable stateAs unsafe as they always were, minus the luck

Performance expectations

Subinterpreters: concurrent.interpreters

A third model sits between threads and processes. A subinterpreter is a separate Python interpreter inside the same process: its own modules, its own sys, its own objects, and, since 3.12 (PEP 684), its own GIL. Several subinterpreters running in different threads execute Python truly in parallel, even on a standard GIL build. Python 3.14 exposes this through the new concurrent.interpreters module (PEP 734) and concurrent.futures.InterpreterPoolExecutor.

Like processes

Isolated: no shared Python objects, so no data races on your objects. Communicate by passing data (queues, pickling).

Like threads

One OS process: faster to start than a process and lower memory; no fork hazards; cheap to exchange certain data such as bytes and memoryview buffers.

Caveats

New and evolving. Extension modules must support multiple interpreters (many do not yet). Startup still costs (each imports its own modules). Not available in the browser.

Choosing the right tool

WorkloadBest first choiceWhyWatch out for
Many network calls (hundreds to thousands)asyncio (M38)One thread, thousands of cheap tasks, explicit switch pointsNeeds async libraries; blocking calls stall everything
Some I/O with blocking libraries (requests, DB drivers)ThreadPoolExecutorGIL released during I/O; simple; works with any libraryShared state needs locks
CPU-bound pure Python, standard buildProcessPoolExecutorReal parallelism, mature, robust isolationPickling cost; start-up time; __main__ guard
CPU-bound pure Python, want lower overhead than processesInterpreterPoolExecutorParallel, isolated, single processNew; extension module support varies
CPU-bound with shared in-memory data structuresFree-threaded build + threadsParallel with zero-copy sharingYou own thread safety; extension compatibility; ~5–10% single-thread cost
Numeric arraysNumPy (vectorize first), then threadsNumPy releases the GIL in heavy loopsMake sure the work is really in C, not a Python loop
Huge data, many machinesA distributed framework (Dask, Ray, Spark; Part 9)Beyond one machineOperational complexity

Practice

Write speedup(p, n) returning the Amdahl speedup for parallel fraction p (0 to 1) on n workers, and workers_needed(p, target) returning the smallest integer n whose speedup is at least target, or None if the target is impossible (at or above the limit 1 / (1 - p)). Raise ValueError if p is outside [0, 1] or n < 1.

Hint

Solve 1 / ((1 - p) + p / n) >= target for n, or just loop upward from 1 (the loop terminates because you've already excluded impossible targets). Beware floating-point: compare with a tiny tolerance.

Show solution
def speedup(p, n):
    if not 0 <= p <= 1 or n < 1:
        raise ValueError("need 0 <= p <= 1 and n >= 1")
    return 1 / ((1 - p) + p / n)

def workers_needed(p, target):
    if p < 1 and target >= 1 / (1 - p) - 1e-12:
        return None
    n = 1
    while speedup(p, n) < target - 1e-12:
        n += 1
    return n

Using generators as simulated threads, write run_round_robin(tasks) that advances each generator one step at a time in round-robin order (task 0, 1, 2, 0, 1, 2, ...) until all are exhausted, dropping finished ones. Then write simulate(n_tasks, increments, atomic) that resets a shared state = {"count": 0}, creates n_tasks generators that each increment state["count"] increments times, runs them round-robin, and returns the final count. When atomic is False, each increment must yield between reading and writing (a switch point inside the read-modify-write); when True, read and write happen with no yield in between (yield once after the write instead).

Hint

Keep a list of live generators; loop over a copy, call next(g) inside try, and remove it on StopIteration. Define the worker generator inside simulate so it closes over state.

Show solution
def run_round_robin(tasks):
    live = list(tasks)
    while live:
        for task in list(live):
            try:
                next(task)
            except StopIteration:
                live.remove(task)

def simulate(n_tasks, increments, atomic):
    state = {"count": 0}

    def worker():
        for _ in range(increments):
            if atomic:
                state["count"] += 1
                yield
            else:
                value = state["count"]
                yield                      # switch point inside read-modify-write
                state["count"] = value + 1

    run_round_robin([worker() for _ in range(n_tasks)])
    return state["count"]

Parallel workers usually get contiguous slices of the input. Write chunk_ranges(n, workers) returning a list of (start, stop) pairs that cover range(n) exactly once, in order, with sizes differing by at most one (larger chunks first). Never return empty chunks: if workers > n, return n chunks of size 1. n == 0 returns []. Raise ValueError if workers < 1 or n < 0.

Hint

size, extra = divmod(n, k) with k = min(workers, n); the first extra chunks get size + 1 items.

Show solution
def chunk_ranges(n, workers):
    if workers < 1 or n < 0:
        raise ValueError("need workers >= 1 and n >= 0")
    k = min(workers, n)
    if k == 0:
        return []
    size, extra = divmod(n, k)
    chunks, start = [], 0
    for i in range(k):
        stop = start + size + (1 if i < extra else 0)
        chunks.append((start, stop))
        start = stop
    return chunks

Write collect(futures) that takes a list of finished concurrent.futures.Future objects and returns (results, errors): results is a list of the successful results in input order, and errors is a list of (index, exception) pairs for futures that raised. Cancelled futures count as errors with a CancelledError instance. Never let an exception escape.

Hint

Check f.cancelled() first (calling exception() on a cancelled future raises). Otherwise f.exception() returns the exception or None.

Show solution
from concurrent.futures import Future, CancelledError

def collect(futures):
    results, errors = [], []
    for i, f in enumerate(futures):
        if f.cancelled():
            errors.append((i, CancelledError()))
            continue
        exc = f.exception()
        if exc is None:
            results.append(f.result())
        else:
            errors.append((i, exc))
    return results, errors

Mini-project: a parallelism benchmark

Mini-projectbench-parallel: threads vs processes vs interpreters vs free-threading

Claims about parallel performance are cheap; measurements are not. Build a benchmark that runs the same CPU-bound workload (a Mandelbrot tile renderer or a prime counter over number ranges) and the same I/O-bound workload (time.sleep as simulated latency) under every model from this module, on both the standard and the free-threaded interpreter, and prints a comparison table.

pixi init bench-parallel
cd bench-parallel
pixi add python=3.14 numpy
pixi add --feature ft python-freethreading
pixi workspace environment add ft --feature ft
pixi run python bench.py --workers 1 2 4 8
pixi run -e ft python bench.py --workers 1 2 4 8

Specification

  • Workloads: count_primes(start, stop) (pure Python), mandel_tile(rows) (pure Python), numpy_tile(rows) (vectorized), and sleepy(seconds) (I/O).
  • Runners: serial, ThreadPoolExecutor, ProcessPoolExecutor, InterpreterPoolExecutor, all fed by the same chunk_ranges function (Exercise 3).
  • Time with time.perf_counter(), repeat each measurement 3 times, report the median, and verify every runner returns the same result as serial.
  • Print a table: workload × runner × workers → seconds and speedup vs serial, plus the build (Py_GIL_DISABLED) and sys._is_gil_enabled().
  • Write RESULTS.md explaining each number with the ideas from this module (GIL, pickling, start-up costs, Amdahl).

Acceptance criteria

  • All runners produce identical results for every workload
  • Threads show near-linear speedup for sleepy on both builds
  • On the standard build, threads show ~1× for count_primes while processes and interpreters scale
  • On the free-threaded build, threads scale for count_primes (and you report the single-thread overhead)
  • You found the point where chunks are too small and parallel overhead beats the gains
  • RESULTS.md explains every surprising number

Stretch goals

  • Add a shared-memory variant that writes Mandelbrot rows into one SharedMemory-backed NumPy array.
  • Plot speedup vs workers with matplotlib (see M53).
  • Add a contention benchmark: all threads updating one dict vs per-thread dicts merged at the end.

Quiz

Recap

Going further