Skip to content

← True Parallelism and the Runtime step 17 of 18

Medium Research

Subinterpreters: PEP 734 and the Typed Queue

Subinterpreters are the first genuinely new concurrency substrate since asyncio: same process, no fork hazards, no second memory image, no __main__ re-import, and a cheaper transport than a pipe.

PEP 684 (3.12) gave each subinterpreter its own GIL. PEP 734 (3.14) exposes it: concurrent.interpreters, plus concurrent.futures.InterpreterPoolExecutor — which is a ThreadPoolExecutor subclass, one OS thread per worker, each thread running its own interpreter.

Verified against 3.14.6: create(), create_queue(), get_current(), get_main(), list_all(), is_shareable(); and on an Interpreter: exec, call, call_in_thread, prepare_main, close. sys.implementation.supports_isolated_interpreters is True.

What crosses, and how

Shareable without copying: None, bool, int, float, bytes, str, tuples of those, memoryview, and Queue itself.

Everything else — including list and dict — is copied via pickle. So a dict does arrive, and it arrives as a different object, and if it contains a lambda you are back in lesson 10.1.

And the __main__ trap is identical to spawn: a function defined in a REPL, a heredoc or an exec‘d namespace has no importable qualified name, so handing it to another interpreter cannot work.

Honest numbers

10-core arm64, 8 workers, a pure-Python integer loop:

Approach Time Speed-up
serial 516 ms 1.00x
ThreadPoolExecutor 512 ms 1.01x — the GIL
ProcessPoolExecutor 130 ms 3.96x
InterpreterPoolExecutor 121 ms 4.25x

A modest, real margin over processes — not a 10x story, and anyone selling you one is selling you something.

Your task

class TypedQueue[T]:
    def __init__(self, item_type: type[T], raw: interpreters.Queue) -> None: ...
    def put(self, item: T) -> None: ...
    def get(self, timeout: int = 5) -> T: ...       # isinstance-checked

def run_isolated[T](channel: TypedQueue[T], code: str, **shared: object) -> T: ...

run_isolated creates a fresh interpreter, prepare_mains the queue and any extra names into it, execs the code, collects the one result, and always closes the interpreter.

def solve(
    *, code: str, values: list[int], failing_code: str
) -> tuple[tuple[int, ...], int, str, bool]:

Return the sorted results, the change in live-interpreter count (which must be 0 — every interpreter you made, you closed), the exception class name that failing_code produced, and confirmation that a dict survives the queue.

The ordering constraint in run_isolated is the subtle part: read before you close. An item still sitting in the queue when its sending interpreter is destroyed becomes unbound, and by default you get a sentinel rather than your value. Collect, then tear down.

Why the runtime check is not redundant

def get(self, timeout: int = 5) -> T:
    item = self._raw.get(timeout=timeout)
    if not isinstance(item, self._item_type):
        raise TypeError(...)
    return item

Everywhere else in this course, an isinstance guard on a value the checker already knows the type of is noise. Here it is load-bearing: the sender is a different interpreter, running code the type checker never analysed, and no static analysis can span that boundary. This is the same category as parsing JSON off a socket — the boundary is where types are established, not assumed.

Documentation drift, and the habit it should teach

The published docs describe QueueEmptyError / QueueFullError and exec(code, /, dedent=True). The shipped module exports QueueEmpty and QueueFull, and its signature is exec(self, code, /) with no dedent. For portability, catch queue.Empty and queue.Full — the module’s exceptions subclass them.

The habit: on a module this new, verify against dir() and inspect.signature rather than prose. Prose lags the implementation, and a try/except QueueEmptyError that raises AttributeError is a worse failure than the one you were guarding against.

Loading visualization…