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:

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:

  1. A thread binds to a PyThreadState uniquely associated with a target PyInterpreterState.
  2. The thread enters the interpreter's evaluation loop (_PyEval_EvalFrameDefault) and requests only the GIL belonging to that interpreter.
  3. 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.