pythonasync-awaitpython-trio

In trio, how can I have a background task that lives as long as my object does?


I'm writing a class that will spawn tasks during its lifetime. Since I'm using Trio, I can't spawn tasks without a nursery. My first thought was to have a self._nursery in my class that I can spawn tasks into. But it seems that nursery objects can only be used in a context manager, so they are always closed in the same scope where they were created. I don't want to pass in a nursery from outside because it's an implementation detail, but I do want my objects to be able to spawn tasks that last as long as the object does (e.g. a heartbeat task).

How can I write such a class, which has long-lived background tasks, using Trio?


Solution

  • Excellent question!

    One of Trio's weirdest, most controversial decisions is that it takes the position that the existence of a background task is not an implementation detail, and should be exposed as part of your API. On balance I think this is the right decision, but it's definitely a bit experimental and has some trade-offs.

    Why does Trio do this? In my experience, other systems make it seem like you can abstract away the presence of a background task or thread, but in reality it leaks in all kinds of ways: they end up breaking control-C handling, or they cause problems when you're trying to exit the program cleanly, or they leak when you try to cancel the main operation, or you have sequencing problems because the function you called completed but the work it promised to do is still going on in the background, or the background task crashes with an unexpected exception and then the exception gets lost and all kinds of weird problems ensue... so while it might make your API feel a little messier in the short term, in the long term everything goes easier if you make this explicit.

    Also, keep in mind that everyone else writing and using Trio libraries has the same issue, so your API is not going to feel too weird :-).

    I don't know what you're trying to do exactly. Maybe it's something like a websocket connection, where you want to constantly be reading from the socket to respond to heartbeat ("ping") requests. One pattern would be to do something like:

    @asynccontextmanager
    async def open_websocket(url):
        ws = WebSocket()
        await ws._connect(url)
        try:
            async with trio.open_nursery() as nursery:
                nursery.start_soon(ws._heartbeat_task)
                yield ws
                # Cancel the heartbeat task, since we're about to close the connection
                nursery.cancel_scope.cancel()
        finally:
            await ws.aclose()
    

    And then your users can use it like:

    async with open_websocket("https://...") as ws:
        await ws.send("hello")
        ...
    

    If you want to get fancier, another option would be to provide one version where your users pass in their own nursery, for experts:

    class WebSocket(trio.abc.AsyncResource):
        def __init__(self, nursery, url):
            self._nursery = nursery
            self.url = url
    
        async def connect(self):
            # set up the connection
            ...
            # start the heartbeat task
            self._nursery.start_soon(self._heartbeat_task)
    
        async def aclose(self):
            # you'll need some way to shut down the heartbeat task here
            ...
    

    and then also provide a convenience API, for those who just want one connection and don't want to mess with nurseries:

    @asynccontextmanager
    async def open_websocket(url):
        async with trio.open_nursery() as nursery:
            async with WebSocket(nursery, url) as ws:
                await ws.connect()
                yield ws
    

    The main advantage of the pass-in-a-nursery approach is that if your users want to open lots of websocket connections, an arbitrary number of websocket connections, then they can open one nursery once at the top of their websocket management code, and then have lots of websockets inside it.

    You're probably wondering, though: where do you find this @asynccontextmanager? Well, it's included in the stdlib in 3.7, but that's not even out yet, so depending on when you're reading this you might not be using it yet. Until then, the async_generator package gives you @asynccontextmanager all the way back to 3.5.