Summary

  • The “sandwich” structure is typical for async code; in general, it looks like:
trio.run -> [async function] -> ... -> [async function] -> trio.whatever
  • It’s exactly the functions on the path between trio.run() and trio.whatever that have to be async.
  • Trio provides the async bread, and then your code makes up the async sandwich’s tasty async filling.
    • Other functions (e.g., helpers you call along the way) should generally be regular, non-async functions.

Example usage

  • Running multiple async functions at the same time
  • This code will run in 1 second.
# tasks-intro.py
 
import trio
 
async def child1():
    print(" child1: started! sleeping now...")
    await trio.sleep(1)
    print(" child1: exiting!")
 
async def child2():
    print(" child2: started! sleeping now...")
    await trio.sleep(1)
    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...")
        # -- we exit the nursery block here --
    print("parent: all done!")
 
trio.run(parent)
  • async with

    • It’s actually pretty simple. In regular Python, a statement like with someobj: ... instructs the interpreter to call someobj.__enter__() at the beginning of the block, and to call someobj.__exit__() at the end of the block. We call someobj a “context manager”.
    • An async with does exactly the same thing, except that where a regular with statement calls regular methods, an async with statement calls async methods: at the start of the block it does await someobj.__aenter__() and at that end of the block it does await someobj.__aexit__(). In this case we call someobj an “async context manager”.
  • nursery object

    • On line 20, we use trio.open_nursery() to get a “nursery” object, and then inside the async with block we call nursery.start_soon twice, on lines 22 and 25.
    • There are actually two ways to call an async function: the first one is the one we already saw, using await async_fn(); the new one is nursery.start_soon(async_fn): it asks Trio to start running this async function, but then returns immediately without waiting for the function to finish.
    • So after our two calls to nursery.start_soon, child1 and child2 are now running in the background. And then at line 28, the commented line, we hit the end of the async with block, and the nursery’s __aexit__ function runs.
    • What this does is force parent to stop here and wait for all the children in the nursery to exit. This is why you have to use async with to get a nursery: it gives us a way to make sure that the child calls can’t run away and get lost.
      • One reason this is important is that if there’s a bug or other problem in one of the children, and it raises an exception, then it lets us propagate that exception into the parent; in many other frameworks, exceptions like this are just discarded. Trio never discards exceptions.

How does it work?

  • Now, if you’re familiar with programming using threads, this might look familiar – and that’s intentional. But it’s important to realize that there are no threads here.

  • All of this is happening in a single thread.

    • To remind ourselves of this, we use slightly different terminology: instead of spawning two “threads”, we say that we spawned two “tasks”.
    • There are two differences between tasks and threads:
      • (1) many tasks can take turns running on a single thread
      • (2) with threads, the Python interpreter/operating system can switch which thread is running whenever they feel like it;
      • with tasks, we can only switch at certain designated places we call “checkpoints”.
  • The interpreter will give the two childs a chance to run

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

>>> about to run one step of task: __main__.child1
  child1: started! sleeping now...
<<< task step finished: __main__.child1
  • Each task runs until it hits the call to trio.sleep(), and then immediately suddenly we’re back in trio.run() deciding what to run next. How does this happen?

    • The secret is that trio.run() and trio.sleep() work together to make it happen: trio.sleep() has access to some special magic that lets it pause itself, so it sends a note to trio.run() requesting to be woken again after 1 second, and then suspends the task.
    • And once the task is suspended, Python gives control back to trio.run(), which decides what to do next.
  • Only async functions have access to the special magic for suspending a task, so only async functions can cause the program to switch to a different task.

  • This is a checkpoint!

  • What this means is that if a call doesn’t have an await on it, then you know that it can’t be a place where your task will be suspended.

    • This makes tasks much easier to reason about than threads, because there are far fewer ways that tasks can be interleaved with each other and stomp on each others’ state.
    • (For example, in Trio a statement like a += 1 is always atomic – even if a is some arbitrarily complicated custom object!) Trio also makes some further guarantees beyond that, but that’s the big one.

Checkpoints

  • When writing code using Trio, it’s very important to understand the concept of a checkpoint. Many of Trio’s functions act as checkpoints.

A checkpoint is two things:

  1. It’s a point where Trio checks for cancellation. For example, if the code that called your function set a timeout, and that timeout has expired, then the next time your function executes a checkpoint Trio will raise a Cancelled exception. See Cancellation and timeouts below for more details.

  2. It’s a point where the Trio scheduler checks its scheduling policy to see if it’s a good time to switch to another task, and potentially does so. (Currently, this check is very simple: the scheduler always switches at every checkpoint. But this might change in the future.)

Since checkpoints are important and ubiquitous, we make it as simple as possible to keep track of them. Here are the rules:

  • Regular (synchronous) functions never contain any checkpoints.

  • If you call an async function provided by Trio (await <something in trio>), and it doesn’t raise an exception, then it always acts as a checkpoint. (If it does raise an exception, it might act as a checkpoint or might not.)

    • This includes async iterators: If you write async for ... in <a trio object>, then there will be at least one checkpoint in each iteration of the loop, and it will still checkpoint if the iterable is empty.
    • Partial exception for async context managers: Both the entry and exit of an async with block are defined as async functions; but for a particular type of async context manager, it’s often the case that only one of them is able to block, which means only that one will act as a checkpoint. This is documented on a case-by-case basis.
      • trio.open_nursery() is a further exception to this rule. Only the exit blocks and is a checkpoint
  • Third-party async functions / iterators / context managers can act as checkpoints; if you see await <something> or one of its friends, then that might be a checkpoint. So to be safe, you should prepare for scheduling or cancellation happening there.

  • The reason we distinguish between Trio functions and other functions is that we can’t make any guarantees about third party code