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.

a supervision tree of actor processes

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:

examples/actor_spawning_and_causality.py#
import trio
import tractor


async def cellar_door():
    assert not tractor.is_root_process()
    return "Dang that's beautiful"


async def main():
    """The main ``tractor`` routine.
    """
    async with tractor.open_nursery() as n:

        portal = await n.run_in_actor(
            cellar_door,
            name='some_linguist',
        )

    # The ``async with`` will unblock here since the 'some_linguist'
    # actor has completed its main task ``cellar_door``.

    print(await portal.wait_for_result())


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; the tractor runtime boots implicitly inside tractor.open_nursery() whenever it isn’t already up. No special entrypoint, no framework takeover - it’s just a trio app,

  • inside main() a subactor is spawned via ActorNursery.run_in_actor() and told to run exactly one function: cellar_door(),

  • you get back a Portal: your handle for invoking tasks in the new process’s (separate!) memory domain. We lean on it much harder in the next section,

  • the subactor, some_linguist, boots a fresh trio.run() in a new process and executes cellar_door() as its main task (note the child proving it is not the root with tractor.is_root_process()), then ships the return value back over IPC,

  • the parent grabs that final result with await portal.wait_for_result(), much like you’d expect from a “future” - except causality is preserved: the nursery block only exits once the child is done, dead, and reaped.

Note

run_in_actor() 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 ActorNursery.start_actor() (next up) paired with Portal.open_context() for full, SC-linked cross-actor dialogs - see The Context: a cross-actor task pair.

Daemon actors and RPC#

A run_in_actor()-spawned actor terminates when its main 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():

examples/actor_spawning_and_causality_with_daemon.py#
import trio
import tractor


async def movie_theatre_question():
    """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():
    """The main ``tractor`` routine.
    """
    async with tractor.open_nursery() as n:

        portal = await n.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 block here indefinitely waiting
        # for our actor "frank" to complete, but since it's an
        # "outlive_main" actor it will never end until cancelled
        await portal.cancel_actor()


if __name__ == '__main__':
    trio.run(main)

Two lifetime rules to internalize:

  • a run_in_actor() actor lives exactly as long as its main task; the nursery waits for that function (and thus the process) to complete before unblocking,

  • a start_actor() actor lives forever - an RPC daemon the nursery will happily wait on indefinitely - until some task explicitly cancels it via Portal.cancel_actor() (as above), or its parent nursery is cancelled wholesale.

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 $!
examples/parallelism/we_are_processes.py#
'''
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 spawn_and_open_ep(
    an: tractor.ActorNursery,
    i: int,
) -> None:
    '''
    Spawn a subactor, start a remote `endpoint()`-task in it.

    '''
    ptl: tractor.Portal = await an.start_actor(
        name=f'worker_{i}',
        enable_modules=[__name__],
    )
    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 main():
    '''
    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,
        # 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.
        trio.open_nursery() as tn,
    ):
        for i in range(cpu_count()):
            tn.start_soon(
                spawn_and_open_ep,
                an,
                i,
            )
        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:

examples/a_trynamic_first_scene.py#
import trio
import tractor

_this_module = __name__
the_line = 'Hi my name is {}'


tractor.log.get_console_log("INFO")


async def hi():
    return the_line.format(tractor.current_actor().name)


async def say_hello(other_actor):
    async with tractor.wait_for_actor(other_actor) as portal:
        return await portal.run(hi)


async def main():
    """Main tractor entry point, the "master" process (for now
    acts as the "director").
    """
    async with tractor.open_nursery() as n:
        print("Alright... Action!")

        donny = await n.run_in_actor(
            say_hello,
            name='donny',
            # arguments are always named
            other_actor='gretchen',
        )
        gretchen = await n.run_in_actor(
            say_hello,
            name='gretchen',
            other_actor='donny',
        )
        print(await gretchen.wait_for_result())
        print(await donny.wait_for_result())
        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:

  • two subactors, donny and gretchen, are each told to run say_hello() targeting the other by name,

  • tractor.wait_for_actor() blocks until the named peer has registered with the tree’s registrar (every actor announces itself at boot), then yields a Portal connected directly to that peer,

  • each actor invokes its partner’s hi() over that portal: actor-to-actor RPC with the root merely directing - and both final lines flow back to main() via await portal.wait_for_result(),

  • 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,