Subinterpreters
In this cookbook recipe, you'll learn how to use Python 3.14's
subinterpreters
(concurrent.interpreters
and concurrent.futures.InterpreterPoolExecutor)
for true multi-core CPU parallelism in a Flet app.
A subinterpreter is a separate Python interpreter running inside the same process. Since Python 3.12 each one has its own GIL, so several subinterpreters can run pure-Python code on several CPU cores at once — without starting separate processes.
When to use which
| runs on | true CPU parallelism | notes | |
|---|---|---|---|
| threads | one interpreter, one GIL | ❌ (pure Python) | best for I/O, or C libraries that release the GIL |
| subinterpreters | one process, N interpreters | ✅ | in-process, works on mobile; restricted data sharing; can't force-cancel |
| multiprocessing | N processes | ✅ | full isolation, can hard-cancel a worker; heavier, desktop-only in Flet |
Reach for subinterpreters when you need multiple cores for Python work and want
to stay in one process — especially on mobile, where multiprocessing
cannot spawn child processes at all.
Subinterpreters require Python 3.14 or later. When packaging your app using flet build,
ensure that the bundled Python version meets this requirement.
In development (e.g., when using flet run), the Python interpreter in your virtual environment must also meet this requirement.
They work in Flet apps on macOS, Windows, Linux, iOS, and Android.
On the web it depends on where your Python actually runs:
- Dynamic websites run your app server-side as an ordinary CPython process (FastAPI/Uvicorn), so subinterpreters work just like on desktop — as long as the server runs Python 3.14.
- Static websites run entirely in the browser on Pyodide, a single-threaded WebAssembly runtime with no per-interpreter GIL — so subinterpreters are not available there.
Rules
Define workers at module top level
To run a worker in another interpreter, CPython copies it there — its code
plus the module-level functions and constants it references. Define workers at
the top level of a module (your main.py, as the examples below do, or a
separate file); both behave identically on macOS, Windows, Linux, iOS, and
Android.
A worker function nested inside main() or a button handler only works if it is
stateless — no captured variables and no module globals — so the moment it
references a helper or a constant it fails with NotShareableError: only stateless functions are shareable. A top-level function has no such limit: it
can freely call other module-level helpers. (Also don't call a helper from inside
a generator expression — see Caveats.)
Pass only picklable / shareable data
Arguments and return values are
pickled to cross the interpreter
boundary, so they must be picklable. The low-level
Queue
additionally accepts shareable objects directly — numbers, str, bytes,
None, tuples of those, and the queue itself. Don't pass Flet controls, page,
open files, or database connections.
Don't touch the GUI from a subinterpreter
Workers run in an isolated interpreter with no access to your page. Return data
(or stream it through a Queue) and update the UI from the main interpreter.
Examples
Parallel map across cores
InterpreterPoolExecutor
is a drop-in alternative to
ProcessPoolExecutor:
it runs each task in a subinterpreter and, because each has its own GIL, uses
several cores at once — all in one process.
import time
from concurrent.futures import InterpreterPoolExecutor, as_completed
import flet as ft
def _is_prime(n: int) -> bool:
"""Returns True if `n` is prime."""
if n < 2:
return False
for d in range(2, int(n**0.5) + 1):
if n % d == 0:
return False
return True
def count_primes(limit: int) -> int:
"""Count the primes below `limit` (CPU-bound, pure Python)."""
count = 0
for n in range(2, limit):
if _is_prime(n):
count += 1
return count
def main(page: ft.Page):
def start():
button.disabled = True # block a second run while this one is in flight
page.update()
page.run_thread(run)
def run():
"""Time the same work sequentially and across a pool, then report the
speedup. Runs on a background thread so the UI stays responsive."""
limits = [200_000 + i * 30_000 for i in range(8)]
# Baseline: run every chunk in this one interpreter (one core).
status.value = "Sequential…"
progress.value = 0
page.update()
started = time.perf_counter()
for done, limit in enumerate(limits, 1):
count_primes(limit)
progress.value = done / len(limits)
page.update()
seq_time = time.perf_counter() - started
# Parallel: one subinterpreter per chunk, each with its own GIL.
status.value = "Parallel…"
progress.value = 0
page.update()
primes = completed = 0
started = time.perf_counter()
with InterpreterPoolExecutor() as pool: # sizes itself to the CPU count
futures = [pool.submit(count_primes, n) for n in limits]
for future in as_completed(futures):
primes += future.result()
completed += 1
progress.value = completed / len(futures)
page.update()
par_time = time.perf_counter() - started
status.value = (
f"{primes} primes · sequential {seq_time:.1f}s · "
f"parallel {par_time:.1f}s · {seq_time / par_time:.1f}× faster"
)
button.disabled = False
page.update()
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
button := ft.Button(
"Count primes: sequential vs parallel",
on_click=start,
),
progress := ft.ProgressBar(value=0, width=300),
status := ft.Text(),
]
)
)
)
if __name__ == "__main__":
ft.run(main)
The example times the same work run sequentially and then across the pool, and
reports the speedup. Orchestration runs off the UI thread with
page.run_thread, and parallel results are collected
as each task lands via
as_completed.
The pool only wins when each task does enough work to outweigh the cost of
starting a subinterpreter — the speedup is largest on a multi-core desktop, and
smaller on mobile, where startup costs more and there are fewer cores.
Stream progress from a subinterpreter
To show fine-grained progress from a single long job, share a
Queue
with the subinterpreter. The worker puts progress values; a background thread
drains them into the UI:
import threading
from concurrent import interpreters
import flet as ft
def _is_prime(n: int) -> bool:
"""Returns True if `n` is prime."""
if n < 2:
return False
for d in range(2, int(n**0.5) + 1):
if n % d == 0:
return False
return True
def _count_in_range(lo: int, hi: int) -> int:
"""Returns the number of primes in the half-open range [lo, hi)."""
count = 0
for n in range(lo, hi):
if _is_prime(n):
count += 1
return count
def stream_primes(progress_queue, chunks: int, per_chunk: int) -> None:
"""Count primes in `chunks` slices, reporting progress after each one.
Runs in a subinterpreter, which has no access to the page — the queue is
the only channel back to the UI. Values are fractions 0..1; a final `None`
tells the consumer there is nothing more to read.
"""
for i in range(chunks):
lo = i * per_chunk + 2
_count_in_range(lo, lo + per_chunk)
progress_queue.put((i + 1) / chunks)
progress_queue.put(None) # sentinel: no more updates
def main(page: ft.Page):
def start():
button.disabled = True
status.value = "Working…"
page.update()
# A queue shared between this interpreter and the subinterpreter. Only
# "shareable" objects cross it (numbers, str, bytes, None, tuples of
# those, and the queue itself).
queue = interpreters.create_queue()
interp = interpreters.create()
drained = threading.Event()
# The worker runs the job in the subinterpreter and blocks that thread,
# so it goes on its own background thread…
page.run_thread(work, interp, queue, drained)
# …while a second thread drains progress and drives the UI.
page.run_thread(drain, queue, drained)
def work(interp, queue, drained):
"""Run the job in the subinterpreter, then close it once the UI is done.
On its own thread because interp.call() blocks until the job finishes.
The interpreter is closed only after `drained` is set — a subinterpreter
Queue's pending items go invalid the moment its interpreter closes.
"""
interp.call(stream_primes, queue, 20, 100_000)
drained.wait()
interp.close()
def drain(queue, drained):
"""Forward the worker's progress reports to the UI.
Runs on a background thread: queue.get() blocks until the worker
reports again, so it must stay off the UI event loop.
"""
while (value := queue.get()) is not None:
progress.value = value
status.value = f"Counting… {value:.0%}"
page.update()
drained.set()
status.value = "Done!"
button.disabled = False
page.update()
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
button := ft.Button("Start", on_click=start),
progress := ft.ProgressBar(value=0, width=300),
status := ft.Text(),
]
)
)
)
if __name__ == "__main__":
ft.run(main)
Two details worth noting: the worker runs on its own thread because
interp.call()
blocks until the job finishes, and the interpreter is closed
only after the UI has drained every item — a subinterpreter Queue's
pending items become invalid the moment its interpreter closes.
Keep a persistent, stateful interpreter
Creating an interpreter with
interpreters.create()
isn't free, so don't create one per task. Create it once and reuse it: the state a worker builds (here, the prime table cached in
a module global) persists in that interpreter between calls, so expensive setup
happens only on the first call.
import atexit
import time
from concurrent import interpreters
import flet as ft
_UPPER = 2_000_000
_primes: list[int] | None = None
def _is_prime(n: int) -> bool:
"""Returns True if `n` is prime."""
if n < 2:
return False
for d in range(2, int(n**0.5) + 1):
if n % d == 0:
return False
return True
def nth_prime(n: int) -> dict:
"""Returns the n-th prime (1-indexed), building a prime table on first call.
Runs in a long-lived subinterpreter that keeps its state across calls, so
the table is built once (cached in a module global) and reused after that.
The build stands in for genuinely expensive setup — loading a model, opening
a dataset, warming a cache.
"""
global _primes
built = _primes is None
if built:
table = []
for x in range(2, _UPPER):
if _is_prime(x):
table.append(x)
_primes = table
return {
"prime": _primes[n - 1],
"built_this_call": built,
"table_size": len(_primes),
}
def main(page: ft.Page):
# One long-lived subinterpreter, created once and reused for every query,
# so its cached state survives.
interp = interpreters.create()
atexit.register(interp.close) # close it at exit
def query():
button.disabled = True
page.update()
page.run_thread(run)
def run():
"""Call the interpreter on a background thread and show the result."""
started = time.perf_counter()
result = interp.call(nth_prime, 100_000)
elapsed = time.perf_counter() - started
how = "built the table" if result["built_this_call"] else "reused cache"
status.value = f"100,000th prime = {result['prime']}\n{how} in {elapsed:.2f}s"
button.disabled = False
page.update()
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Text(
"Click twice: the first query builds a prime table, the "
"second reuses it from the live interpreter."
),
button := ft.Button("Find the 100,000th prime", on_click=query),
status := ft.Text(),
]
)
)
)
if __name__ == "__main__":
ft.run(main)
Click twice: the first query builds the prime table (slow), the second reuses it from the live interpreter (instant). In a real app that table stands in for a loaded model, an opened dataset, or a warmed cache.
Because this interpreter lives for the whole session, the example registers
atexit to
close()
it at shutdown — otherwise Python warns that a subinterpreter was left open.
Caveats
- You can't force-cancel a subinterpreter: It runs on a thread, so — unlike
a
multiprocessing.Process— there's noterminate(). If you need to abort a runaway task, use multiprocessing instead. - Not every C extension supports subinterpreters: An extension must opt in
(multi-phase initialization with per-interpreter GIL support); some
third-party native libraries don't yet and raise
ImportErrorwhen imported in a subinterpreter. Pure-Python code and the standard library work. - Reuse interpreters and pools: Interpreter startup isn't free (each re-imports its modules); create a pool or a persistent interpreter once rather than per task.
- Call module-level helpers from a loop or list comprehension, not a generator
expression: The names a generator expression looks up aren't carried into the
subinterpreter, so
sum(f(x) for x in ...)referencing a module-levelfraisesNameError— in a module just as much as inmain.py. A plainforloop or a list comprehension (which Python inlines) works instead, as these examples do.