Quickstart#
Time to spawn something B)
If you take one thing from this page make it this: tractor
is just trio - but with nurseries for process management
and cancel-able streaming IPC. Every “actor” you’ll meet below
is a plain Python process running its own trio.run()
scheduled task tree, linked back to its parent through an IPC
protocol which keeps the whole tree structured concurrency
(SC) compliant end-to-end. If you know your nursery semantics
you already know most of tractor; we just stretch them across
the process boundary.
every arrow is a parent which must wait on its kids#
Your first actor tree#
trio takes the hard-line position that a parent task must
wait on the children it spawns; causality is paramount! So
does tractor, one abstraction layer up:
tractor.open_nursery() yields an ActorNursery which
must wait on its spawned subactors to complete (or error)
before the async with block exits, in the same causal way a
trio nursery waits on its subtasks. That includes any one
child’s crash cancelling all of its siblings: one-cancels-all
supervision, exactly like trio.
Enough preamble, spawn a process:
import trio
import tractor
async def cellar_door() -> str:
'''
Return a phrase from a spawned actor.
'''
assert not tractor.is_root_process()
return 'Dang that\'s beautiful'
async def main() -> None:
'''
The main ``tractor`` routine.
'''
# spawn a subactor, run ``cellar_door()`` as its lone task,
# block until its result arrives and the subactor is reaped.
print(
await tractor.to_actor.run(
cellar_door,
name='some_linguist',
)
)
if __name__ == '__main__':
trio.run(main)
Run it:
$ python examples/actor_spawning_and_causality.py
Dang that's beautiful
What’s going on here?
trio.run(main)starts the root actor; thetractorruntime boots implicitly inside thistractor.to_actor.run()call because neitheran=norportal=was supplied. No special entrypoint, no framework takeover - it’s just atrioapp,inside
main()a subactor is spawned viatractor.to_actor.run()and told to run exactly one function:cellar_door(),the subactor, some_linguist, boots a fresh
trio.run()in a new process and executescellar_door()as its linked one-shot task (note the child proving it is not the root withtractor.is_root_process()), then ships the return value back over IPC,the call blocks until that final result arrives, then returns it - causality is preserved: your task only proceeds once the child is done, dead, and reaped.
Note
Without portal=, to_actor.run() (parlance of
trio.to_thread and friends) is the convenience wrapper:
one-shot spawn-run-reap semantics for when a subactor’s entire
job is a single function call. The core primitives are
start_actor() (next up) — which
hands you a Portal, your handle for invoking tasks in the
new process’s (separate!) memory domain — paired with
open_context() for full, SC-linked
cross-actor dialogs; see The Context: a cross-actor task pair.
Daemon actors and RPC#
A subactor spawned by to_actor.run() terminates after its lone
task returns. But often you want long-lived daemon actors instead:
spawned once, then serving (allowlisted) RPC requests until told
otherwise. That’s start_actor():
import trio
import tractor
async def movie_theatre_question() -> str:
'''
A question asked in a dark theatre, in a tangent
(errr, I mean different) process.
'''
return 'have you ever seen a portal?'
async def main() -> None:
'''
The main ``tractor`` routine.
'''
an: tractor.ActorNursery
async with tractor.open_nursery() as an:
portal: tractor.Portal = await an.start_actor(
'frank',
# enable the actor to run funcs from this current module
enable_modules=[__name__],
)
print(await portal.run(movie_theatre_question))
# call the subactor a 2nd time
print(await portal.run(movie_theatre_question))
# the async with will wait indefinitely for "frank" because
# its runtime remains active until explicitly cancelled
await portal.cancel_actor()
if __name__ == '__main__':
trio.run(main)
Two lifetime rules to internalize:
a subactor spawned and owned by
to_actor.run()is cancelled and reaped before the call returns its result or raises its error,a
start_actor()actor lives forever - an RPC daemon the nursery will happily wait on indefinitely - until some task explicitly cancels it viaPortal.cancel_actor()(as above), or its parent nursery is cancelled wholesale.
Passing portal= is different: the call owns only the linked
remote task. It neither spawns nor reaps the existing actor; the
portal’s owner must end that actor’s lifetime.
Tip
Want your entire program to just be a long-lived RPC
daemon? tractor.run_daemon() is the blocking shorthand:
it trio.run()s a root actor which serves requests until
cancelled.
The enable_modules=[__name__] kwarg is the other thing to
notice: it lists the module paths the subactor will load and
expose for remote invocation.
await portal.run(movie_theatre_question) works because this
very module is in that allowlist (and note we call it twice; the
daemon happily serves repeat requests). Ask for a function from
any module not enabled and you’re denied with a
ModuleNotExposed error: a simple, capability-style
restriction mechanism built on Python’s own module system.
We are processes#
Why processes (and not, say, threads)? Python has a GIL and an actor model by definition shares nothing between its concurrent units, so real OS processes are the natural fit: you get all your cores locally, and since actors only ever talk via IPC, the exact same code distributes over multiple hosts without modification.
Of course, the moment you hear “process trees” you should be
asking: what about zombies? Watch tractor eat one for
breakfast - run this while monitoring your process tree:
$TERM -e watch -n 0.1 "pstree -a $$" \
& python examples/parallelism/we_are_processes.py \
&& kill $!
'''
Run with a process monitor from a terminal using::
$TERM -e watch -n 0.1 "pstree -a $$" \
& python examples/parallelism/we_are_processes.py \
&& kill $!
'''
from multiprocessing import cpu_count
import os
import tractor
import trio
@tractor.context
async def endpoint(
ctx: tractor.Context,
):
actor_name: str = tractor.current_actor().name
pid: int = os.getpid()
await ctx.started((actor_name, pid))
await trio.sleep_forever()
async def open_ep(
ptl: tractor.Portal,
i: int,
) -> None:
ctx: tractor.Context
async with ptl.open_context(endpoint) as (
ctx,
(sub_name, sub_pid),
):
print(
f'Started ep-task in subactor,\n'
f'{i}::{sub_name!r}@{sub_pid}\n'
)
await ctx.wait_for_result()
async def spawn_and_open_ep(
an: tractor.ActorNursery,
i: int,
maybe_ptl: tractor.Portal|None = None,
) -> None:
'''
Spawn a subactor, start a remote `endpoint()`-task in it.
'''
if maybe_ptl is None:
maybe_ptl: tractor.Portal = await an.start_actor(
name=f'worker_{i}',
enable_modules=[__name__],
)
await open_ep(
ptl=maybe_ptl,
i=i,
)
async def main(
# spawn subs concurrently (in bg `trio.Task`s) so each
# actor's cold `import tractor` (~0.4s, see #470) overlaps
# instead of stacking; once forkserver (#463) lands, spawn
# is cheap enough to just loop sequentially.
spawn_subs_in_bg_tasks: bool = True,
):
'''
Spawn a subactor-per-CPU then self-destruct the cluster.
'''
tn: trio.Nursery
an: tractor.ActorNursery
async with (
tractor.open_nursery(
# XXX coming soon!
# https://github.com/goodboy/tractor/pull/463
# start_method='main_thread_forkserver',
) as an,
trio.open_nursery() as tn,
):
for i in range(cpu_count()):
maybe_ptl: tractor.Portal|None = None
if not spawn_subs_in_bg_tasks:
maybe_ptl: tractor.Portal = await an.start_actor(
name=f'worker_{i}',
enable_modules=[__name__],
)
tn.start_soon(
spawn_and_open_ep,
an,
i,
maybe_ptl,
)
destruct_in: int = 2
print(
f'This tree will self-destruct in {destruct_in}s..\n'
)
await trio.sleep(destruct_in)
raise Exception('Self Destructed')
if __name__ == '__main__':
try:
trio.run(main)
except Exception:
print('Zombies Contained')
You’ll see something like (one subactor per core - 24 on this box, trimmed here):
$ python examples/parallelism/we_are_processes.py
This tree will self-destruct in 2s..
Started ep-task in subactor,
0::'worker_0'@218140
Started ep-task in subactor,
2::'worker_2'@218134
Started ep-task in subactor,
1::'worker_1'@218137
Started ep-task in subactor,
3::'worker_3'@218132
Zombies Contained
(The Started ep-task lines land in whatever order the OS
schedules them; they’re separate processes, racing, and that’s
the point.)
One subactor is spawned per core - concurrently, from background
trio tasks, so each child’s cold import tractor overlaps
instead of stacking. Each runs a @tractor.context
endpoint() that ctx.started()-hands its name and pid back
through Portal.open_context() (those Started ep-task
lines), then parks in trio.sleep_forever(). Then the root
crashes on purpose and the ActorNursery responds with hard
trio discipline: every child is cancelled, every process is
reaped, the error propagates to trio.run(), and your terminal
prints Zombies Contained. No orphans, no kill -9
archaeology in htop afterwards.
Note
The zombie-safety guarantee: tractor tries to protect
you from zombies, no matter what. If you can create zombie
child processes (without using a system signal) it is a
bug - please report it so we can hunt it down.
A trynamic first scene#
So far the root actor has done all the talking, but subactors can just as well discover and call each other. Let’s direct a couple actors and have them run their lines for the hip new film we’re shooting:
import trio
import tractor
_this_module: str = __name__
the_line: str = 'Hi my name is {}'
tractor.log.get_console_log('INFO')
async def hi() -> str:
'''
Return a greeting naming the current actor.
'''
return the_line.format(tractor.current_actor().name)
async def say_hello(other_actor: str) -> str:
'''
Ask another actor to return its greeting.
'''
portal: tractor.Portal
async with tractor.wait_for_actor(other_actor) as portal:
return await portal.run(hi)
async def main() -> None:
'''
Main tractor entry point, the "master" process (for now
acts as the "director").
'''
an: tractor.ActorNursery
async with tractor.open_nursery() as an:
print('Alright... Action!')
# both actors wait on (then dial!) the *other*, so each
# must outlive both hellos: spawn as daemons, run the
# hellos concurrently, reap only once both complete.
portals: dict[str, tractor.Portal] = {
name: await an.start_actor(
name,
enable_modules=[__name__],
)
for name in ('donny', 'gretchen')
}
async def run_and_print(
name: str,
other_actor: str,
) -> None:
'''
Print a greeting fetched through a named actor.
'''
print(
# RPC through an existing actor's `Portal`.
await portals[name].run(
say_hello,
other_actor=other_actor,
)
)
tn: trio.Nursery
async with trio.open_nursery() as tn:
tn.start_soon(run_and_print, 'donny', 'gretchen')
tn.start_soon(run_and_print, 'gretchen', 'donny')
await an.cancel()
print('CUTTTT CUUTT CUT!!! Donny!! You\'re supposed to say...')
if __name__ == '__main__':
trio.run(main)
The script of the scene (runtime INFO log lines trimmed):
$ python examples/a_trynamic_first_scene.py
Alright... Action!
Hi my name is gretchen
Hi my name is donny
CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...
The new tricks in play:
donny and gretchen start as daemon actors so each remains alive while the other discovers it and completes its line,
a local
trionursery runs bothPortal.run(say_hello)calls concurrently; starting both actors first avoids either reciprocal dialog racing one-shot process reaping,tractor.wait_for_actor()blocks until the named peer has registered with the tree’s registrar (every actor announces itself at boot), then yields aPortalconnected directly to that peer,each actor invokes its partner’s
hi()over that portal: actor-to-actor RPC with the root merely directing - and eachPortal.run()returns its final line directly tomain(),the actor nursery explicitly cancels both daemons only after both dialogs complete,
tractor.log.get_console_log("INFO")cranks up runtime logging so you can watch the spawn/register/cancel machinery narrate itself; remove it for a quiet set.
Cross-actor calls look just like (async) function calls; there are no proxy objects and no shared references, only messages B)
Crash handling, native feeling#
One last teaser before the guide proper. Flip exactly one switch:
async with tractor.open_nursery(
debug_mode=True,
) as an:
...
and any crash, in any actor at any depth of the tree, drops
your terminal into a multi-process-safe pdbp REPL at the
offending frame, with the rest of the tree held back from
clobbering the tty. await tractor.pause() likewise gives you
a breakpoint that just works inside subprocesses. We think it
might be the first native multi-process debugging UX for Python;
get the full tour in “Native” multi-process debugging.
Where to next?#
You can now boot a runtime, spawn one-shot and daemon actors, make cross-process RPC calls, and contain zombies: that’s the on-ramp done. The guide takes each subsystem deeper,
Structured concurrency, across processes - the structured concurrency worldview and how
tractorextends it across processes,Spawning actors - everything
ActorNursery: spawn kwargs, lifetimes and supervision semantics,RPC: calling into other actors - the
Portalin depth: calling into another actor’s memory domain,The Context: a cross-actor task pair - the core API:
@tractor.contextendpoints, thectx.started()handshake, and SC-linked cross-actor task pairs,Cross-process streaming - bidirectional
MsgStreamdialogs and fan-out broadcasting,“Native” multi-process debugging - the multi-process REPL, crash handling mode, and
tractor.pause(),Infected asyncio - “infected
asyncio” mode: SC supervision wrapped aroundasynciotasks,Actor discovery - registries, service daemons, and finding actors from anywhere in (or out of) the tree.