Cancel Task Group in Python 3.15

Cancel Task Group in Python 3.15

A task group runs several coroutines together and waits for all of them to finish. Sometimes that is more work than necessary. When a group asks several sources for the same thing, the first answer makes the rest pointless, and the remaining tasks should simply stop. Before Python 3.15, the only way out of a group early was to raise an exception.

For example, three mirrors can be queried at once, with the first response ending the group:

import asyncio


class Done(Exception):
    pass


async def fetch(mirror, delay):
    await asyncio.sleep(delay)
    print(f'{mirror} responded')
    raise Done


async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fetch('mirror-a', 0.3))
            tg.create_task(fetch('mirror-b', 0.1))
            tg.create_task(fetch('mirror-c', 0.2))
    except* Done:
        pass
    print('done')


asyncio.run(main())

Output:

mirror-b responded
done

The fastest mirror answers, raises Done, and the group cancels the two slower ones. The behaviour is correct, but it requires three separate mechanisms to handle a non-error situation: a custom exception class, a try block, and an except* clause. The last one is necessary because a task group wraps exceptions raised by its tasks in an ExceptionGroup, which a plain except Done would not catch.

Since Python 3.15, a task group can be stopped directly with its cancel method.

import asyncio


async def fetch(mirror, delay, tg):
    await asyncio.sleep(delay)
    print(f'{mirror} responded')
    tg.cancel()


async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch('mirror-a', 0.3, tg))
        tg.create_task(fetch('mirror-b', 0.1, tg))
        tg.create_task(fetch('mirror-c', 0.2, tg))
    print('done')


asyncio.run(main())

Output:

mirror-b responded
done

The result is the same and the exception class, the try and the except* are gone. The task calls cancel on the group it belongs to, every unfinished task is cancelled, and the async with block exits normally. Nothing is raised, so the code after the block runs as usual, and except* goes back to being needed only for real failures.

Leave a Comment

Cancel reply

Your email address will not be published.