bigonotes
CS ConceptsOS Fundamentals

Python GIL

What CPython's Global Interpreter Lock is, why it exists, why threads don't speed up CPU-bound Python, and how to work around it.

5 min readLast updated OS Fundamentals

The GIL (Global Interpreter Lock) is a mutex inside CPython, the standard Python interpreter, that lets only one thread execute Python bytecode at a time, even on a multi-core machine. It is why Python threads speed up I/O-bound code but not CPU-bound code, and why Python developers reach for multiprocessing for parallel computation.

What it does#

Compare four CPU-bound threads on a 4-core machine in a language without a GIL (such as C++, Go or Rust) and in CPython:

Core 1 Core 2 Core 3 Core 4
No GIL (true parallelism) T1 runs T2 runs T3 runs T4 runs
CPython (one thread runs Python at a time) T1 runs idle idle idle

For CPU-bound work, 4 threads in a no-GIL language can give close to a 4× speedup. 4 CPython threads run about as fast as 1.

Why it exists#

CPython manages memory with reference counting. Each object counts how many references point to it and is freed when the count reaches zero.

a = [1, 2, 3]   # list refcount = 1
b = a           # refcount = 2
del a           # refcount = 1
del b           # refcount = 0 → freed

If two threads updated a refcount at the same time, that would be a race condition: counts could drift, causing leaks (never reaching 0) or crashes (freeing an object still in use).

CPython's designers had two options:

  • Option A: fine-grained locks on every object's refcount. Every assignment would take and release a lock, slowing down single-threaded programs, which is most Python code.
  • Option B: one big lock on the whole interpreter. Simple, and single-threaded code pays nothing extra. Multi-threaded CPU-bound code suffers.

They chose B. It optimized for the common case at the cost of the rarer one.

When the GIL doesn't matter: I/O#

A thread releases the GIL while it waits on blocking I/O (socket reads, requests.get(), file reads, time.sleep()), so other threads run in the meantime.

import requests
from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    # GIL is released while waiting on the network
    return requests.get(url).status_code

urls = ["https://example.com"] * 10
with ThreadPoolExecutor(max_workers=10) as pool:
    # Roughly 10x faster than fetching one by one: all ten wait in parallel
    print(list(pool.map(fetch, urls)))
def compute(n):
    total = 0
    for i in range(n):
        total += i * i   # pure Python: holds the GIL the whole time
    return total

# Running this in 10 threads takes about as long as running it 10 times in a row
Thread is doing... GIL held? Runs in parallel?
CPU work in pure Python (own data or shared) Yes No
Waiting on I/O (DB, network, disk) Released Yes
Heavy work inside a C extension that releases the GIL (many NumPy operations, for example) Released during that work Yes

Getting real parallelism: multiprocessing#

Each process has its own interpreter and its own GIL.

from multiprocessing import Pool

def compute(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

if __name__ == "__main__":      # required on platforms that spawn new processes
    with Pool(processes=4) as pool:
        # Four interpreters, four GILs → uses four cores
        print(pool.map(compute, [10_000_000] * 4))

concurrent.futures.ProcessPoolExecutor offers the same thing with the futures API.

The cost: processes don't share memory. Arguments and results are pickled and copied between processes, which is slow for big data. Passing a 2 GB DataFrame to 8 workers can mean ~16 GB of RAM.

Why the GIL gets criticized#

You still need locks in threaded Python. The GIL protects the interpreter's internals, not your data. counter += 1 is several bytecodes, and a thread switch can happen between them.

No-GIL language (C++, Go, Rust) CPython (with GIL)
CPU-bound parallelism with threads Yes No
Need locks for shared data Yes Still yes
Threading overhead Pays off with real speedup Same complexity, no speedup for CPU work

An illustrative (not measured) example of the effect on an 8-core machine processing images with CPU-heavy transforms:

Setup Speed vs 1 thread
No-GIL language, 8 threads ~8× faster
CPython, 8 threads About the same
CPython, 8 processes Close to 8×, but with extra memory and copy costs

The complaints boil down to three things:

  1. Python is popular for data and ML work, which is often CPU-heavy (much of it is rescued by C extensions like NumPy that release the GIL).
  2. The workaround is clunky: multiprocessing means serialization and duplicated memory.
  3. Other languages (C++, Go, Rust) have had true multi-threaded parallelism for a long time.

Why not just remove it? Thousands of C extensions were written assuming the GIL protects them, so removing it breaks compatibility and needs careful, gradual migration.

The free-threaded future#

PEP 703 makes the GIL optional. Python 3.13 shipped an experimental free-threaded build (often installed as python3.13t) that uses finer-grained, per-object locking instead. Single-threaded code runs somewhat slower in that build, and C extensions must be updated to support it. Python 3.14 moved the free-threaded build from experimental to officially supported, though the default build still has a GIL.

Other Python implementations differ too: Jython and IronPython never had a GIL; PyPy has one.

Comparison#

Here is how CPython stacks up against languages without a GIL.

CPython C++ / Go
GIL Yes (optional in free-threaded builds) No
CPU-bound threading No speedup True parallelism
I/O-bound threading Works well (GIL released while waiting) Works well
CPU parallelism fix multiprocessing, C extensions, free-threaded build Just use threads
Root cause Reference counting isn't thread-safe without it No interpreter-wide refcounting to protect

Key takeaways#

  • The GIL lets one thread at a time run Python bytecode in CPython.
  • It exists to keep reference counting safe without per-object locks.
  • Threads help I/O-bound Python (the GIL is released while waiting) but not CPU-bound pure Python.
  • Use multiprocessing / ProcessPoolExecutor for CPU parallelism, at the cost of copying data between processes.
  • You still need locks for shared data; the GIL protects the interpreter, not your invariants.
  • PEP 703's free-threaded build makes the GIL optional, starting in Python 3.13.

Found a mistake? Tell me on LinkedIn ↗

Further reading#