Skip to content

Threads, Processes & the GIL

What this lesson gives you

The Global Interpreter Lock has shaped Python concurrency for decades — and Python 3.13's free-threading build is starting to change the picture.

Estimated time: 22 min read · Part: Concurrency, Parallelism & Performance

Learning objectives

  • Explain precisely what the GIL is and why it exists in CPython
  • Know when threads help (I/O-bound work) vs when they don't (CPU-bound)
  • Use ThreadPoolExecutor and ProcessPoolExecutor correctly
  • Manage shared state safely using locks and thread-safe data structures
  • Know Python 3.13's free-threaded build and where the ecosystem stands in 2026

The Global Interpreter Lock (GIL) is a mutex in CPython that allows only one thread to execute Python bytecode at a time. Even if you create 8 threads on an 8-core machine, only one thread ever runs Python code simultaneously. The GIL exists because CPython's memory management — especially reference counting — is not thread-safe: without the GIL, two threads incrementing the same reference count simultaneously could corrupt memory.

The consequence: Python threads do not give true parallelism for CPU-bound Python code. This surprises many developers coming from Java or Go. The trade-off: threads are still useful for I/O-bound work (the GIL is released during blocking I/O), require no IPC overhead, and share memory trivially. For CPU-bound parallelism, Python provides the multiprocessing module — separate OS processes, each with its own Python interpreter and GIL.

The GIL: What It Is and Why It Exists

GIL Effect on CPU-bound vs I/O-bound Threads
──────────────────────────────────────────────────────────────────────────────
CPU-bound (computing prime numbers):
  Core 1:  Thread A [Python bytecode] ████████████████████████████████████
  Core 2:  Thread B [Python bytecode] ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
  → Thread B NEVER runs Python; GIL serializes all CPU work on one core
  → 2 threads = same speed as 1 thread (or slower due to GIL contention)

I/O-bound (waiting on network):
  Core 1:  Thread A [Python] → [waiting for network I/O] → [Python] → ...
                                     ↑ GIL released during OS wait
  Core 2:  Thread B            [Python] → [waiting]  → [Python] → ...
  → Thread B CAN run Python while Thread A waits on I/O
  → Threads DO help for I/O-bound work

Multiprocessing (CPU-bound):
  Core 1:  Process 1 [own Python interpreter + own GIL] ████████████████
  Core 2:  Process 2 [own Python interpreter + own GIL] ████████████████
  → True parallelism: each process has its own GIL
  → Overhead: data must be serialized (pickle) between processes

Why does the GIL exist at all?

CPython's memory management uses reference counting: every object has a counter of how many references point to it; when the count hits zero, the object is freed. If two threads simultaneously decrement the same counter, they might both see "1" and both decide to free the object — double-free memory corruption. The GIL prevents this without requiring a lock on every single Python object. The trade-off (no CPU parallelism) was considered acceptable when Python was designed in the early 1990s, before multi-core machines were common. Removing it while maintaining single-threaded performance has proven extremely difficult — hence the decades-long GIL story.

executor_patterns.py
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time, requests

# ── ThreadPoolExecutor: I/O-bound work ────────────────────────────────────────
urls = [f"https://httpbin.org/delay/1?n={i}" for i in range(10)]

def download(url: str) -> str:
    """Simulate a blocking HTTP GET."""
    time.sleep(1)   # in production: requests.get(url).text
    return f"content of {url[-1]}"

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(download, urls))
print(f"Threads: {time.perf_counter()-start:.1f}s")   # ~1.0s (parallel I/O waits)

# Sequential would take ~10s
# Threads overlap the waiting (GIL released during sleep/I/O)

# ── ProcessPoolExecutor: CPU-bound work ──────────────────────────────────────
def is_prime(n: int) -> bool:
    """CPU-intensive check."""
    if n < 2: return False
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0: return False
    return True

numbers = list(range(10**6, 10**6 + 100))   # 100 numbers near 1 million

start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as pool:  # 4 real CPU cores
    primes = [n for n, ok in zip(numbers, pool.map(is_prime, numbers)) if ok]
elapsed = time.perf_counter() - start
print(f"Processes: {elapsed:.2f}s  found {len(primes)} primes")

# With threads this would be ~4x slower (GIL prevents parallel CPU work)
# With processes: real parallelism across 4 cores

# ── submit() for individual tasks + futures ────────────────────────────────
with ThreadPoolExecutor(max_workers=5) as pool:
    futures = {pool.submit(download, url): url for url in urls[:5]}
    for future in futures:
        url = futures[future]
        try:
            result = future.result(timeout=5)
        except Exception as e:
            print(f"  {url} failed: {e}")

Shared State and Thread Safety

Threads share memory — which is convenient for communication but dangerous without synchronization. Even simple operations like counter += 1 are not atomic: they involve read, add, write — three steps any other thread can interrupt between. A threading.Lock (or RLock) serializes access to shared state. Use it via a context manager to guarantee unlock even on exception.

thread_safety.py
import threading
from collections import defaultdict
from queue import Queue

# ── Race condition — counter += 1 is NOT atomic ──────────────────────────────
counter = 0
def increment(n):
    global counter
    for _ in range(n):
        counter += 1    # read, add, write — 3 ops, interruptable!

threads = [threading.Thread(target=increment, args=(10_000,)) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
# counter may be < 100_000 due to lost updates (race condition)

# ── Fix with a Lock ───────────────────────────────────────────────────────────
counter = 0
lock = threading.Lock()

def safe_increment(n):
    global counter
    for _ in range(n):
        with lock:          # acquires lock, releases on exit (even on exception)
            counter += 1    # now atomic

threads = [threading.Thread(target=safe_increment, args=(10_000,)) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
# counter is exactly 100_000

# ── Thread-safe producer/consumer with Queue ──────────────────────────────────
# queue.Queue is designed for thread communication — fully thread-safe
job_queue = Queue()

def producer():
    for i in range(20):
        job_queue.put(f"job_{i}")
    job_queue.put(None)   # sentinel to signal done

def consumer():
    while True:
        job = job_queue.get()
        if job is None:
            break
        print(f"Processing {job}")
        job_queue.task_done()

prod_thread = threading.Thread(target=producer)
cons_thread = threading.Thread(target=consumer)
prod_thread.start(); cons_thread.start()
prod_thread.join();  cons_thread.join()

# ── threading.local() — per-thread storage ────────────────────────────────────
local_data = threading.local()
def worker():
    local_data.value = threading.current_thread().name  # each thread has own .value
    print(f"{local_data.value}: {local_data.value}")

Python 3.13 free-threaded build (PEP 703)

Python 3.13 (October 2024) shipped an experimental free-threaded build (compiled with --disable-gil) that removes the GIL, enabling true parallel execution of Python threads on multiple cores. Python 3.14 (October 2025) continued to mature this toward a non-experimental supported mode. The transition is multi-year: as of mid-2026, most production deployments still use the standard GIL build, many C extensions need per-extension updates, and performance is still being tuned. But the trajectory is clear — the GIL is being removed. Know both the classic model and where it's heading; "the GIL is being phased out in an opt-in build" is exactly the kind of current-awareness signal that impresses interviewers.

Workload Best tool Why When not to use
I/O-bound, many connections asyncio Lowest overhead; one thread, thousands of awaits When libraries don't have async versions
I/O-bound, blocking libraries ThreadPoolExecutor GIL released during I/O; easy to add to sync code When you need more than ~100 threads
CPU-bound ProcessPoolExecutor Each process has its own GIL — true parallelism When data transfer overhead exceeds compute gain
CPU-bound (3.13+ opt-in) Free-threaded threads True parallel threads without process overhead Extensions not yet free-thread safe
Mixed I/O + CPU Processes + async inside each Processes for CPU; async within each process for I/O When complexity cost isn't justified

Consulting lens: choosing the right concurrency model

The question "which concurrency model should we use?" is often framed as a religious debate. The engineering answer is simple: identify your bottleneck first. Profile the workload. If 95% of wall-clock time is network I/O — async or threads. If 95% is CPU — processes. If it's a web server with database-backed request handlers — async (most time is awaiting DB responses). If it's an image-processing pipeline — processes. The biggest mistakes are using threads for CPU-bound work (GIL defeats the purpose) and using multiprocessing for many small tasks (process creation and IPC overhead dominates). Match the tool to the measured bottleneck.

Knowledge check

InterviewYou have a CPU-bound task and want to use all 8 cores. Why won't a standard ThreadPoolExecutor help, and what's the fix?

  • Threads are always parallel; they will use all 8 cores.
  • The GIL allows only one thread to execute Python bytecode at a time, serializing CPU-bound threads; use ProcessPoolExecutor — each process has its own GIL, enabling true multi-core parallelism.
  • Switch to asyncio — it parallelizes CPU work automatically.
  • Add more threads — 64 threads will use 8 cores.
Answer

The GIL allows only one thread to execute Python bytecode at a time, serializing CPU-bound threads; use ProcessPoolExecutor — each process has its own GIL, enabling true multi-core parallelism.

In standard CPython, the GIL serializes Python bytecode execution across threads — multiple threads running CPU-bound Python code take turns rather than running simultaneously. ProcessPoolExecutor uses separate OS processes, each with its own Python interpreter and GIL, achieving real parallelism. The experimental 3.13+ free-threaded build removes this limitation for threads, but it's not yet the default. asyncio doesn't help CPU-bound work at all.

Knowledge check

Concept CheckWhy do threads still help for I/O-bound work despite the GIL?

  • The GIL doesn't apply to I/O operations — they use a different lock.
  • The GIL is released while a thread waits on I/O (OS-level blocking syscall), so other threads can run Python code while one thread is blocked waiting for a network response.
  • Threads use multiple cores for I/O even with the GIL.
  • I/O-bound threads don't execute Python bytecode at all.
Answer

The GIL is released while a thread waits on I/O (OS-level blocking syscall), so other threads can run Python code while one thread is blocked waiting for a network response.

When a thread calls a blocking I/O syscall (read a socket, write to disk), CPython releases the GIL before entering the syscall and reacquires it when the syscall returns. While one thread is blocked waiting for data, other threads can run Python code. This is why ThreadPoolExecutor is useful for concurrent HTTP requests or file reads — the waiting is done at the OS level, outside the GIL.

Knowledge check

Current EventsWhat does Python 3.13's "free-threaded" build (PEP 703) do, and what's the main adoption barrier as of 2026?

  • It removes async/await and replaces them with threads.
  • It removes the GIL, enabling true parallel Python threads. The main barrier is C extension compatibility — many popular extensions (NumPy, etc.) need per-package updates to be thread-safe.
  • It makes Python free/open-source for the first time.
  • It removes multiprocessing, replacing it with threads.
Answer

It removes the GIL, enabling true parallel Python threads. The main barrier is C extension compatibility — many popular extensions (NumPy, etc.) need per-package updates to be thread-safe.

PEP 703 (Python 3.13, experimental) is a CPython build compiled without the GIL, allowing Python threads to run in true parallel on multiple cores. The main barriers to adoption: (1) C extensions must be explicitly marked thread-safe and many haven't been updated yet, (2) single-threaded performance is slightly reduced due to finer-grained locking, (3) the build is still opt-in and experimental. Python 3.14 continues to mature it. Most production deployments in 2026 still use the standard GIL build.

Exercise: Parallel File Word Counter

You have a directory with 50 large text files. Write a function count_words_parallel(directory) that counts the total word frequency across all files using ProcessPoolExecutor (since reading + counting is CPU-bound at scale). Each process should count one file; the main process aggregates the results. Return a Counter of word frequencies sorted by most common.

Show solution
from concurrent.futures import ProcessPoolExecutor
from collections import Counter
from pathlib import Path
import re

def count_file(path: str) -> dict[str, int]:
    """Count word frequencies in a single file. Runs in a subprocess."""
    text = Path(path).read_text(encoding="utf-8", errors="replace")
    # Normalize: lowercase, extract alphabetic words
    words = re.findall(r"\b[a-z]+\b", text.lower())
    return dict(Counter(words))   # dict for pickling across process boundary

def count_words_parallel(directory: str, max_workers: int = 4) -> Counter:
    """
    Count word frequencies across all .txt files in directory.
    Uses ProcessPoolExecutor for true CPU parallelism.
    Returns Counter sorted by most common.
    """
    paths = [str(p) for p in Path(directory).glob("*.txt")]
    if not paths:
        return Counter()

    total = Counter()
    with ProcessPoolExecutor(max_workers=max_workers) as pool:
        # Each worker counts one file; results are pickled back to main process
        for file_counts in pool.map(count_file, paths):
            total.update(file_counts)  # aggregate in main process

    return total   # Counter supports .most_common(), arithmetic, etc.

# Demo with synthetic files
import tempfile, os

with tempfile.TemporaryDirectory() as tmpdir:
    # Create test files
    for i, content in enumerate([
        "the quick brown fox jumps over the lazy dog the fox",
        "python is a great language python is easy python rocks",
        "the brown dog ate the fox the dog is happy",
    ]):
        Path(tmpdir, f"doc{i}.txt").write_text(content, encoding="utf-8")

    result = count_words_parallel(tmpdir, max_workers=2)
    print(result.most_common(5))
    # [('the', 7), ('fox', 3), ('python', 3), ('dog', 3), ('brown', 2)]

Key takeaways

  • The GIL serializes Python bytecode across threads — no CPU parallelism in standard CPython.
  • Threads still help I/O-bound work: GIL is released during blocking I/O syscalls.
  • Use ProcessPoolExecutor for CPU-bound parallelism — separate processes, each with their own GIL.
  • threading.Lock protects shared state; queue.Queue is the thread-safe communication primitive.
  • Python 3.13+ ships an experimental free-threaded (no-GIL) build — maturing through 2026 but not yet the default.