Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

interp-rpc

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)

The problem

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 data

That 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.

What we found (Python 3.14.7, verified on this machine)

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").

Install

pip install interp-rpc

Requires 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.

Use

# mymodule.py
def process(item: dict) -> dict:
    item["squared"] = item["n"] ** 2
    return item
from 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 form

Either 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).

Errors

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 log

Be 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.

Shareability validation

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.

Pool

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.

Errors

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 after close().
  • PoolInitError — init= failed or timed out in one or more workers during startup.
  • CallTimeoutError — also a TimeoutError. target, timeout. See "Timeouts" below.

Timeouts

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.

Worker death

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.

Shutdown

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.

Evidence

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.

What it does not do

  • 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 raises InterpreterError, not a kill. A timeout= on call()/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 ShareabilityError naming 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_interpreters support for any C extension before relying on it inside init=.

Develop

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.py

License

MIT

About

Typed RPC over a warm pool of PEP 734 subinterpreters — real parallelism, real error propagation, no hanging calls.

Topics

Resources

Stars

68 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages