๐Ÿ”‘ The GIL & True Parallelism

Every rule in this entire site โ€” "processes for CPU, threads for I/O", the pickling tax, the whole shape of multiprocessing โ€” is downstream of one design decision inside CPython. This is that decision.

What the GIL Actually Is

The Global Interpreter Lock is a single mutex inside the CPython interpreter. To execute any Python bytecode, a thread must first acquire that mutex. Because there is exactly one GIL per interpreter, only one thread runs Python bytecode at any instant โ€” even on a 64-core machine with 64 threads ready to go.

Key Insight: The GIL is not part of the Python language. It is an implementation detail of CPython โ€” the reference interpreter you almost certainly run. Jython (on the JVM) and IronPython (on .NET) never had one. The language spec is silent about it. When people say "Python has a GIL", they mean CPython does.

What does it protect? Chiefly CPython's reference counts. Every object carries an integer counting how many references point at it; when that count hits zero, the object is freed. That counter is touched constantly โ€” on nearly every assignment, argument pass, and attribute access. If two threads incremented and decremented the same refcount without coordination, the counts would corrupt: objects would be freed while still in use (use-after-free crashes) or leak forever. The GIL makes each bytecode step's refcount bookkeeping safe by fiat, because only one thread is ever touching those counters.

Why It Exists (a Feature, Not Just a Wart)

It is tempting to read the GIL as pure legacy baggage, but it bought CPython three concrete things that shaped the whole ecosystem:

The trade: single-threaded and I/O-bound code got faster and simpler; CPU-bound multithreading got sacrificed. For decades that was the right call โ€” most Python is glue and I/O. It only hurts the moment you try to use threads to burn CPU in pure Python.

Threads Take Turns on One GIL

A running thread doesn't keep the GIL forever โ€” it releases it periodically so other runnable threads get a turn. Conceptually, threaded execution is a relay race where the baton is the GIL: everyone can be scheduled, but only the baton-holder actually runs Python bytecode.

sequenceDiagram participant T1 as Thread 1 participant GIL as The one GIL participant T2 as Thread 2 T1->>GIL: acquire Note over T1: runs bytecode T1->>GIL: release (switch interval / I/O) GIL->>T2: hand off Note over T2: runs bytecode T2->>GIL: release GIL->>T1: hand off Note over T1,T2: One holder at a time.
Threads interleave โ€” never truly parallel on CPU.

The interpreter invites a switch using the switch interval (5 ms by default). After that interval elapses, the interpreter requests that the current thread drop the GIL at the next safe point. It is a fairness/latency knob, not a performance knob โ€” lowering it makes switching more responsive but adds hand-off overhead. It does not give you parallelism; it just shares one core more finely.

import sys

sys.getswitchinterval()        # 0.005  โ†’ 5 milliseconds (default)
sys.setswitchinterval(0.001)   # ask for switches ~every 1ms (fairness, not speed)

The Key Consequence: One GIL Per Interpreter, One Interpreter Per Process

Here is the sentence this entire site is built on. The GIL is per interpreter. And every OS process that runs Python gets its own private interpreter โ€” therefore its own private GIL. Two GILs cannot serialize each other, because neither knows the other exists.

Key Insight: This is precisely why N processes achieve true N-core parallelism while N threads don't. N threads share one interpreter and fight over one GIL โ€” they take turns on a single core's worth of bytecode. N processes are N interpreters with N GILs, running bytecode genuinely simultaneously across N cores. Nothing is being cleverly worked around inside multiprocessing; it simply sidesteps the GIL by putting each worker behind its own copy of it.
flowchart TB subgraph THREADS["threading โ€” 1 process, 1 interpreter, 1 GIL"] direction LR GT["GIL"] TA["Thread A"] -. "waits turn" .-> GT TB["Thread B"] -. "waits turn" .-> GT TC["Thread C"] -. "waits turn" .-> GT end subgraph PROCS["multiprocessing โ€” 3 processes, 3 interpreters, 3 GILs"] direction LR P1["Process 1
own GIL โ€” runs now"] P2["Process 2
own GIL โ€” runs now"] P3["Process 3
own GIL โ€” runs now"] end

So the framing for the rest of these guides: multiprocessing is the GIL workaround that ships in the standard library. It doesn't remove the GIL โ€” it gives every worker its own, and pays for that isolation with memory copies and pickling (see Pattern 8 and Processes 101).

When the GIL Is Released โ€” So Threads Can Overlap

The GIL only serializes threads while they run pure-Python bytecode. A thread releases the GIL whenever it is about to wait on something outside the interpreter, or when it enters C code that explicitly says it is safe to let others run. During that window, other Python threads make real progress. This is the entire reason threads help I/O-bound work at all โ€” and the dividing line for whether threads or processes are the right tool.

WorkloadDoes the GIL block it?Threads help?Processes help?
Pure-Python CPU (loops, parsing, arithmetic on int/float)โœ… Held the whole timeโŒ No โ€” they serializeโœ… Yes โ€” a GIL each, true parallelism
Blocking network / socket I/OโŒ Released (parked in kernel)โœ… Yes โ€” overlap the waitingโœ… Works, but heavier than needed
Blocking disk / file I/OโŒ Released (kernel wait)โœ… Yesโœ… Works, overkill
time.sleep(n)โŒ Released for the whole sleepโœ… Yesโœ… Works, overkill
NumPy np.dot, hashlib, zlib, compiled C that releases the GILโŒ Released while in Cโœ… Yes โ€” real CPU overlap in Cโœ… Yes โ€” also fine
C extension touching Python objects (not releasing it)โœ… HeldโŒ Noโœ… Yes
The one-line rule: if a thread is waiting (I/O, sleep, a well-behaved C library), the GIL is free and threads overlap. If a thread is computing in pure Python, it holds the GIL and only processes give you parallelism. The full thread-side treatment of exactly when the GIL is held or released lives in the sibling GIL deep dive; the process-side speedups and their limits are in Pattern 10.

Note the NumPy row is why "just use threads" sometimes works for numeric code: a big np.dot releases the GIL and crunches in C (often across several BLAS threads) while your other Python threads keep running. But the moment your hot loop is pure Python, that escape hatch closes and you are back to needing processes.

Free-Threaded Python (PEP 703) โ€” the GIL Actually Removed

Python 3.13 introduced an experimental free-threaded build โ€” a separate interpreter compiled with --disable-gil, shipped as python3.13t (the trailing t = "threaded"). Python 3.14 continues stabilizing it. In this build the GIL is genuinely gone: threads run Python bytecode in true parallel across cores, the way threads do in Java or Go.

To detect which build you are on:

import sys

# True on a normal (GIL) build; False on a free-threaded build (3.13+).
sys._is_gil_enabled()
The caveats are real โ€” do not overstate its maturity:
  • Single-thread overhead. Removing the GIL means refcounting must be made thread-safe (biased reference counting, deferred/immortal objects), which historically added single-threaded slowdown. It is shrinking release over release but is not zero.
  • C-extension compatibility. Extensions must be rebuilt and audited for thread-safety; many popular ones lag. A C extension that assumed the GIL can now race.
  • Still officially experimental. Through 3.13โ€“3.14 it is opt-in and evolving; it is not the default interpreter you get from a plain python.
Why multiprocessing still matters even in a no-GIL world:
  • It is stable and shipping today on every mainstream Python โ€” no special build, no rebuilt extensions.
  • Memory isolation is a feature, not just a cost. A worker that segfaults, leaks, or corrupts state takes down only itself โ€” the parent survives. Shared-memory threads (free-threaded or not) offer no such blast-radius containment.
  • The whole ecosystem (pools, joblib, Dask, Ray โ€” see Common Libraries) is built on the process model.
Free-threading may eventually change when you reach for threads over processes; it does not make processes obsolete.

Sub-Interpreters (PEP 734) โ€” a Middle Ground

There is a third point on the spectrum, new and still evolving. Sub-interpreters let you run multiple interpreters inside one process, and since Python 3.12 each sub-interpreter has its own GIL. So they run bytecode in parallel โ€” like processes โ€” but without spinning up a whole new OS process.

Python 3.12 exposed sub-interpreters at the C-API level; a friendlier standard-library Python API (interpreters) is arriving through 3.13/3.14. Treat this as new and evolving โ€” promising for CPU parallelism with lower overhead than processes, but not yet the boring, battle-tested default that multiprocessing is.

Key Takeaways

ConceptTakeaway
What the GIL isOne CPython mutex; only one thread runs bytecode at a time
What it protectsInterpreter state, chiefly non-atomic reference counts
Why it existsFast simple refcounting + easy, fast C extensions
The pivotal factOne GIL per interpreter; one interpreter per process โ†’ a GIL per process
Why processes parallelizeN processes = N GILs = true N-core parallelism
Released duringI/O, time.sleep, GIL-releasing C (NumPy, hashlib, zlib)
Free-threaded build (PEP 703)3.13+ python3.13t: no GIL, experimental, ecosystem catching up
Sub-interpreters (PEP 734)3.12+ many interpreters/process, a GIL each โ€” a lighter middle ground, still evolving
The whole site in one sentence: the entire "threading vs multiprocessing" tension in Python is downstream of a single design decision โ€” the GIL. Once you internalize that the GIL is per-interpreter and each process gets its own, every rule on every other page (use processes for CPU, expect a pickling tax, guard the launch, watch oversubscription) stops being a list to memorize and becomes something you can derive.