Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 1.0.0

### Documentation

* Add a [**Subinterpreters** cookbook page](https://docs.flet.dev/cookbook/subinterpreters) on using Python 3.14's [`concurrent.interpreters`](https://docs.python.org/3/library/concurrent.interpreters.html) and [`InterpreterPoolExecutor`](https://docs.python.org/3/library/concurrent.futures.html#interpreterpoolexecutor) for true multi-core CPU parallelism inside a single Flet process — the in-process, mobile-capable counterpart to [Multiprocessing](https://docs.flet.dev/cookbook/multiprocessing), which can't spawn child processes on iOS/Android. Walks through three runnable examples — a parallel pool map, streaming progress over a shared cross-interpreter `Queue`, and a reused long-lived interpreter — with the rules and gotchas for each. Works on desktop and mobile with the bundled Python 3.14; not in static (Pyodide) web builds ([#6782](https://github.com/flet-dev/flet/pull/6782)) by @ndonkoHenri.

## 0.86.7

### Bug fixes
Expand Down
87 changes: 87 additions & 0 deletions sdk/python/examples/cookbook/subinterpreters/parallel_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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)
100 changes: 100 additions & 0 deletions sdk/python/examples/cookbook/subinterpreters/streaming_queue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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)
13 changes: 10 additions & 3 deletions website/docs/cookbook/multiprocessing.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ For I/O-bound work, or work that just needs to stay off the UI thread, prefer
Reach for `multiprocessing` when you need multiple CPU cores doing Python work
at the same time (number crunching, batch processing, ML inference, etc.), or
when you need process isolation for work that may fail or need to be stopped.
For multi-core Python **in a single process** — including on mobile, where
`multiprocessing` cannot run — see [subinterpreters](subinterpreters.md)
(Python 3.14+).

:::important[Platform and Flet version support]
`multiprocessing` works in Flet desktop apps during development ([`flet run`](../cli/flet-run.md)) and
Expand All @@ -22,7 +25,10 @@ or [`flet debug {macos,windows,linux}`](../cli/flet-debug.md) when using [Flet v

It is **not supported on iOS and Android** (mobile operating systems don't
allow apps to spawn arbitrary child processes) or **in the browser**. On those
platforms, prefer threads or `asyncio` instead.
platforms, use threads or [`asyncio`](https://docs.python.org/3/library/asyncio.html)
for I/O-bound work, and [subinterpreters](subinterpreters.md) (Python 3.14+) for
CPU-bound work — they run in-process, so they parallelize Python across cores on
mobile, where `multiprocessing` cannot.
:::

## How does it work?
Expand Down Expand Up @@ -53,8 +59,9 @@ if __name__ == "__main__":
ft.run(main)
```

With the `spawn` and `forkserver` start methods, worker/helper processes need
to safely import your main module. `spawn` is the default on macOS and Windows;
With the `spawn` and `forkserver`
[start methods](https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods),
worker/helper processes need to safely import your main module. `spawn` is the default on macOS and Windows;
`forkserver` is the default on Linux starting with Python 3.14. Without the
guard, a child process can try to start your whole app again.

Expand Down
Loading
Loading