A deep dive

Python
subinterpreters

CPython has supported several interpreters inside one process for decades, primarily through its C API. The older interpreters shared one GIL and much of the runtime’s state. Python 3.12 added support for a GIL per interpreter2. Python 3.14 added the public standard-library interface, concurrent.interpreters4; private Python interfaces existed before it.

An interpreter is a Python environment: its modules, globals, builtins and execution state. A thread supplies the execution. Creating another environment does not start another thread, and entering it synchronously does not make the caller run two things at once.

There are two ways to use this from Python. The direct API lets you create an interpreter, run code in it and close it. InterpreterPoolExecutor manages reusable worker threads, each with its own interpreter, and dispatches tasks through submit() or map(). The familiar thread-pool interface sits on top of separate Python environments.

Direct APIWhat it does
interpreters.create()Build an environment and return a handle.
interp.exec(source)Run a script there on the calling thread; wait for completion.
interp.call(fn, *args)Call a function there on the calling thread; transfer the result back.
interp.call_in_thread(fn, *args)Start a thread that makes the call; return that thread.
interpreters.create_queue()Create a communication channel whose handles can be passed between interpreters.
interp.close()Destroy the environment when its work is finished.

This post follows an interpreter from construction to execution, communication and teardown. The mechanism described is the isolated preset on a GIL-enabled build, unless stated otherwise. Source links are pinned to CPython v3.15.0; observations from running examples are identified as Python 3.14.3. The final section separates the new 3.15 lifetime APIs from that earlier runtime behavior.

We start with a small program and extend it as each mechanism appears. The buffer, extension and pool examples are separate cases. In a fresh process the main interpreter is 0 and the first new interpreter is 1:

from concurrent import interpreters

interp = interpreters.create()
interp.exec('total = sum(range(10))')

For now, keep interp alive. We will reuse its state, run work on another thread, receive a result through a queue, then close it.

01One process, many interpreters

CPython keeps its state in three layers. The runtime, a single global called _PyRuntime, exists once per process. Each interpreter is a PyInterpreterState. Each OS thread that runs Python code has a thread state, a PyThreadState, and every thread state belongs to exactly one interpreter.

Almost everything you think of as “Python” lives in the interpreter layer. It holds sysdict and builtins, the import state in imports (including sys.modules), the garbage collector in gc, the type caches in types, the list of its thread states in threads, and the evaluation state in ceval, which points at a GIL. Since 3.12 it also holds a pointer to its own object allocator, obmalloc. If two interpreters both import json, each gets its own module object, its own sys.modules entry and independently initialized module globals.

The runtime ties them together. It keeps every interpreter in a linked list (interpreters.head, with interpreters.main pointing at interpreter 0) and hands out ids from interpreters.next_id. It also holds a few safely shared objects, which the next section explains.

A thread is attached when its current thread state gives it permission to use that interpreter’s Python objects and C API. On a GIL-enabled build, attachment also requires holding that interpreter’s GIL. Switching environments means detaching the old state and attaching a state belonging to the target; the OS thread can stay the same.

Figure 1One runtime, three interpreters, one OS thread (simplified)
Plays on its own; a button takes over
Each record shows only the fields this post uses; a real PyInterpreterState has dozens more, and __main__ is an entry in imports.modules. A thread state belongs to one interpreter for its whole life: running elsewhere means a new thread state there. The config of an interpreter is fixed when it is created; the legacy switch shows what interpreter 2 would look like had it been created with "legacy".

02What interpreters still share

Isolation has limits, and they are deliberate. Some objects would be wasteful to build once per interpreter, and some cannot be. _PyRuntime holds a struct called _Py_static_objects, described in the source as “all the objects that are shared by the runtime's interpreters”. It contains the small integers, the empty bytes and tuple objects, the statically allocated identifier strings and a few others. They are safe to share because they are immortal3: ordinary reference-count operations do not mutate their counts, so two interpreters running at the same moment never race to update them.

The static builtin types are the subtle case. PyList_Type is a single C struct, compiled into the binary, so every interpreter sees the same type object. But a type has mutable parts: its attribute dict, its list of subclasses, its weak references. Sharing those would mix interpreter-owned objects and mutations across environments. CPython splits them off. struct types_state keeps a tp_dict, tp_subclasses and tp_weaklist for every static builtin type, per interpreter, in types.builtins.initialized. The shared type object stores only an index into that array, in the field that used to hold tp_subclasses.

Beyond CPython’s own structures, the process is shared too. File descriptors, the current directory, environment variables and the C runtime belong to the process, and every interpreter sees them. The documentation is blunt about what that means: interpreters are not a security boundary, and extension modules can break isolation easily7.

03The configuration decides what is shared

How separate a new interpreter is depends on a small C struct, PyInterpreterConfig. It has seven fields, and CPython defines two presets for them. interpreters.create() takes no arguments; underneath, init_named_config maps “no config” to the "isolated" preset. The old C function Py_NewInterpreter() still uses "legacy".

Creation setting"isolated""legacy"
use_main_obmalloc01
allow_fork01
allow_exec01
allow_threads11
allow_daemon_threads01
check_multi_interp_extensions10
gilPyInterpreterConfig_OWN_GILPyInterpreterConfig_SHARED_GIL

The fields are not independent. For the GIL-enabled configuration described here, an interpreter with its own GIL must also have its own allocator: the docs require use_main_obmalloc to be 0 whenever gil is PyInterpreterConfig_OWN_GIL8. In turn, an interpreter with its own allocator must refuse old-style extension modules. init_interp_settings fails with “per-interpreter obmalloc does not support single-phase init extension modules”, and the comment beside it gives the reason: PyModuleDef.m_base.m_copy leaks objects between interpreters. The extension section returns to that.

The permission settings control operations that affect the surrounding process or complicate shutdown. Here allow_exec concerns replacing the process image through operations such as os.execv(); it does not disable interp.exec(). With allow_fork at 0, the runtime will not fork the process from a thread where this interpreter is active, though subprocess still works: a fork followed straight away by an exec is always allowed. With allow_daemon_threads at 0, the interpreter can start threads but not daemon threads.

04Building an interpreter

Calling interpreters.create() turns an empty interpreter record into a working Python environment. The calling thread does the construction itself. It saves its current thread state and releases its interpreter’s GIL, so other threads there can run while it builds the new environment. No new OS thread is started.

Giving it an identity

CPython allocates the record from a clean template, gives it an id, and adds it to the runtime’s list of interpreters. A runtime lock protects that bookkeeping against two creations at once. Ids only go up and are never reused, so an old id can never point at a replacement interpreter.

Startup settings are copied from the main interpreter. The isolation config then decides which resources the new interpreter will own and which operations it permits; the Python API always picks the isolated preset from the configuration section.

Giving it its own resources

An isolated interpreter needs its own small-object allocator before it starts allocating its own ordinary Python objects. Allocating changes pools, arenas and free lists, and private allocator state lets each interpreter change those under its own lock, without coordinating every allocation with the others.

The new GIL protects execution and object access inside this interpreter. Together, the separate lock and the separate allocator let it build and manage its objects on its own. Extension checks reject imports that do not declare the necessary isolation support; the extension section explains what that declaration requires.

Bootstrapping Python inside it

Building the rest needs a thread running inside the new interpreter. CPython gives the calling thread a temporary thread state that belongs to the new interpreter and attaches it, which takes the new GIL. The same OS thread is now working in the environment it is building.

From there CPython sets up the garbage collector, the type machinery, builtins and sys, then the import system, the encodings and the standard streams. Shared builtin types get their per-interpreter parts. Signal handlers, faulthandler and tracemalloc belong to the main interpreter only, so they are skipped. An empty __main__ module is added last, the namespace later code will run in.

Leaving it ready and idle

The calling thread clears its temporary thread state, releases the new GIL, and goes back to its original thread state and lock. The temporary state is deleted; the interpreter keeps everything built during initialization. It is complete, with no thread running in it.

The Interpreter object you get back holds a reference tied to the interpreter’s id. CPython counts these references, and releasing the last one destroys the interpreter; interp.close() destroys it explicitly. Keeping an interpreter alive and running code in it are separate things.

In the source: creation and initialization, the calling thread’s visit, allocating the record. The sidebar follows the phases and names each call.

Figure 2Where the thread is during create()
Follows the text

05Executing code

An interpreter does not execute code by itself. Our newly created interpreter has no running thread; the direct call borrows the caller’s. interp.exec('total = sum(range(10))') brings the calling thread into it for a round trip: prepare the code, enter the target, run there, and come back. The caller waits until the work is done.

Preparing the code

The source string is compiled in the calling interpreter, under the file name "<script>". The code object that comes out belongs to that interpreter, so CPython encodes it with marshal, and the target rebuilds its own code object from the bytes. The instructions travel; the objects that held them stay with their owners.

Entering the target

The switch changes which interpreter supplies the thread’s execution state. CPython keeps the caller’s thread state and creates a temporary one for the target. Detaching releases the caller’s GIL; attaching takes the target’s. The OS thread is the same, but it now runs with the target’s state, and other threads in the calling interpreter can run while it is away.

CPython also claims the target’s __main__ for this run, and a second exec is refused while the claim is held. The GIL alone could not enforce that: the running code may release the GIL while it waits for I/O, yet its run is still in progress. The claim lasts for the whole visit.

Running in its namespace

The code is rebuilt and evaluated in the target’s __main__ namespace, with the target’s builtins and import state. Our assignment creates total there; nothing is added to the caller’s globals.

That namespace belongs to the interpreter and outlives each call. A later exec can use variables and modules an earlier one left behind, which is what makes an interpreter a reusable environment.

Coming back

Before leaving, CPython turns any uncaught exception into plain data: the type’s name, the message and the formatted traceback. That happens inside the target, where the exception object lives, so the caller can report the failure without receiving the object.

CPython releases the claim on __main__, clears the temporary thread state, and returns to the caller’s: the thread releases the target’s GIL, takes the caller’s back, and the temporary state is deleted. Success returns None; failure raises ExecutionFailed in the caller, carrying the captured description. Changes already made in the target stay there.

interp.call() makes the same round trip, sending a function and its arguments in and a return value back; those values need their own transfer form, which the transfer section covers. Parallelism adds one more ingredient: several threads making these visits at once.

In the source: running scripts and calls, switching in and out. The sidebar follows the phases and names each call.

Figure 3One thread, two interpreters: exec()
Follows the text

We can observe the retained state with another script, then get a value back by calling a function. This function uses only its argument and builtins, so it does not depend on the caller’s module globals:

interp.exec('print(total)')               # 45, from the earlier assignment

def total_of(n):
    return sum(range(n))

print(interp.call(total_of, 10))           # 45, returned to the caller
Source detail: the shared core of script execution and function calls

Both APIs enter the same session. The middle either evaluates the script or calls the transferred function and preserves its return value; both leave through the same cleanup path. This is the trimmed implementation:

// Modules/_interpretersmodule.c, _run_in_interpreter() (trimmed)
if (_PyXI_Enter(session, interp, shareables, &result) < 0) {
    return -1;                 // we never switched
}
if (script != NULL) {
    PyObject *mainns = _PyXI_GetMainNamespace(session, failure);
    res = _run_script(script, mainns, failure);
}
else {
    res = _make_call(call, &resobj, failure);
    res = _PyXI_Preserve(session, "resobj", resobj, failure);
}
// Clean up and switch back.
int exitres = _PyXI_Exit(session, failure, &result);

06Parallel execution with threads and interpreters

The previous round trip used one OS thread. Parallel execution needs several: one can keep running in the caller’s interpreter while another runs in the target. call_in_thread() starts a threading.Thread that makes the direct call for us:

worker = interp.call_in_thread(total_of, 1_000_000)
# The caller can do other work here while the worker runs.
worker.join()

The returned object is a thread, and the function’s return value is discarded by this API. Joining waits for completion; a queue will let our worker deliver a result. The thread starts in the caller’s environment and enters the target for the call. No permanent thread was hidden inside the interpreter when we created it.

Each thread takes the GIL of the interpreter it enters. Threads in one interpreter still take turns executing Python code; threads in different isolated interpreters do not compete for the same interpreter GIL and can execute on different cores7. They can still wait on queues, native locks, I/O and shared runtime resources.

A pool keeps the workers ready

An interpreter pool supplies reusable workers instead of asking us to create a thread for each call. Each worker keeps its own interpreter across tasks. This is an alternative to managing interp directly:

from concurrent.futures import InterpreterPoolExecutor

with InterpreterPoolExecutor(max_workers=2) as pool:
    print(list(pool.map(total_of, [10, 20, 30])))  # [45, 190, 435]
Figure 4One GIL or three (schematic)
Thread poolThreadPoolExecutorinterpreter 0ceval.giloneworker 1OS threadPyThreadStateholds the GILworker 2OS threadPyThreadStatewaits for the GILworker 3OS threadPyThreadStatewaits for the GILone copy,shared by allsys.modulesmoduleglobals,objects byreferenceOne worker runs Python at a time;the others wait for the same GIL.Arguments and results are the same objects.Interpreter poolInterpreterPoolExecutorinterpreter 0(caller)interp.call( do_call, …)args andresults crossas copies:_PyXIData_tinterpreter 1worker 1OS threadPyThreadStateruns, in parallelown ceval.gil,heldown sys.modules,module globalsinterpreter 2worker 2OS threadPyThreadStateruns, in parallelown ceval.gil,heldown sys.modules,module globalsinterpreter 3worker 3OS threadPyThreadStateruns, in parallelown ceval.gil,heldown sys.modules,module globalsGIL-enabled build, the default. On a free-threaded build the thread pool also runs Python in parallel;separate interpreters still give each worker its own module state. Thread poolThreadPoolExecutorinterpreter 0ceval.giloneworker 1OS threadPyThreadStateholds the GILworker 2OS threadPyThreadStatewaitsworker 3OS threadPyThreadStatewaitsone copy,shared by allsys.modulesmoduleglobalsobjects byreferenceOne worker runs Python at a time;the others wait for the same GIL.Interpreter poolInterpreterPoolExecutorinterpreter 0, the callerinterp.call(do_call, …)args in, results back, as copies:_PyXIData_tinterpreter 1worker 1OS threadPyThreadStateruns, in parallelown GIL,heldown sys.modules,module globalsinterpreter 2worker 2OS threadPyThreadStateruns, in parallelown GIL,heldown sys.modules,module globalsinterpreter 3worker 3OS threadPyThreadStateruns, in parallelown GIL,heldown sys.modules,module globalsGIL-enabled build, the default. On a free-threadedbuild the thread pool also runs Python in parallel;separate interpreters still give each worker itsown module state.
Berry: the one thread holding the only GIL. Ink: running.

submit() and map() enqueue tasks for those existing workers; they do not start a fresh call_in_thread() for each task. Each worker uses synchronous interpreter calls on its own thread. After the transfer machinery, the queue section shows how results come back.

Separate state is useful independently of the GIL. Subinterpreters make module globals, imports and ordinary Python object ownership local to each environment; free-threading allows concurrent access within a shared environment. A free-threaded build still supports separate interpreters, and its extension checks remain enabled even for the legacy preset. Neither design guarantees a faster program: interpreter startup, duplicated state, transfer costs and extension compatibility all matter. The question is whether the work benefits from separate environments or from sharing objects directly. See the free-threading guide and executor documentation for the two execution models.

07Extension modules

Pure Python imports normally create separate module objects and globals in each interpreter. C extensions need additional care. Older single-phase modules may keep interpreter-owned state in C globals, and CPython caches a copy of their module dict in PyModuleDef.m_base.m_copy to reuse on later imports. In a second interpreter, that copy would hand over objects from the first interpreter. So an interpreter with check_multi_interp_extensions set refuses them:

>>> interp.exec('import _testsinglephase')
ExecutionFailed: ImportError: module _testsinglephase does not support loading in subinterpreters

Multi-phase modules5 declare what they support with the Py_mod_multiple_interpreters slot9, and the import machinery checks the value against the interpreter:

Slot valueMainShared GIL, checks onOwn GIL, checks on
Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTEDimportsrefusedrefused
Py_MOD_MULTIPLE_INTERPRETERS_SUPPORTED (the default)importsimportsrefused
Py_MOD_PER_INTERPRETER_GIL_SUPPORTEDimportsimportsimports

This table assumes extension checks are enabled in the subinterpreters. The legacy preset disables them on a GIL-enabled build, so its imports are not a promise that a module is safe to share. The default slot value matters. A multi-phase module that never mentions the slot imports fine into a legacy interpreter but is refused by one created with interpreters.create(). Supporting isolated interpreters means keeping interpreter-owned Python state in per-module storage and avoiding unsafe sharing through C globals, which is the work described in the isolation HOWTO6. The support slot is a declaration, not a mechanism that makes unsafe globals safe. Carefully managed process-global resources are possible, but need their own ownership and synchronization rules.

Here is the shape of a module that does it right. Its state is a struct whose size the module definition requests in m_size, and every function reaches it through the module object, never through a C global:

typedef struct {
    PyObject *cache;                   /* one per module object */
} mod_state;

static int mod_exec(PyObject *m)
{
    mod_state *st = PyModule_GetState(m);
    st->cache = PyDict_New();
    return st->cache ? 0 : -1;
}

/* The module definition requests sizeof(mod_state) in m_size.
   Methods use PyModule_GetState(m) to reach this module's cache. */

Each interpreter that imports mod gets its own module object, and with it its own m_size bytes of state. Py_mod_exec fills them in once per module object, m_traverse lets that interpreter’s garbage collector see the cache, and m_clear and m_free release references as the module object is cleared or freed. Those hooks participate in the module object’s lifetime, including garbage collection and interpreter shutdown. The slot only declares support; the isolation comes from where the state lives. A static PyObject *cache in place of the struct would be one object reached from every interpreter, its reference count updated under different GILs.

Complete C example: module definition, support slot and cleanup hooks
typedef struct {
    PyObject *cache;    /* one per module object */
} mod_state;

static int
mod_exec(PyObject *m)
{
    mod_state *st = PyModule_GetState(m);
    st->cache = PyDict_New();
    return st->cache ? 0 : -1;
}

static int
mod_traverse(PyObject *m, visitproc visit, void *arg)
{
    Py_VISIT(((mod_state *)PyModule_GetState(m))->cache);
    return 0;
}

static int
mod_clear(PyObject *m)
{
    Py_CLEAR(((mod_state *)PyModule_GetState(m))->cache);
    return 0;
}

static void
mod_free(void *m)
{
    (void)mod_clear((PyObject *)m);
}

static PyModuleDef_Slot mod_slots[] = {
    {Py_mod_exec, mod_exec},
    {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
    {0, NULL},
};

static struct PyModuleDef mod_def = {
    PyModuleDef_HEAD_INIT,
    .m_name = "mod",
    .m_size = sizeof(mod_state),
    .m_slots = mod_slots,
    .m_traverse = mod_traverse,
    .m_clear = mod_clear,
    .m_free = mod_free,
};

PyMODINIT_FUNC
PyInit_mod(void)
{
    return PyModuleDef_Init(&mod_def);
}

08Crossing the boundary

An ordinary Python object belongs to its interpreter. Handing another interpreter its address would bypass the owner’s allocator, reference-counting and lifetime rules. Instead, the sender describes a value and the receiver reconstructs an object it can use locally. This does not mean every transfer makes a distinct object: safely shared immortal objects can be reused, and local wrappers can refer to shared storage or infrastructure.

Describe, rebuild, release

Consider a string built at runtime, ''.join(['sp', 'am']). The sender records where its characters are, their length and their representation, and retains the source string. The receiver copies those characters into a local string. Once the description is no longer needed, the source reference and temporary payload must be released under the owner’s rules. Reconstruction and cleanup are both part of the transfer.

The representation depends on the type. A small integer can travel as its value; bytes and strings carry enough information to copy their contents; a tuple describes each element in turn. A transfer descriptor records the payload, any retained source object, the owner’s identity and the functions that rebuild and release it. The receiver never treats a retained source reference as an ordinary local object.

Source detail: descriptors and type lookup

_PyXIData_t contains data, obj, interpid, new_object and free. A type’s registered exporter fills these in. Static types use a global registry; heap types use an interpreter-local registry. The builtin exporters cover None, integers, bytes, strings, booleans, floats and tuples. The integer exporter stores a value fitting in Py_ssize_t; larger integers need a fallback. The string exporter retains the source object while the receiver copies its character data.

The API chooses the fallback

prepare_main() only accepts types with a registered transfer representation. A dictionary passed that way raises NotShareableError. Function arguments to call() and values sent through Queue.put() have more options: after registered transfers, CPython tries function transfer and then pickle. Pickle can carry a dictionary, but it still reconstructs objects in the receiver; it does not share the original dictionary.

Figure 5 lets you compare those paths. The source lookup order is in _PyObject_GetXIData.

Figure 5What actually crosses
Pick an API and a value
The bytes are real: marshal.dumps and pickle.dumps output from Python 3.14.3 (the function was compiled from a file named demo.py).

Functions carry code, not their whole environment

Our total_of() uses its argument and builtins. CPython can marshal that kind of function’s code and build a new function against the target’s globals. It does not bring the caller’s global dictionary along. This direct path requires a stateless function: no captured closure, no dependence on module globals, and no default arguments. A closure carrying an enclosing local variable is refused.

A module-level function that needs global state instead takes the pickle path, which identifies it by module and qualified name. The target must resolve that name in its own environment. Importing the same module gives it that interpreter’s state, not the sender’s live globals.

Script functions: why the main guard matters

A function pickled as __main__.name may not exist in the target’s __main__. For this case, _PyPickle_Loads can run the script file under <fake __main__>, cache its namespace and retry. Ordinary top-level statements then run again in the target; an if __name__ == '__main__': block does not. Keep task dispatch and program startup under that guard when using a script. The direct stateless path, used by total_of(), does not need this script fallback.

The function reconstruction itself is in _PyFunction_FromXIData.

Shared buffers: new wrappers, the same bytes

A memoryview deliberately changes the storage relationship7. The receiving interpreter gets a local view over the same underlying bytes. This can avoid copying a large buffer, but it makes synchronization and the owner’s lifetime part of the program’s design.

In the separate example below, interpreter 0 owns a bytearray and its original memoryview. Creating that view already prevents resizing the bytearray. Transferring it adds an export of the view, keeping its buffer dependency alive. Dropping the receiving view releases that dependency back in the owner; resizing is still blocked if the original view remains alive. On Python 3.14.3, writes through the received view were visible in the original bytearray, and resizing succeeded only after both views were released.

The two interpreters’ GILs do not protect the same bytes. Coordinate access explicitly. The pinned source also warns that using a received view after the owning interpreter is destroyed can crash; exporting a buffer does not make its owner safe to close.

In the source: exporting a buffer, building the receiver’s wrapper, returning its release to the owner, the owner-lifetime warning.

Figure 6Two views, one set of bytes (simplified)
mvmemoryviewexports1view.bufone export held for interpreter 1bufbytearrayob_bytesob_exports1held by mv: no resizing while mv livesthe bytes, in interpreter 0’s memoryJellomv[0] = ord('J'), run in interpreter 1nothing locks the bytes: GIL 0 and GIL 1are different locksCrossInterpreterBufferViewviewthe sender’s Py_bufferinterpid0made by _memoryview_from_xidmvmemoryview, newview.bufview.obj_pybuffer_shared: PyObject_GetBuffer(mv), in interpreter 0on drop: xibufferview_dealloc, release runs in interpreter 0interpreter 0GIL 0interpreter 1GIL 1 interpreter 0GIL 0mvmemoryviewexports1view.bufbufbytearrayob_bytesob_exports1held by mv: no resizing while mv livesthe bytes, in interp 0’s memoryJellomv[0] = ord('J'), run in interpreter 1nothing locks the bytes: two different GILs_pybuffer_shared: PyObject_GetBuffer(mv)interpreter 1GIL 1CrossInterpreterBufferViewviewsender’s Py_bufferinterpid0made by _memoryview_from_xidmvmemoryview, newview.bufview.objon drop: xibufferview_dealloc, back in interp 0
The bytearray’s bytes are written in place from interpreter 1; nothing is copied and nothing is locked. If interpreter 0 is destroyed while interpreter 1 still holds its view, using the view crashes: the source leaves this case open.

09Queues

Calls transfer data for one visit. A queue lets interpreters communicate across many visits. Its underlying C queue lives at process scope and protects its items with a mutex. Each interpreter has a local Python handle to that same queue; passing a handle does not copy the channel.

Return to our worker. We pass it a results queue and an argument, then receive the number in the caller. The function needs no caller globals:

results = interpreters.create_queue()

def send_total(q, n):
    q.put(sum(range(n)))

worker = interp.call_in_thread(send_total, results, 10)
print(results.get())                      # 45
worker.join()

put() prepares the value in the sender and appends its transfer record. get() takes the oldest item and reconstructs its value for the receiver, using the same fallback choices described above. The handle can be shared; an ordinary mutable payload still does not become a shared Python object.

Cleanup goes back to the owner

For the runtime-built string from the transfer section, the queued record keeps the sender’s string alive while the receiver copies its characters. Releasing that record may free temporary storage and decrement the retained string’s reference count. That work belongs in the sender’s interpreter, under its GIL.

If another interpreter consumes the item, CPython schedules the owner-side cleanup as a pending call. It runs when the owner next processes pending work, or during finalization. Receiving the value and completing all source-side cleanup are therefore separate events.

A channel can outlive a sender

Queued data can depend on the interpreter that produced it. When that interpreter is destroyed, its queued items become unbound. By default, a later get() returns UNBOUND in their place; UNBOUND_ERROR raises instead, and UNBOUND_REMOVE removes those entries. This binding policy also applies to payloads, such as small integers, that do not need the sender’s object storage.

Figure 7A queue outlives the interpreter that filled it
Before: three items queuedinterp 1q.put('hi')interp 2q.put('ab'); q.put('cd')put()to tail_queueprocess-globalmutexPyThread_type_lockitems.firstitems.lastitems.count3_queueiteminterpid2data'ab'unboundop3next_queueiteminterpid1data'hi'unboundop3next_queueiteminterpid2data'cd'unboundop3nextNULLget() takes the headinterp 0the next q.get() would return 'ab'a new str, its characters copied into interpreter 0. After interpreter 2 is closedinterp 1q.put('hi')interp 2, closednext create(): id 3put()to tail_queueprocess-globalmutexPyThread_type_lockitems.firstitems.lastitems.count3_queueiteminterpid2dataNULLunboundop3next_queueiteminterpid1data'hi'unboundop3next_queueiteminterpid2dataNULLunboundop3nextNULLget() takes the headinterp 0the next q.get() would return UNBOUNDThe item keeps its place; its data was released with interpreter 2.
The two panels are alternatives, not a sequence: what the next get() would return before and after interpreter 2 closes. data is a _PyXIData_t*, shown here by its value; for these strings it points at characters in the sender’s memory. Every item also records its sender’s interpid, and closing an interpreter unbinds all of its items, whatever their data holds. unboundop 3 is UNBOUND_REPLACE, the default. A blocking get() retries every 10 ms (_delay).

This completes the pool model introduced earlier. InterpreterPoolExecutor subclasses ThreadPoolExecutor; each worker’s WorkerContext creates an interpreter and a results queue. On its existing worker thread it calls a helper inside that interpreter, passing the task function, arguments and results handle. The helper communicates the outcome through the queue, and the executor completes the caller’s future. Reusing workers and environments is what makes it a pool.

Source detail: queue storage, blocking and release

The process-global _queue holds a mutex and linked list. queue_put records the sender’s identity in each _queueitem. queue_get reconstructs the value and releases the record. For another owner, _xidata_release uses _Py_CallInInterpreter to schedule cleanup there. In the pinned source, blocking Queue.get() retries a nonblocking operation, sleeping 10 milliseconds by default between attempts.

10Taking it apart

Our worker has joined and the result is in the caller. We can now end the environment:

interp.close()

Closing destroys interpreter-owned state; it is not just dropping the handle. The API refuses to destroy the current interpreter or one occupied by an active execution call. Stop submitting work and finish outstanding calls before closing.

Enter for the last visit

Finalization needs a thread attached to the target. In our example, each direct call removed its temporary thread state and no background thread remains. CPython creates a finalization thread state and enters the target on the closing thread. Other interpreters can have background threads even when no direct call is active; “idle” does not universally mean “no thread states”.

Finish work that needs Python

Shutdown first waits for non-daemon threading threads and runs pending calls and exit handlers while enough of the interpreter still works. That is also an opportunity to finish deferred transfer cleanup. CPython then stops other threads in this interpreter, marks it as finalizing and removes the other thread states from normal use.

A thread that subsequently tries to attach during finalization can hang indefinitely. It does not simply exit: Python changed this behavior in 3.14, so it applies to both the 3.14.3 observations and the 3.15 source discussed here. The isolated preset separately disallows creating daemon threads. See the thread-state finalization documentation.

Release Python state, then the interpreter

The import machinery is dismantled and modules are cleared. Builtins, sys, caches and the remaining garbage-collector state follow. Only after that cleanup can CPython remove the interpreter from the runtime’s list and free its record. The closing thread returns to its original interpreter.

The old id is not reused, so a stale id cannot silently name a new interpreter. Explicit closure also controls when queue items become unbound and when owned buffers become invalid; it belongs after communication and received-view cleanup, not merely after sending work.

Protecting native attachment in 3.15

A native thread needs more than an earlier “is it alive?” check: the interpreter could start finalizing between that check and attachment. Python 3.15 adds guards that prevent finalization while held, views for accessing an interpreter’s lifetime state, and attachment APIs built around those mechanisms. They address this attachment-versus-finalization race. They do not make arbitrary shared Python objects safe. The older PyGILState family is now soft deprecated17.

Source detail: the teardown path

_interpreters.destroy checks whether destruction is allowed. _PyXI_EndInterpreter supplies the finalization thread state. Py_EndInterpreter runs pre-finalization work, shuts down other thread states, clears imports and modules, then clears and deletes the interpreter. If interpreters remain at process exit, the runtime also attempts cleanup; explicit close() makes the lifetime boundary deliberate.

A subinterpreter is a separately initialized Python environment inside the same process. A thread enters that environment to execute; multiple threads in separately locked interpreters can run in parallel. Ordinary interpreter-owned state stays local by default, while communication uses explicit transfer or managed sharing. Process resources, immortal objects, shared buffers and extension code define the limits of that separation. The useful model is an environment with an owner and a lifetime, plus the threads and channels that work with it.

Lakshay

Published

© 2026 Lakshay