concurrent.interpreters Module Complexity¶
The concurrent.interpreters module, new in Python 3.14, runs code in isolated interpreters
inside the current process, each with its own modules and its own GIL. Objects are not shared: a
value that crosses into an interpreter, or through one of the module's queues, arrives as a copy,
and a value the fast path cannot copy is pickled. Queues themselves and memoryview buffers are
the exceptions, crossing as handles to the same underlying data. So the costs are creating an
interpreter, which is large and fixed, and moving data, which is linear in what moves.
i is live interpreters, s is the size of the data one call moves across - the bytes or
characters of shareable values, or the pickled size of anything else - k is names passed to
prepare_main(), p is the length of source passed to exec(), and m is the objects an
interpreter holds when it is closed. The bounds exclude the work of the code or callable run
inside the interpreter.
Complexity Reference¶
Module Functions¶
| Operation | Time | Space | Notes |
|---|---|---|---|
interpreters.create() |
O(1) | O(1) | Fixed but large, far above a small call(): a fresh interpreter initialises its own sys, builtins and import system, so reuse interpreters |
interpreters.list_all() |
O(i) | O(i) | One Interpreter per live interpreter, the main one included |
interpreters.get_current(), interpreters.get_main() |
O(1) | O(1) | |
interpreters.is_shareable(obj) |
O(1) | O(1) | Checks the outer type only: a tuple holding a list passes, and sending it still raises NotShareableError |
interpreters.create_queue(maxsize=0, *, unbounditems=UNBOUND) |
O(1) | O(1) | maxsize=0 is unbounded |
Interpreter¶
| Operation | Time | Space | Notes |
|---|---|---|---|
Interpreter.id, Interpreter.whence |
O(1) | O(1) | Stored id; whence names what created it |
Interpreter.is_running() |
O(1) | O(1) | Whether an exec() or call() is running in it now |
Interpreter.prepare_main(ns=None, /, **kwargs) |
O(k + s) | O(k + s) | Copies each value into the interpreter's __main__; only shareable values, anything else raises NotShareableError |
Interpreter.exec(code, /) |
O(p) | O(p) | Compiles and runs in the interpreter's __main__; an uncaught exception comes back as ExecutionFailed, not the exception itself |
Interpreter.call(callable, /, *args, **kwargs) |
O(s) | O(s) | Arguments and return value cross by copy, falling back to pickle; blocks the calling thread |
Interpreter.call_in_thread(callable, /, *args, **kwargs) |
O(1) | O(1) | Starts a thread that makes the call() and discards its result; the thread pays the O(s) |
Interpreter.close() |
O(m) | O(1) | Finalises and frees everything the interpreter holds; the object is unusable afterwards |
Queue¶
| Operation | Time | Space | Notes |
|---|---|---|---|
Queue.put(obj, block=True, timeout=None, *, unbounditems=None), Queue.put_nowait(obj, *, unbounditems=None) |
O(s) | O(s) | Stores a copy, or a pickle of anything not shareable. On a full bounded queue put() waits and put_nowait() raises QueueFull |
Queue.get(block=True, timeout=None), Queue.get_nowait() |
O(s) | O(s) | A copy or unpickled reconstruction, not the object put (singletons such as None aside); QueueEmpty when there is none. The timeout is truncated to whole seconds, so under one second a get() does not wait |
Queue.qsize(), Queue.empty(), Queue.full() |
O(1) | O(1) | Counters held by the queue |
Queue.id, Queue.maxsize, Queue.unbounditems |
O(1) | O(1) | The id is what crosses when a queue is sent to another interpreter |
Exceptions¶
| Operation | Time | Space | Notes |
|---|---|---|---|
interpreters.InterpreterError, interpreters.InterpreterNotFoundError |
O(1) | O(1) | The second is raised by a method on a closed interpreter |
interpreters.ExecutionFailed, ExecutionFailed.excinfo |
O(1) | O(1) | A summary of the uncaught exception - its type name, message and traceback text |
interpreters.NotShareableError |
O(1) | O(1) | A value prepare_main() cannot copy |
interpreters.QueueEmpty, interpreters.QueueFull |
O(1) | O(1) | Subclasses of queue.Empty and queue.Full |
Creating and Reusing Interpreters¶
An interpreter is a whole Python runtime, so creating one costs far more than a call into one
that exists. Create a few, reuse them for many calls, and close() them when done.
import sys
if sys.version_info >= (3, 14):
from concurrent import interpreters
interp = interpreters.create() # O(1), but a whole runtime
assert interp in interpreters.list_all() # O(i)
assert interpreters.get_main().whence == 'runtime init'
assert interpreters.get_current().id == interpreters.get_main().id # O(1)
for value in range(3):
assert interp.call(pow, value, 2) == value * value # reused, O(s) each
thread = interp.call_in_thread(pow, 2, 5) # O(1) here; the result is discarded
thread.join()
assert not interp.is_running() # O(1)
interp.close() # O(m)
try:
interp.exec('pass')
except interpreters.InterpreterNotFoundError:
pass
else:
raise AssertionError('a closed interpreter ran code')
Moving Data Across¶
Values Cross as Copies¶
prepare_main(), call() and the queues copy what they carry, so each costs the size of the data
and the receiver gets a distinct object; a memoryview is the exception, sharing its buffer.
is_shareable() looks at the outer type only.
import sys
if sys.version_info >= (3, 14):
from concurrent import interpreters
interp = interpreters.create()
interp.prepare_main(data=b'x' * 1000, label='run') # O(k + s)
interp.exec('size = len(data)') # O(p)
assert interp.call(len, b'x' * 1000) == 1000 # O(s) each way
assert interpreters.is_shareable(([],)) is True # O(1) - the tuple, not its list
try:
interp.prepare_main(nested=([],))
except interpreters.NotShareableError:
pass
else:
raise AssertionError('a list crossed into another interpreter')
try:
interp.exec('raise ValueError("boom")')
except interpreters.ExecutionFailed as error:
assert error.excinfo.type.__name__ == 'ValueError'
assert error.excinfo.msg == 'boom'
else:
raise AssertionError('the uncaught exception was lost')
interp.close()
Queues¶
A queue is shared by id, so passing one to another interpreter is O(1). The items on it are
copies, pickled when they are not shareable, so get() returns a reconstruction, not the object
that was put.
import sys
if sys.version_info >= (3, 14):
from concurrent import interpreters
queue = interpreters.create_queue(maxsize=1) # O(1)
payload = {'rows': [1, 2, 3]}
queue.put(payload) # O(s) - pickled, since a dict is not shareable
assert queue.full() and queue.qsize() == 1 # O(1)
try:
queue.put_nowait('more')
except interpreters.QueueFull:
pass
else:
raise AssertionError('a full queue took another item')
received = queue.get() # O(s)
assert received == payload and received is not payload
assert queue.empty()
try:
queue.get(timeout=0.5) # truncated to 0 seconds: raises at once
except interpreters.QueueEmpty:
pass
else:
raise AssertionError('an empty queue returned an item')
Common Patterns¶
A Worker Interpreter Fed by a Queue¶
import sys
import threading
if sys.version_info >= (3, 14):
from concurrent import interpreters
tasks = interpreters.create_queue()
results = interpreters.create_queue()
interp = interpreters.create()
interp.prepare_main(tasks=tasks, results=results) # O(1) per queue: sent by id
worker = threading.Thread(target=interp.exec, args=('''
while (item := tasks.get()) is not None:
results.put(item * item)
''',))
worker.start()
for item in range(5):
tasks.put(item) # O(s)
tasks.put(None)
worker.join()
assert [results.get() for _ in range(5)] == [0, 1, 4, 9, 16]
interp.close()
Performance Best Practices¶
✅ Do:
- Create interpreters once and reuse them, or use
concurrent.futures.InterpreterPoolExecutor, which keeps one per worker thread - Send bytes, strings, numbers and tuples of them: they take the copy path, not pickle
- Close interpreters you are done with;
close()is what frees their memory
❌ Avoid:
- An interpreter per task:
create()costs more than most small tasks - Large or deeply nested values sent many times; each crossing is O(s) and makes a new copy
- Relying on
is_shareable()for containers: it does not look inside a tuple - A sub-second
timeouton a queue'sget()orput(); it is truncated to zero
Version Notes¶
- Python 3.14+: Module added
Related Modules¶
- concurrent.futures -
InterpreterPoolExecutorruns tasks on a pool of these - threading -
call_in_thread()returns athreading.Thread - multiprocessing - Isolation by process instead, with pickling on every transfer
- pickle - The fallback for values that are not shareable