Python Per-Interpreter GIL Isolation in PEP 684
This article explains how Python 3.12 implements PEP 684 to achieve per-interpreter Global Interpreter Lock (GIL) isolation. By decoupling the GIL from the global runtime state and attaching it directly to individual interpreter instances, CPython enables multiple sub-interpreters to run concurrently across separate CPU cores within a single OS process. Below is a breakdown of the architectural shifts, memory isolation strategies, and state-management changes that make this isolation work.
Moving the GIL to Interpreter State
Historically, the GIL was a process-wide construct anchored inside
CPython’s global runtime state (_PyRuntimeState). Because
every sub-interpreter shared this single runtime structure, only one
thread could execute Python bytecode at any given moment, regardless of
how many interpreters existed.
PEP 684 changed this by moving the GIL's mutex and synchronization
state from _PyRuntimeState into
PyInterpreterState. Each interpreter instance now manages
its own ceval.gil structure, containing its own lock,
condition variables, and thread-switching logic. When an OS thread runs
code inside an interpreter, it acquires that specific interpreter's GIL,
leaving other interpreters free to execute on other threads
concurrently.
Isolating the Global State
Moving the lock itself was only a small part of the challenge; sharing mutable global variables between interpreters with separate GILs would cause severe race conditions. To resolve this, CPython underwent an extensive refactoring effort to move global state into either per-interpreter state or immutable shared state.
1. Immortal Objects (PEP 683)
In standard CPython, reading or passing an object modifies its
reference count via Py_INCREF and Py_DECREF.
If multiple interpreters shared core objects like None,
True, False, or small integer singletons,
updating these reference counts concurrently would require
cross-interpreter synchronization, killing performance. Python solved
this using immortal objects (PEP 683), where specific runtime-defined
singletons have a dedicated bitflag that causes reference-counting
operations to be skipped entirely. These objects remain completely
read-only and safe to access simultaneously across multiple GIL-isolated
interpreters.
2. Moving Types to Per-Interpreter State
Built-in types (such as int, str, and
dict) historically stored mutable metadata and method
caches in static global variables. Under PEP 684, static types are
systematically converted to heap types or adjusted so that mutable
runtime properties (like method resolution order caches and subclass
lists) reside inside the PyInterpreterState rather than the
static C definition.
3. Module State and Multi-Phase Initialization
Built-in and extension modules must avoid C static variables. PEP 684 relies heavily on PEP 489 (Multi-Phase Extension Module Initialization). Modules store their internal state within per-module, per-interpreter dictionaries rather than C-level globals, allowing each sub-interpreter to hold an independent instance of the module.
Memory Allocator Isolation
CPython relies on pymalloc, a specialized small-object
allocator optimized for speed. Before PEP 684, pymalloc
operated with process-wide pools and arenas without thread-safe
synchronization because it assumed the protection of the single global
GIL.
With per-interpreter GILs, running pymalloc globally
would cause simultaneous allocations across threads to corrupt memory
pools. CPython resolved this by:
- Isolating
pymallocstate per interpreter so each interpreter manages its own memory pools independently. - Routing allocations that must cross interpreter boundaries directly
through system allocators (such as
malloc), which are natively thread-safe.
Thread-to-Interpreter Association
When a native thread executes Python code, it attaches to a
PyThreadState structure. Prior to PEP 684, switching
interpreters required synchronizing through the global lock. Under the
isolated model:
- A thread binds to a
PyThreadStateuniquely associated with a targetPyInterpreterState. - The thread enters the interpreter's evaluation loop
(
_PyEval_EvalFrameDefault) and requests only the GIL belonging to that interpreter. - Thread switching, tick counting for cooperative scheduling, and signal handling evaluation are tracked entirely within that interpreter's private execution context.
Current Boundaries and Extension Modules
While PEP 684 establishes the internal architecture for per-interpreter GIL isolation, it enforces strict boundaries on C-API extensions. Any extension module that has not declared support for multiple interpreters—or that relies on legacy, unisolated C globals—cannot be loaded into an isolated sub-interpreter. This prevents third-party code from introducing data races into an otherwise isolated runtime.