Per-Interpreter GIL

CPython has supported running multiple interpreters in one process for over twenty years, but until recently they all shared a single, process-global Global Interpreter Lock (GIL) — so even with several interpreters, only one thread anywhere in the process could execute Python bytecode at a time, and true multi-core parallelism was impossible. The per-interpreter GIL, specified in PEP 684 and shipped in Python 3.12, changes that: each interpreter can be created with its own GIL, so two interpreters in the same process run their own threads on different CPU cores in genuine parallel. This is a fundamentally different answer to the multi-core problem from free-threading — instead of removing the GIL, it replicates it, one per interpreter, and relies on each interpreter being fully isolated from the others (PEP 684).

The single most important idea: a GIL is only a bottleneck because it is shared. If you have many interpreters and each owns a private GIL guarding only its own state, the lock still does its cheap job (serializing access to one interpreter’s objects) while no longer serializing the whole process. The hard part is not the lock — it is making the interpreters actually independent, which required moving a large amount of state that used to be C global variables down into a per-interpreter struct.

This note covers the per-interpreter GIL mechanism and the state-isolation work that made it possible. It is the C-level foundation under Subinterpreters, where the user-facing concurrent.interpreters module (PEP 734, new in 3.14) lives. Contrast it with Free-Threaded CPython: that build removes the GIL entirely and therefore needs Per-Object Locking in Free-Threading to keep containers safe; per-interpreter GIL keeps a GIL and needs none of that. The groundwork that made shared singletons safe across interpreters is Immortal Objects (PEP 683).

Mental Model

Picture the process as an office building and the GIL as a single “talking stick” — only the person holding it may speak (run bytecode). The classic CPython model gives the whole building one talking stick, so no matter how many meeting rooms (interpreters) you set up, only one person speaks at a time. The per-interpreter GIL gives each meeting room its own talking stick. Within a room people still take turns, but two rooms can hold simultaneous conversations because each has its own stick. This only works if the rooms don’t share notes — if a value created in room A were visibly mutated by room B, two sticks could not protect it. So the bulk of the engineering was soundproofing the rooms: pushing the runtime’s mutable state out of shared global variables and into each room’s own filing cabinet (PyInterpreterState), and making the genuinely-shared, never-changing items (None, True, small ints) safe to share by making them immortal so no one ever writes to them.

flowchart LR
    subgraph Process
        direction TB
        subgraph I1["Interpreter 1 (PyInterpreterState)"]
            G1["own _gil (own_gil=1)"]
            S1["per-interp state:<br/>obmalloc, GC, imports,<br/>interned strings, dtoa"]
            T1["threads run bytecode<br/>holding GIL #1"]
        end
        subgraph I2["Interpreter 2 (PyInterpreterState)"]
            G2["own _gil (own_gil=1)"]
            S2["per-interp state:<br/>obmalloc, GC, imports,<br/>interned strings, dtoa"]
            T2["threads run bytecode<br/>holding GIL #2"]
        end
        IMM["Shared immortal objects:<br/>None, True, False, small ints<br/>(refcount pinned, never written)"]
    end
    T1 -.reads only.-> IMM
    T2 -.reads only.-> IMM
    T1 ===|"CPU core A"| G1
    T2 ===|"CPU core B"| G2

Figure: Two interpreters in one process, each with its own GIL (own_gil=1) guarding its own copy of formerly-global runtime state — allocator, garbage collector, import machinery, interned strings. Their threads run in parallel on different cores. The only shared objects are immortal singletons, which are safe because their refcounts are pinned and never mutated. The insight: parallelism comes not from removing the lock but from making the lock private, which is only sound because the state behind each lock is isolated.

Mechanical Walk-through

The problem PEP 684 solves

PEP 684’s motivation is blunt about why this was overdue: “interpreters in the same process have always shared a significant amount of global state. This is a source of bugs, with a growing impact as more and more people use the feature.” Multiple interpreters existed since the 1990s (Py_NewInterpreter), but they leaked into each other through C global variables — a single static variable in some module was visible to every interpreter. As long as one GIL serialized the whole process, that sharing was merely a correctness footgun; to give each interpreter its own GIL and run them in parallel, the sharing had to be eliminated, or two parallel interpreters would race on that shared C global.

Moving global state into PyInterpreterState

The core architectural change is, in PEP 684’s words: “Most of CPython’s runtime state be stored in the PyInterpreterState struct. Currently, only a portion of it is; the rest is found either in C global variables or in _PyRuntimeState.” PyInterpreterState is the C struct that holds everything belonging to one interpreter; _PyRuntimeState is the one truly process-global struct (there is exactly one). The PEP lays out three sequential phases:

  1. Consolidate scattered global runtime state into _PyRuntimeState.
  2. Move nearly all of that state down into PyInterpreterState.
  3. Move the GIL itself into PyInterpreterState.

You can verify that this work actually landed by reading the 3.14.5 struct definitions. In pycore_interp_structs.h, the per-interpreter eval state carries the GIL pointer and a flag:

struct _ceval_state {
    uintptr_t instrumentation_version;
    int recursion_limit;
    struct _gil_runtime_state *gil;   // which GIL this interpreter uses
    int own_gil;                      // 1 if this interpreter has its OWN gil
    struct _pending_calls pending;
};

The gil field is a pointer to a GIL, and own_gil records whether that GIL belongs to this interpreter or is borrowed from the main interpreter (the legacy, shared-GIL behavior). The actual GIL storage is embedded directly in PyInterpreterState, with a comment that captures the whole design in one line:

    /* The per-interpreter GIL, which might not be used. */
    struct _gil_runtime_state _gil;

“Which might not be used” is exactly the own_gil toggle: an interpreter created without its own GIL leaves this embedded _gil idle and points its _ceval_state.gil at the main interpreter’s GIL instead. The same struct shows the other state that moved per-interpreter: a struct _obmalloc_state *obmalloc (the small-block allocator state), a struct _dtoa_state dtoa (float↔string conversion caches), the import state, and — guarded by #ifdef Py_GIL_DISABLED — a PyMutex interned_mutex and per-interpreter interned_strings table (pycore_interp_structs.h, v3.14.5). Each interpreter therefore has its own GC state, its own import cache, its own interned-string table — the soundproofing.

What actually broke when state was global

It helps to name concrete offenders, because “shared global state” is abstract until you see what races. Three categories recur. First, interned strings: CPython keeps a single table that deduplicates string literals, so that "foo" is "foo" holds and identifier comparisons can use pointer equality. If that table were process-global and two parallel interpreters interned strings at once, they would race on the table’s internals and could hand out cross-interpreter string objects whose refcounts both interpreters then mutate without coordination. The 3.14.5 struct shows the fix landed: under Py_GIL_DISABLED PyInterpreterState carries its own PyMutex interned_mutex and interned_strings table. Second, the small-integer cache: CPython preallocates the integers −5..256 as shared singletons; a parallel interpreter incref/decref-ing those would race on their refcounts — which is exactly the problem Immortal Objects resolves by pinning them so the refcount is never written. Third, module-level static C variables inside extensions and even inside CPython’s own modules: any such variable is one storage location seen by every interpreter, so a second parallel interpreter writing it corrupts the first’s view. This last category is why extension isolation (below) is not optional bookkeeping but a correctness requirement.

The general rule the migration followed: anything mutable and interpreter-specific (caches, the GC’s generation lists, the import sys.modules equivalent, allocator pools) had to move into PyInterpreterState; anything immutable could be shared but had to be made write-free (immortal) so sharing introduced no race; and the small residue that is genuinely process-global (the runtime struct, the allocator policy) had to be made thread-safe.

The allocator decision

A subtle design choice concerns memory allocators. PEP 684 did not give each interpreter a fully independent allocator hierarchy. Instead, per the spec, the strategy is to “keep the allocators in the global runtime state, require that they be thread-safe, [and] move the state of the default object allocator (AKA ‘small block’ allocator) to PyInterpreterState.” That is exactly what the struct _obmalloc_state *obmalloc field in PyInterpreterState reflects: the policy (which malloc-family functions to call) stays global and must be thread-safe, but the pooled small-object arenas are per-interpreter, so two interpreters allocating objects in parallel are not fighting over one set of pymalloc pools. The struct comment in 3.14.5 confirms the lifetime subtlety: for non-main interpreters the obmalloc state is heap-allocated and “freed when the interpreter is finalized,” and it is “not safe to hold on to or use memory after the interpreter is freed.”

Immortal objects: how the GIL can be private but some objects shared

If every object lived in exactly one interpreter, the picture would be clean — but the C API exposes static singletons (Py_None, Py_True, the small-integer cache) that have historically been shared process-wide. With a per-interpreter GIL, two interpreters touching the same None object would each Py_INCREF/Py_DECREF it, and those refcount writes are not serialized across interpreters (each has its own GIL) — a data race. PEP 683 (Immortal Objects) solves this. As PEP 684 states: “With immortal objects, we can share any otherwise immutable global objects between all interpreters. Consequently, this PEP does not need to address how to deal with the various objects exposed in the public C-API.” PEP 683 itself frames it from the other side: “With immortal objects, support for a per-interpreter GIL becomes much simpler.” An immortal object’s refcount is pinned and never written by incref/decref, so sharing it across interpreters introduces no cross-interpreter write and therefore no race. This is why immortal objects (3.12) and per-interpreter GIL (3.12) shipped together: the former is load-bearing for the latter.

Using it — the C API

Per-interpreter GIL was delivered as a C-API-only feature in 3.12. The 3.12 What’s New shows the entry point:

PyInterpreterConfig config = {
    .check_multi_interp_extensions = 1,        // 1
    .gil = PyInterpreterConfig_OWN_GIL,        // 2
};
PyThreadState *tstate = NULL;
PyStatus status = Py_NewInterpreterFromConfig(&tstate, &config);  // 3
if (PyStatus_Exception(status)) {
    return -1;
}
/* The new interpreter is now active in the current thread. */

Line 1 sets check_multi_interp_extensions, which makes the import system reject single-phase-init extension modules that have not declared themselves multi-interpreter-safe (see below). Line 2 is the request that matters: PyInterpreterConfig_OWN_GIL asks for a private GIL; the alternative, PyInterpreterConfig_SHARED_GIL, reproduces the legacy behavior where the new interpreter borrows the main interpreter’s GIL. Line 3 creates the interpreter and makes it current on the calling thread (3.12 What’s New). Legacy Py_NewInterpreter() and the main interpreter continue to use the shared GIL, preserving backward compatibility.

The PyGILState complication

One specific piece of the C API remains a sharp edge: the PyGILState_Ensure()/PyGILState_Release() pair. These are the functions a native thread calls when it wants to enter Python and “acquire the GIL” without knowing which thread state to attach — the classic embedder pattern. Their original design baked in the assumption that there is one GIL and one implicit thread-state-per-OS-thread mapping for the whole process. Under a per-interpreter GIL that assumption breaks. PEP 684 explicitly identified PyGILState-related state as affected, and the 3.14 documentation is blunt about the limitation: the GIL-state APIs “use thread-local storage, and are not compatible with sub-interpreters,” and combining them with the sub-interpreter APIs “is delicate, because these APIs assume a bijection between Python thread states and OS-level threads, an assumption broken by the presence of sub-interpreters” (c-api/threads.html, c-api/subinterpreters.html). Concretely, the resolution is not “attach to the right interpreter”: when a thread that has never run Python (e.g. one created by a C library) calls PyGILState_Ensure(), the docs state it “will create and attach a thread state for the ‘main’ interpreter (the first interpreter in the Python process).” The implementation matches — PyGILState_Ensure in Python/pystate.c (v3.14.5) creates its new thread state on runtime->gilstate.autoInterpreterState, which _PyGILState_Init sets to the first/main interpreter; only when a thread state already exists for the calling thread does it reuse that (its own interpreter). So the practical rule is: PyGILState_Ensure from a foreign thread always lands in the main interpreter, and the recommendation is to not switch sub-interpreters between a matching Ensure/Release pair. Extensions such as ctypes that use these APIs to call into Python from non-Python threads “will probably be broken when using sub-interpreters.”

Extension-module isolation

The other half of isolation is preventing a C extension from smuggling shared state in. PEP 684 ties this to PEP 489 multi-phase initialization: extensions that implement multi-phase init are considered multi-interpreter-compatible; others are not. From the PEP: “If an incompatible extension is imported and the current PyInterpreterState.strict_extension_compat value is true then the import system will raise ImportError.” The main interpreter initializes that flag to false, so legacy single-phase extensions keep working there; a fresh own-GIL interpreter created with check_multi_interp_extensions=1 enforces the check. A workaround, importlib.util.allow_all_extensions(), can temporarily disable it for experimentation.

Failure Modes and Common Misunderstandings

“Per-interpreter GIL means no GIL.” The opposite — there are now more GILs, one per interpreter. Within a single interpreter, execution is still serialized exactly as before; the parallelism is across interpreters. If your workload is one interpreter with many threads, a per-interpreter GIL changes nothing; you need either multiple interpreters or the free-threaded build.

“I can pass any object between interpreters.” No. Because each interpreter owns its objects (and its GIL), arbitrary objects are not safely shareable — that is the entire isolation premise. The PEP 734 concurrent.interpreters layer provides a Queue and restricts what may cross (immutable/shareable types, buffers); see Subinterpreters. Only immortal singletons are freely shared at the C level.

“It shipped with a Python API in 3.13.” This is a stale forward-looking claim frozen in the 3.12 documentation, which said a Python API was “anticipated for 3.13” and linked the now-superseded PEP 554. What actually happened: the Python-level module is PEP 734, it is Final, and it shipped as concurrent.interpreters in Python 3.14 (PEP 734; 3.14 What’s New). Do not cite the 3.13 anticipation as fact.

“A C extension that worked under one interpreter works under many.” Only if it uses multi-phase initialization and holds no module-global mutable state. Single-phase extensions with static mutable globals are precisely what check_multi_interp_extensions is designed to reject.

Alternatives and When to Choose Them

The defining comparison is with Free-Threaded CPython (PEP 703). Both target multi-core parallelism, and they are independent answers, not layers:

  • Per-interpreter GIL keeps the GIL but makes it private per interpreter. Threads within an interpreter still serialize; you get parallelism by running multiple isolated interpreters. There is no per-object locking, no critical sections, no QSBR — the interpreter’s GIL still makes container mutations atomic. The cost is the isolation tax: objects do not flow freely between interpreters, so you communicate through a queue and pay (de)serialization-like costs, conceptually closer to multiprocessing but in one process and far cheaper to spin up.
  • Free-threading removes the GIL entirely, so threads in one interpreter run in true parallel and share objects directly — but every mutable container now needs Per-Object Locking in Free-Threading for safety, and every object pays atomic-refcount overhead even single-threaded.

Choose per-interpreter GIL (via Subinterpreters) when your work decomposes into independent units that exchange little data — it gives parallelism with the strongest isolation and no per-object-locking overhead. Choose free-threading when threads must share large mutable data structures directly and the communication cost of isolated interpreters would dominate. Against multiprocessing, per-interpreter GIL avoids OS process overhead and fork/spawn cost while keeping a similar “share little, copy across a boundary” discipline.

Production Notes

Per-interpreter GIL was contributed primarily by Eric Snow (PEP 684, in gh-104210), the same author behind the subinterpreters effort and PEP 734. It landed in 3.12 as plumbing with no Python surface, which limited early adoption to embedders and a handful of C extensions. The 3.14 release is the inflection point: PEP 734 finally exposed it to Python programmers as concurrent.interpreters, and PEP 734 also adds concurrent.futures.InterpreterPoolExecutor, making “run this function in a parallel interpreter” as ergonomic as the existing thread/process pools (PEP 734). As of 3.14 the two parallelism stories — per-interpreter GIL and the officially-supported-but-opt-in free-threaded build (PEP 779) — coexist; per-interpreter GIL is in the default build, whereas free-threading still requires a separate interpreter binary.

Resolved (2026-06-01)

Confirmed against Include/internal/pycore_interp_structs.h at v3.14.5: PyInterpreterState holds struct _gc_runtime_state gc;, struct _import_state imports;, struct _ceval_state ceval;, and struct _gil_runtime_state _gil; as direct per-interpreter fields — so the GC and import state are genuinely per-interpreter, alongside the obmalloc/dtoa/interned-string state. A full field-by-field audit of what deliberately stays in _PyRuntimeState (e.g. cross-interpreter coordination, the immortal singletons) was not performed, but it is by design: _PyRuntimeState is the single process-global struct and the soundness of per-interpreter GILs rests on shared items being immortal/never-mutated.

See Also