6.7 KiB
Parallelism and worker pools
The initial ask is almost always the same: "how do i make a worker pool?" — i.e. the thing multiprocessing and concurrent.futures.ProcessPoolExecutor get reached for once the GIL becomes the enemy.
Here's the structured concurrency (SC) answer: tractor is built to handle any SC process tree you can imagine; a "worker pool" pattern is a trivial special case. So instead of shipping a pool class with knobs bolted on, you compose one from the same two ingredients used everywhere else in tractor: an actor nursery and some IPC.
The stdlib baseline
For a fair comparison, start from the canonical ~concurrent.futures.ProcessPoolExecutor primes example straight out of the Python docs,
../../examples/parallelism/concurrent_futures_primes.py
Synchronous code, a hidden thread + IPC machine under the hood, and an API surface (executors, futures, .map()) invented to paper over the fact that the pool isn't part of your program's task tree. Keep an eye on three things for the rewrite: how work is submitted, how results come back, and what happens when a worker dies.
The tractor way
Now the same workload as a tractor program,
../../examples/parallelism/concurrent_actors_primes.py
What's different (and what isn't),
worker_pool()is ~30 lines of your code: an actor nursery spawningworkerssubactors — each a full process running its owntriotask tree — kept alive and ready for work until the block exits;enable_modules=[__name__]is the capability allowlist letting them run this module's functions,- jobs are "submitted" by just... calling the function:
portal.run(is_prime, n=value)runsis_prime()in a worker and hands back its result like any localawait, - results stream back through a plain
trio.open_memory_channelas they complete — no futures and no polling, - teardown is one
await tn.cancel()(tractor.ActorNursery.cancel), and any worker crash triggers the one-cancels-all machinery from/guide/cancellation— a dead worker can never strand the pool.
This uses no extra threads, fancy semaphores or futures; all we need is tractor's IPC! The full scorecard,
concurrent.futures |
tractor |
|---|---|
ProcessPoolExecutor() |
worker_pool() — yours, ~30 lines |
executor.map(is_prime, PRIMES) |
actor_map(is_prime, PRIMES) async-gen |
Future + internal result queue |
trio.open_memory_channel |
| results in input order | results as they complete |
worker crash -> BrokenProcessPool |
boxed tractor.RemoteActorError |
pool teardown on with exit |
one-cancels-all nursery teardown |
How many workers?
Same calculus as any process pool: about core-count for CPU-bound work (the default sizing in /guide/clustering); more only if workers block on I/O — though at that point you likely want plain trio tasks, not processes.
And because the pool is just SC code, every variation — bounded submission, per-worker state, streaming partial results (see /guide/streaming), nested pools — is a local edit to your pool, not a feature request against an executor class B)
An async pool, though?
Yep: RPC targets must be async functions — the runtime rejects a plain def with TypeError: ... must be an async function!. That's not zealotry, it's cancel-responsiveness: each worker is a full trio runtime whose msg loop is what hears graceful cancel requests, and a hot loop that never yields can't be (politely) interrupted.
Two practical consequences,
- CPU-bound loops should checkpoint once in a while; note how
burn_cpu()in the next example sprinklesawait trio.sleep()calls so the worker stays responsive while still pegging a core, - if some sync call blocks a worker anyway you're still covered: an unresponsive actor just rides the graceful-then-hard teardown ladder from
/guide/cancellationinstead of acking its cancel — slower, but never a zombie.
Run a func in a process
Even a pool can be overkill; "run this one async func in a subprocess and give me the result" is a one-liner via tractor.ActorNursery.run_in_actor,
../../examples/parallelism/single_func.py
run_in_actor() is a convenience wrapper — spawn an actor, run exactly one task in it, reap on result — not the core spawning model (that's tractor.ActorNursery.start_actor plus tractor.Portal.open_context; see /guide/context). But for this fire-and-collect shape it's exactly the right amount of typing.
As the module docstring suggests, run it under a process-tree monitor to watch the child appear and get reaped,
$TERM -e watch -n 0.1 "pstree -a $$" \
& python examples/parallelism/single_func.py \
&& kill $!You'll see a core get burned in both parent and child — real parallelism, no GIL sharing, since these are processes (i.e. non-shared-memory threads).
When all you have is sync code
Honesty corner: if your workload is purely synchronous functions and you've zero need for IPC dialogs, streaming, daemons or supervision trees — i.e. you really do just want "ProcessPoolExecutor but trio-native" — the smaller, focused trio-parallel project may serve you better. tractor happily covers the use case (as above) but brings a whole runtime along for the ride. (And when blocking I/O — not the GIL — is the actual problem, plain in-process trio.to_thread.run_sync may be all you ever needed.)
And to see that runtime's process-management story — a per-core fleet self-destructing with zero zombies left behind — go run examples/parallelism/we_are_processes.py, walked through in the /start/quickstart.
/guide/clustering— the one-liner flat-cluster convenience (open_actor_cluster()) for when even a hand-rolled pool is too much typing,/guide/cancellation— why pool teardown is bulletproof (graceful-then-hard escalation, no zombies),/guide/context— the core per-task API your pool workers can graduate to.