How the GIL works

threading & multiprocessing · Expert · 13 min

Knowing the mechanism lets you predict performance and avoid relying on accidental thread-safety.

The GIL is a mutex around the interpreter's internal state. It exists mainly because CPython's memory management uses reference counting: every object's count changes constantly, and the GIL makes those updates safe without a lock per object.

How threads share it:

  • A running thread holds the GIL and executes bytecode.
  • A waiting thread sets a "drop request" after the switch interval (sys.getswitchinterval(), default 5 ms). The running thread checks at safe points between bytecodes and hands the GIL over. sys.setswitchinterval() tunes this trade-off between responsiveness and throughput.
  • Blocking calls release it explicitly: C code wraps slow work in Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS. That's why time.sleep, socket I/O, hashlib on large data, zlib and NumPy kernels run in parallel.

What the GIL protects: interpreter internals and the atomicity of individual bytecodes and many built-in C operations. What it doesn't: your invariants. Switches can happen between any two bytecodes, so multi-step logic needs locks.

Consequences: CPU-bound threads add switching overhead, and one CPU-heavy thread can delay an I/O thread's response by up to the switch interval each time it wants the GIL back. Python 3.12 added per-interpreter GILs (PEP 684), and 3.13 an optional build without a GIL.

Example

import hashlib, sys, threading, time

print(sys.getswitchinterval())             # 0.005

data = b"x" * 50_000_000

def digest():
    hashlib.sha256(data).hexdigest()       # C code releases the GIL for large inputs

def py_loop():
    sum(range(10_000_000))                 # bytecode: holds the GIL

def timed(fn, n):
    t = time.perf_counter()
    threads = [threading.Thread(target=fn) for _ in range(n)]
    for th in threads: th.start()
    for th in threads: th.join()
    return time.perf_counter() - t

for fn in (digest, py_loop):
    print(fn.__name__, "4 threads vs 1:", round(timed(fn, 4) / timed(fn, 1), 1), "x")
# on a machine with 4+ free cores:
# digest 4 threads vs 1: ~1 x    -> runs in parallel (GIL released)
# py_loop 4 threads vs 1: ~4 x   -> serialised by the GIL

Remember

  • The GIL protects reference counts and interpreter state
  • Threads hand it over every switch interval (5 ms) or when blocking
  • C extensions release it around heavy work, enabling real parallelism

Watch out for

  • Treating 'the GIL makes it safe' as a design principle; it doesn't protect multi-step invariants.

Check yourself

  1. Why does hashlib.sha256 on large buffers scale across threads on a GIL build?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/sys.html#sys.setswitchinterval