A warm pool of PEP 734 subinterpreters
with structured request/response dispatch, real error propagation, and a
shutdown path that never hangs. Python's counterpart to
worker-rpc — same problem,
one process, threads that don't share a GIL instead of threads that do.
from interp_rpc import InterpreterPool
with InterpreterPool(workers=4, init="import mymodule") as pool:
result = pool.call("mymodule.process", data)
results = pool.map("mymodule.process", items)Python 3.14 shipped concurrent.interpreters: multiple interpreters in one
process, each with its own GIL, so CPU-bound code actually runs in
parallel — no multiprocessing, no pickling a whole process's worth of
startup cost. This is a big deal. It is also, deliberately, a very low-level
module: raw Interpreter.exec()/.call(), a narrow set of directly
shareable types, and errors that tell you that something didn't cross the
boundary without telling you what, or where.
>>> import concurrent.interpreters as interp
>>> it = interp.create()
>>> it.call(process, {"a": 1, "b": {"handle": some_lock}})
NotShareableError: ({'a': 1, 'b': {'handle': <unlocked _thread.lock object at 0x104...>}},) does not support cross-interpreter dataThat error names the entire argument tuple, repr'd in full — not that
data["b"]["handle"] is the actual problem. On a real payload with
megabytes of legitimate data around one bad field, this is close to
useless. concurrent.futures.InterpreterPoolExecutor (also stdlib) is
shaped like a normal executor but surfaces the same raw errors. The one
PyPI package for this, interpreters-pep-734, is a backport of the
low-level API for Python 3.12/3.13 — not an ergonomic layer either.
worker-rpc solved this exact problem for Node's worker_threads:
structured calls, real error propagation, clear failures instead of hangs
or cryptic serialization errors. This is that, for subinterpreters.
Everything below was run directly against concurrent.interpreters, not
assumed. See examples/ for the scripts.
is_shareable() is stricter than what actually crosses. The
zero-copy shareable protocol accepts int, str, bytes, bool, None,
float, tuples of those, and a couple of special types like Queue. It
rejects plain dict and list. But Interpreter.call()'s docstring says
it "will fall back to pickle" — and it does: a dict/list argument, or
a custom exception instance, crosses fine as long as it (and everything
inside it) is picklable. The real constraint on call()/exec() is
picklable, not "directly shareable" — a materially bigger set of types
than is_shareable() alone suggests.
Interpreter.call() accepts real callables, and "stateless" is defined
narrowly. A plain module-level function crosses fine, whether or not it
references module globals — call() pickles it by reference
(module.qualname) and the target interpreter imports the module to
resolve it, exactly like unpickling any other reference. A closure —
fn.__closure__ is non-empty because it captures a free variable from an
enclosing scope — is rejected outright with a bare NotShareableError
("only stateless functions are shareable" or "func not shareable",
we saw both wordings depending on the code path). A nested function with
no free variables works fine despite being defined inside another
function — __closure__ is None is the actual test, not "was this
defined at module level." interp-rpc checks __closure__ before ever
dispatching, so a closure fails immediately with a clear message instead
of a call round-trip and a cryptic NotShareableError.
Exceptions cross as data, never as the original type.
ExecutionFailed.excinfo is a plain namespace: .type has __name__,
__qualname__, __module__ (not the real class — isinstance() against
it is meaningless), .msg (the formatted message), .formatted
("module.ClassName: message"), and .errdisplay — the full remote
traceback as text, file names, line numbers and all. The traceback
object itself doesn't cross (it can't — it references live frames in a
different interpreter), but its formatted text does, completely. This is
the same trade worker-rpc makes for JS Errors across postMessage:
the type name and message survive, isinstance/instanceof against the
original class does not.
A closed/dead interpreter fails immediately, not by hanging.
Interpreter.call()/.exec() on an interpreter whose .close() already
ran raises InterpreterNotFoundError synchronously — confirmed with a
direct test, no timeout needed. The one surprising asymmetry:
Interpreter.close() on an interpreter that's currently running a call
raises InterpreterError("interpreter running") instead of killing it —
there is no forceful cancel. Unlike multiprocessing, you cannot
.terminate() a stuck subinterpreter. interp-rpc's pool is built around
both halves of this: worker death is detected and turned into a clean,
immediate failure (never a hang) for every pending call; a slow/stuck
call is not killable, and the library says so rather than pretending
otherwise (see "What it does not do").
pip install interp-rpcRequires Python 3.14+. This is not a style choice — concurrent.interpreters
(PEP 734) does not exist before 3.14, and the package fails to import on
anything older with a clear RuntimeError rather than a confusing one.
Zero runtime dependencies.
# mymodule.py
def process(item: dict) -> dict:
item["squared"] = item["n"] ** 2
return itemfrom interp_rpc import InterpreterPool
with InterpreterPool(workers=4, init="import mymodule") as pool:
result = pool.call("mymodule.process", {"n": 7})
results = pool.map("mymodule.process", [{"n": i} for i in range(1000)])init= runs once per worker, before it serves any calls — this is where
imports belong. Imports are per-interpreter and genuinely not cheap (each
subinterpreter re-imports and re-initializes every module it uses), so
paying that cost once at pool startup instead of on a cold first call is
the whole point of a pool rather than a one-shot interp.create().
A target can be a dotted path string (resolved via importlib, walking
back through the path to find where the module ends and the attribute
path begins — so "mypkg.mymodule.MyClass.method" works too) or a bare
module-level callable:
import mymodule
pool.call(mymodule.process, {"n": 7}) # same as the string formEither way, the target has to be resolvable by reference in the worker —
defined at module level in a module the worker can import (typically via
init=), not a closure or a lambda that captures state (see "What we
found" above for exactly where that line is).
A remote exception arrives as RemoteError, not a bare
ExecutionFailed:
# mymodule.py
class ValidationError(ValueError):
def __init__(self, field, message):
super().__init__(message)
self.field = field
def save(record):
raise ValidationError("email", "invalid field: email")from interp_rpc import RemoteError
try:
pool.call("mymodule.save", record)
except RemoteError as err:
err.remote_type_name # "ValidationError"
err.remote_module # "mymodule"
err.remote_message # "invalid field: email"
err.remote_traceback # full formatted traceback text from the worker
str(err) # both, stitched together, ready to logBe honest about what doesn't survive: isinstance(err, ValidationError)
is False. The original class object never crosses the interpreter
boundary — there's no mechanism for shipping a class across, only data —
so every propagated error reconstructs as RemoteError. Branch on
err.remote_type_name (and err.remote_module, if two modules use the
same class name), not isinstance your custom class. This is the same
trade-off worker-rpc documents for postMessage-based errors on the
Node side, for the same underlying reason.
A bad argument fails before a call is ever dispatched, and names the exact path to the offending value — not the whole argument, repr'd:
>>> pool.call("mymodule.process", {"a": 1, "b": {"handle": open("f")}})
interp_rpc.ShareabilityError: argument 'item'['b']['handle'] is not shareable
across the interpreter boundary: a value of type 'BufferedReader' was rejected
(cannot pickle '_thread.lock' object).
Shareable values: str, bytes, bytearray, int, float, bool, None, and any
nesting of list/tuple/dict/set/frozenset built from those, plus instances of
classes importable under the same name in the worker (define them at module
level in a module your pool's init= imports -- not inside a function, and
not a lambda).interp-rpc gets the parameter name ('item' above, not args[0]) from
the target function's own signature via inspect.signature(...).bind_partial(...),
then walks the value the same way pickle would — dict keys, list/tuple
indices, set elements, object attributes — trying to pickle each one, so
the first name in the path is always the actual culprit.
| Option | Default | What it does |
|---|---|---|
workers |
4 |
Number of subinterpreters, each with its own dedicated OS thread and its own GIL. |
init |
None |
Source run once per worker via Interpreter.exec() before it serves calls. Typically imports. |
init_timeout |
30.0 |
Seconds to wait for every worker's init= during construction. Raises PoolInitError (and tears every worker back down) if any worker's init= fails or times out. |
| Method | What it does |
|---|---|
pool.call(target, *args, timeout=None, **kwargs) |
Dispatch and block for the result. |
pool.map(target, iterable, timeout=None) |
Dispatch target(item) for every item, in parallel, in order. |
pool.submit(target, *args, **kwargs) |
Dispatch without blocking; returns a concurrent.futures.Future. |
pool.close(wait=True, timeout=None) |
Stop accepting new calls; let queued work finish; shut every worker down. |
All extend InterpRpcError.
RemoteError— a call raised inside a worker.remote_type_name,remote_module,remote_message,remote_traceback.ShareabilityError— an argument (or, rarely, a return value) can't cross the boundary.argument_path,value_type,reason.TargetResolutionError— the target string/callable can't be resolved or dispatched (bad dotted path, missing attribute, or a closure).WorkerDiedError— a call's worker interpreter was gone by the time it ran.target,detail.PoolBrokenError— every worker has died; nothing is left to run new calls.PoolClosedError— a call was made afterclose().PoolInitError—init=failed or timed out in one or more workers during startup.CallTimeoutError— also aTimeoutError.target,timeout. See "Timeouts" below.
pool.call("mymodule.slow_thing", timeout=5.0)Raises CallTimeoutError after 5 seconds if the call hasn't finished.
This only stops waiting — it does not stop the call. We verified
directly that Interpreter.close() on a running interpreter raises
InterpreterError("interpreter running") instead of killing it; there is
no API to force-stop a subinterpreter mid-call. The call keeps running,
and that worker stays busy (unavailable for new dispatch) until it
finishes on its own. If code needs to be genuinely cancellable, it needs
to check for cancellation itself (e.g. poll a shared flag written through
an argument), the same caveat worker-rpc documents for AbortSignal
against synchronous JS.
If a worker's interpreter is gone (confirmed empirically: closed, or
otherwise unusable — InterpreterNotFoundError on the next call()/exec()),
the call in flight or about to run on it fails immediately with
WorkerDiedError — verified this does not hang, since
InterpreterNotFoundError raises synchronously rather than blocking.
Other queued work is unaffected; it runs on whichever worker picks it up
next from the shared queue. If every worker has died, the pool marks
itself broken: anything still queued is drained and failed with
PoolBrokenError immediately, and any further call()/submit() raises
PoolBrokenError right away instead of queuing into a pool with nothing
left to serve it.
pool.close() lets already-queued work finish, then joins every worker
thread. If a worker is stuck in a call that never returns, there is (as
above) no way to force it to stop — close(timeout=...) bounds how long
it waits before giving up and returning anyway, emitting a
RuntimeWarning naming the stuck worker(s), rather than hanging the
calling program forever.
python examples/bench.py, 4 workers, counting primes below 300,000 four
times over (a CPU-bound, no-I/O workload chosen specifically to defeat the
GIL):
N=300000, WORKERS=4
sequential (1 core, 4x work): 0.662s
threads (4 threads, GIL-bound): 0.661s speedup vs seq: 1.00x
interpreters (4 subinterpreters): 0.199s speedup vs seq: 3.34x
multiprocessing (4 processes): 0.237s speedup vs seq: 2.79x
startup: subinterpreter create+close: 7.58ms
startup: process pool(1) spawn+call+teardown: 37.79ms
small-payload latency: subinterpreter call(): 0.005ms/call
small-payload latency: process pool.apply(): 0.251ms/call
Threads get essentially no speedup on CPU-bound work (1.00x — exactly the
GIL-bound result you'd expect: four threads, one interpreter, one lock).
Subinterpreters get 3.34x on 4 workers — real parallelism, not perfect
(interpreter-creation and dispatch overhead eat some of the ideal 4x), and
in this run ahead of multiprocessing's 2.79x, though that gap moved
around a bit run to run (2.44x-2.79x for multiprocessing across runs on
this machine) — treat "beats multiprocessing on throughput" as close, not
a wide margin. Startup is ~5x faster than spinning up a process, and
small-payload round-trip latency is ~50x faster than
multiprocessing.Pool.apply(), consistently across runs — consistent
with subinterpreters sharing one process (no fork/spawn, no pickling
across an OS pipe) while still getting a private GIL. The startup and
latency wins are the closer-to-guaranteed part of this story; the raw
throughput win over multiprocessing is real here but narrower and
noisier than the threads comparison.
These numbers are from one run on one machine (Apple Silicon, macOS,
CPython 3.14.7) — real hardware and real measurements, not invented, but
not a guarantee of what you'll see elsewhere. Run examples/bench.py
yourself before relying on this for a capacity decision.
- Not a replacement for
multiprocessing's isolation. Subinterpreters share one OS process. A segfault in native code (a C extension, mostly) takes down every worker and the main interpreter with it, exactly like a single-interpreter Python program — there is no process boundary here. - No forceful cancellation. Verified directly:
Interpreter.close()on a running interpreter raisesInterpreterError, not a kill. Atimeout=oncall()/map()stops waiting, not the call itself — see "Timeouts" above. - Arguments and return values must be picklable, and any custom
classes involved must be importable under the same qualified name in
the worker. Sockets, locks, open file handles, generators, and most
closures are not picklable and fail before dispatch with a
ShareabilityErrornaming exactly where. - No streaming. Every call is one request, one response, like
worker-rpc. There's no channel for a call to push partial results back before it returns. - Every worker in a pool runs the same
init=. There's no per-worker configuration; if workers need to differ, run separate pools. - C-extension modules that aren't subinterpreter-safe. Most of the
standard library and popular pure-Python packages are fine on 3.14, but
a C extension that hasn't opted into multi-interpreter support (via its
module init slots) can fail to import in a subinterpreter, or worse,
corrupt shared global state if it wasn't built with that isolation in
mind. Check
Py_mod_multiple_interpreterssupport for any C extension before relying on it insideinit=.
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest -q
.venv/bin/python -m mypy src --strict
python examples/bench.pyMIT