Skip to content

description: "Concurrency I — Threading & Multiprocessing — CPython's Global Interpreter Lock allows only one thread to execute Python bytecode at a time, even on a…"---

04 · Concurrency I — Threading & Multiprocessing

🎥 Video walkthrough

When a program needs to do more than one thing "at once," Python offers two very different tools depending on what kind of work is involved: threading for I/O-bound work, and multiprocessing for CPU-bound work. This module explains why the split exists, starting with the GIL.

The Global Interpreter Lock (GIL)

CPython's Global Interpreter Lock allows only one thread to execute Python bytecode at a time, even on a multi-core machine. This means threads do not give you parallel speedup for CPU-heavy pure-Python code.

import threading
import time

def cpu_bound(n):
    count = 0
    for _ in range(n):
        count += 1
    return count

start = time.perf_counter()
threads = [threading.Thread(target=cpu_bound, args=(20_000_000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"threaded: {time.perf_counter() - start:.2f}s")   # not much faster than sequential

However, the GIL is released during I/O waits (network calls, disk reads, time.sleep), which is exactly why threading does help for I/O-bound work.

threading — good for I/O-bound work

import threading
import time

def download_simulated(name, delay):
    print(f"{name}: starting")
    time.sleep(delay)         # simulates waiting on a network response — GIL is released here
    print(f"{name}: done")


start = time.perf_counter()
threads = [
    threading.Thread(target=download_simulated, args=(f"file-{i}", 1))
    for i in range(5)
]
for t in threads:
    t.start()
for t in threads:
    t.join()   # wait for all threads to finish

print(f"total: {time.perf_counter() - start:.2f}s")   # ~1s, not ~5s — they waited concurrently

Race conditions and locks

Multiple threads mutating shared state without coordination causes race conditions — the classic bug being a shared counter that ends up wrong because two threads read-modify-write it at overlapping times.

import threading

counter = 0
lock = threading.Lock()

def increment(n):
    global counter
    for _ in range(n):
        with lock:      # only one thread can hold the lock at a time
            counter += 1


threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)   # 400000 — correct, because the lock serializes the increments

Removing with lock: above would likely produce a number less than 400000, since increments can be lost when two threads interleave their read-modify-write steps.

multiprocessing — good for CPU-bound work

Each process gets its own Python interpreter and its own GIL, so multiprocessing gives genuine parallelism across CPU cores — at the cost of higher memory use and the need to explicitly pass data between processes (since they don't share memory by default).

import multiprocessing
import time

def cpu_bound(n):
    count = 0
    for _ in range(n):
        count += 1
    return count


if __name__ == "__main__":
    start = time.perf_counter()
    with multiprocessing.Pool(processes=4) as pool:
        results = pool.map(cpu_bound, [20_000_000] * 4)
    print(f"multiprocessing: {time.perf_counter() - start:.2f}s")   # noticeably faster on multi-core machines

The if __name__ == "__main__": guard is required for multiprocessing on some platforms — worker processes re-import your script, and without the guard they'd try to spawn their own pools recursively.

concurrent.futures — a unified, higher-level API

concurrent.futures provides ThreadPoolExecutor and ProcessPoolExecutor with the same interface, so switching between threads and processes is a one-line change once your work is expressed as a function.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed

def fetch_length(url):
    # pretend this makes a real HTTP request
    import time
    time.sleep(0.5)
    return len(url)


urls = ["https://a.com", "https://b.com", "https://c.com"]

with ThreadPoolExecutor(max_workers=3) as executor:
    futures = {executor.submit(fetch_length, url): url for url in urls}
    for future in as_completed(futures):
        url = futures[future]
        print(url, "->", future.result())

Swapping ThreadPoolExecutor for ProcessPoolExecutor above would run each fetch_length call in a separate process instead of a thread — useful the moment the work becomes CPU-bound instead of I/O-bound.

executor.map — simpler, ordered results

from concurrent.futures import ThreadPoolExecutor

def square(n):
    return n * n

with ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(square, range(10)))

print(results)   # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81], in the original order

Choosing the right tool

Workload Tool Why
Waiting on network/disk (I/O-bound) threading / ThreadPoolExecutor GIL releases during I/O waits; low overhead
Heavy computation (CPU-bound) multiprocessing / ProcessPoolExecutor true parallelism across cores, sidesteps the GIL
Many concurrent I/O tasks, single-threaded asyncio (next module) avoids thread overhead entirely for I/O-bound work
Shared mutable state across threads threading.Lock prevents race conditions

How It Actually Works

The GIL is a single mutex inside the CPython interpreter that must be held by a thread to execute Python bytecode — it exists because CPython's memory management (reference counting, specifically) is not thread-safe by design: incrementing and decrementing an object's refcount from multiple threads simultaneously without a lock would corrupt it, freeing an object still in use or leaking one that should have been freed. Rather than adding fine-grained locking to every object (which earlier experiments showed hurt single-threaded performance badly), CPython uses one global lock and switches which thread holds it periodically — since Python 3.2, based on a configurable time interval (sys.getswitchinterval(), default ~5ms) rather than a fixed bytecode-instruction count, giving each thread a fair, tunable slice of actual execution time.

Crucially, the GIL is released around any operation that calls into blocking C code — a socket.recv(), time.sleep(), or file-read system call all release the GIL before making the OS call and reacquire it after, specifically so other Python threads can run bytecode while one thread is stuck waiting on the kernel. This is the precise, mechanical reason threading helps I/O-bound work (the threads spend most of their time with the GIL released, blocked in the OS) but not CPU-bound pure-Python work (the threads spend all their time holding the GIL, so the "concurrency" is just rapid switching between them, not simultaneous execution — four threads doing count += 1 in a loop still execute one bytecode instruction at a time, system-wide).

multiprocessing.Pool sidesteps the GIL entirely by not sharing a process at all: each worker is a genuinely separate OS process (started via fork on Unix, which duplicates the parent's memory via copy-on-write, or spawn, which starts a fresh interpreter and re-imports your module) with its own independent memory space, its own reference counts, and its own GIL — true parallel bytecode execution across cores. The cost is that arguments and return values crossing the process boundary (pool.map(cpu_bound, [...])) must be pickled (serialized to bytes), sent through an OS pipe to the worker, and unpickled on the other side — this is exactly why the if __name__ == "__main__": guard matters on spawn-based platforms: a freshly started worker process re-imports your script as a module to reconstruct the functions it needs to call, and without the guard, that re-import would execute your top-level Pool(...) creation code again, recursively spawning more pools.

A threading.Lock is a thin wrapper around an OS-level mutex primitive: with lock: calls lock.acquire() (blocking the calling thread until it can obtain the lock — implemented via a semaphore the OS scheduler manages) on entry and lock.release() on exit, guaranteeing that the counter += 1 read-modify-write sequence (itself several separate bytecode instructions — a LOAD_FAST, a numeric add, a STORE_FAST — any of which the GIL could switch threads between) runs to completion in one thread before another can start it, which is precisely what a race condition without the lock is missing.

Exercise

Write a function word_count(text) that counts words in a string. Simulate processing 8 large text "documents" (just repeat a string many times) using both ThreadPoolExecutor and ProcessPoolExecutor, time each approach, and explain in a comment why one is faster for this CPU-bound task. Then write a thread-safe Counter class using a threading.Lock and prove with multiple threads incrementing it that the final count is exactly correct.