Zalgorithm

The Trio Python async concurrency library

Following along with https://trio.readthedocs.io/en/stable/tutorial.html . This is not a tutorial.

Trio is a Python library for writing asynchronous applications — programs that want to do multiple things at the same time with parallelized I/O, web spiders, web servers, etc.1

The tutorial is at a high level.

Async functions #

An async function is defined like a normal function, except with async def instead of def:

def regular_double(x):
    return 2 * x

async def async_double(x):
    return 2 * x

To call an async function you have to use the await keyword. You can only use the await keyword inside of async functions.

How is an application’s first async function called? #

When a program starts, it’s running regular synchronous code. How is the first async function called?

Trio, and it seems other libraries, use a special synchronous run function. trio.run() takes and calls an asynchronous function:

import trio


async def async_double_print(x):
    print(2 * x)


def main():
    trio.run(async_double_print, 3)


if __name__ == "__main__":
    main()

How Python interprets calls to await #

Python interprets await trio.sleep(3) as two things. The trio.sleep(3) part is a coroutine object:

In [1]: import trio

In [2]: trio.sleep(3)
Out[2]: <coroutine object sleep at 0x7fe41dc85220>

The coroutine gets passed to await. await runs the coroutine.

Running multiple async functions at the same time #

import trio


async def child1():
    print("    child1 started. sleeping now...")
    await trio.sleep(3)
    print("child1 exiting")


async def child2():
    print("    child2 started. sleeping now...")
    await trio.sleep(3)
    print("child2 exiting")


async def parent():
    print("parent started")
    async with trio.open_nursery() as nursery:
        print("parent spawning child1...")
        nursery.start_soon(child1)

        print("parent spawning child2...")
        nursery.start_soon(child2)

        print("parent waiting for children to finish")
    print("parent done")


def main():
    trio.run(parent)  # note that main doesn't have to be async


if __name__ == "__main__":
    main()

The async with trio.open_nursery() creates an “async context manager”.

Python context managers #

In Python, the statement with someobj: instructs the interpreter to call someobj.__enter__() at the beginning of the block, and someobj.__exit__() at the end of the block.

For async code: async with someobj calls await someobj.__aenter__() and await someobj.__aexit__().

Note that Python also has async for...

There’s a related note at notes / Running coroutines in Python

nursery.start_soon #

From googling, it seems that trio.nursery.start_soon() is similar to asyncio.TaskGroup.create_task().

multiple async functions can run at (seemingly) the same time #

The output of the code above is completed in 3 seconds, not 6 seconds. The child1 and child2 functions are run at (close to) the same time.

This is similar to what can be done with threads, but it’s done within a single thread.

In async programming, instead of spawning threads the code is spawning tasks.

The difference between threads and tasks #

Task switching at designated checkpoints #

Async/await can be used to run lots of tasks simultaneously on a single thread. For example, on a web server one task could be sending an HTTP response at the same time as another task is waiting for new connections.

This is done by switching between tasks at appropriate places.

My sense is that task switching is generally handled by libraries like asyncio or Trio.

Trio has an Instrument API that can be used to get a high level idea of what’s going on. Here’s a Tracer class that implements the Instrument interface:

import trio


class Tracer(trio.abc.Instrument):
    def before_run(self):
        print("!!! run started")

    def _print_with_task(self, msg, task):
        # repr(task) could be used here (it's more noisy)
        print(f"{msg}: {task.name}")

    def task_spawned(self, task):
        self._print_with_task("### new task spawned", task)

    def task_scheduled(self, task):
        self._print_with_task("### new task scheduled", task)

    def before_task_step(self, task):
        self._print_with_task(">>> about to run one step of task", task)

    def after_task_step(self, task):
        self._print_with_task("<<< task step finished", task)

    def task_exited(self, task):
        self._print_with_task("### task exited", task)

    def before_io_wait(self, timeout):
        if timeout:
            print(f"### waiting for I/O for up to {timeout} seconds")
        else:
            print("### doing a quick check for I/O")
        self._sleep_time = trio.current_time()

    def after_io_wait(self, timeout):
        duration = trio.current_time() - self._sleep_time
        print(f"### finished I/O check (took {duration}) seconds")

    def after_run(self):
        print("!!! run finished")

A Tracer() instance can be passed to a call to trio.run():

async def child1():
    print("    child1 started. sleeping now...")
    await trio.sleep(3)
    print("child1 exiting")


async def child2():
    print("    child2 started. sleeping now...")
    await trio.sleep(3)
    print("child2 exiting")


async def parent():
    print("parent started")
    async with trio.open_nursery() as nursery:
        print("parent spawning child1...")
        nursery.start_soon(child1)

        print("parent spawning child2...")
        nursery.start_soon(child2)

        print("parent waiting for children to finish")
    print("parent done")


def main():
    trio.run(parent, instruments=[Tracer()])


if __name__ == "__main__":
    main()

Ignoring the initial Tracer() output, first a task is created and scheduled for the parent() function:

>>> about to run one step of task: <init>
### new task spawned: __main__.parent
### new task scheduled: __main__.parent
### new task spawned: <TrioToken.run_sync_soon task>
### new task scheduled: <TrioToken.run_sync_soon task>
<<< task step finished: <init>
### doing a quick check for I/O
### finished I/O check (took 3.141991328448057e-06) seconds
>>> about to run one step of task: __main__.parent
parent started

parent() then spawns its two child tasks. It pauses at the end of its async with block:

parent spawning child1...
### new task spawned: __main__.child1
### new task scheduled: __main__.child1
parent spawning child2...
### new task spawned: __main__.child2
### new task scheduled: __main__.child2
parent waiting for children to finish
<<< task step finished: __main__.parent

After the async with block, control goes back to trio.run():

>>> about to run one step of task: <TrioToken.run_sync_soon task>
<<< task step finished: <TrioToken.run_sync_soon task>
### doing a quick check for I/O
### finished I/O check (took 2.607994247227907e-06) seconds

Then the two child tasks are given the chance to run:

>>> about to run one step of task: __main__.child1
    child1 started. sleeping now...
<<< task step finished: __main__.child1
>>> about to run one step of task: __main__.child2
    child2 started. sleeping now...
<<< task step finished: __main__.child2

Each of the child tasks run until they hit the call to trio.sleep(), then control goes back to trio.run() so that trio.run() can decide what to run next.

This task switching works because trio.sleep() “has access to some special magic” that lets it pause itself and send “a note to trio.run()” requesting that it be woken after the sleep duration. The trio.sleep() task then suspends itself (that’s my imprecise understanding.) Python then gives control back to trio.run().

This is similar to how generators can suspend execution with yield (there’s overlap between the async and generator implementations).

Only async functions have access to the “special magic” for suspending tasks.

This execution switching would be relevant in a program where run() had some other work to do during the time that the tasks are suspended, for example, deal with some I/O operation.

Note that the calls to trio.sleep() are contrived. The “special magic” that it’s using isn’t clear to me. For example, what if instead of trio.sleep() the child functions were listing for requests to an HTTP route?

Further reading #

I’ll add these references here instead of to the references section. They are recommended in the Trio tutorial:

References #

python-trio.trio. “Trio.” Accessed on: February 4, 2026. https://github.com/python-trio/trio .

trio.readthedocs.io. “Tutorial.” Accessed on: February 4, 2026. https://trio.readthedocs.io/en/stable/tutorial.html .


  1. trio.readthedocs.io, “Tutorial,” Accessed on: February 4, 2026, https://trio.readthedocs.io/en/stable/tutorial.html↩︎