Is Python Asyncio's Documentation Misleading About KeyboardInterrupt?

When working with Python's asyncio module, handling graceful shutdowns via Ctrl+C (SIGINT) can sometimes lead to surprising behavior. A common source of confusion arises when comparing the official asyncio documentation with the actual execution order of tasks during a KeyboardInterrupt.

According to the Python documentation for asyncio.Runner and asyncio.run():

When signal.SIGINT is raised by Ctrl-C, the custom signal handler cancels the main task by calling asyncio.Task.cancel() which raises asyncio.CancelledError inside the main task... After the main task is cancelled, asyncio.Runner.run() raises KeyboardInterrupt.

However, if you spawn background tasks using asyncio.create_task(), you might observe child tasks being cancelled before—and even after—the main task completes its cleanup blocks. Does this mean the documentation is wrong, or is there more going on under the hood?

The Experiment: Expectation vs. Reality

Consider the following asynchronous program running on Python 3.11 or later:

import asyncio

async def coro(id):
    try:
        while True:
            print(f'coro {id} doing something')
            await asyncio.sleep(1)
    except asyncio.CancelledError:
        print(f'coro {id} cancelled')
        raise

async def main():
    try:
        tasks = [asyncio.create_task(coro(i), name=f"task {i}") for i in range(3)]
        for task in tasks:
            try:
                await task
            except asyncio.CancelledError:
                print(f"task {task.get_name()} - cancelled")
                raise
    except asyncio.CancelledError:
        print('main cancelled')
    finally:
        print('done main')

asyncio.run(main())

When pressing Ctrl+C while this script runs, you might expect main() to instantly catch the cancellation and exit. Instead, the console output looks similar to this:

coro 0 doing something
coro 1 doing something
coro 2 doing something
^Ccoro 0 cancelled
task task 0 - cancelled
main cancelled
done main
coro 1 cancelled
coro 2 cancelled

Why did coro 0 get cancelled first? And why did coro 1 and coro 2 get cancelled after done main was printed?

Under the Hood: Why This Happens

The documentation is technically accurate, but it omits key details regarding cancellation propagation and event loop teardown.

1. Cancellation Propagation Down the Await Chain

When you press Ctrl+C, asyncio's signal handler indeed calls main_task.cancel(). At that exact moment, the main task is paused at await task (specifically task 0).

In Python 3.11+, when a task awaiting another task receives a cancellation request, that cancellation propagates down to the task currently being awaited. Thus, task 0 receives the CancelledError while inside asyncio.sleep(1). coro 0 catches it, prints coro 0 cancelled, and re-raises it up to main().

2. The Unhandled Background Tasks

Notice that task 1 and task 2 were created with asyncio.create_task() but were never awaited inside main() because main() exited early due to the exception re-raised from task 0.

When main() finishes its finally block and prints done main, control returns to asyncio.run(). Before asyncio.run() raises the final KeyboardInterrupt exception, it performs a mandatory cleanup step: cancelling all remaining active tasks on the event loop (_cancel_all_tasks).

This teardown phase is what cancels task 1 and task 2, causing coro 1 cancelled and coro 2 cancelled to appear at the very end of execution.

Modern Best Practices for Task Cleanup

Relying on manual for task in tasks: await task loops can easily lead to leaked tasks or confusing shutdown sequences when exceptions occur. Here are two modern, robust approaches to manage task lifecycles safely.

Approach 1: Use asyncio.TaskGroup (Python 3.11+)

Python 3.11 introduced Structured Concurrency via asyncio.TaskGroup. If any child task or the parent task is cancelled or fails, the TaskGroup automatically cancels all other running tasks within the group before exiting.

import asyncio

async def coro(id):
    try:
        while True:
            print(f'coro {id} doing something')
            await asyncio.sleep(1)
    except asyncio.CancelledError:
        print(f'coro {id} cancelled')
        raise

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            for i in range(3):
                tg.create_task(coro(i), name=f"task {i}")
    except ExceptionGroup as eg:
        print("Caught exception group during shutdown")
    finally:
        print('done main')

try:
    asyncio.run(main())
except KeyboardInterrupt:
    print("Program interrupted by user.")

Approach 2: Clean Up Tasks in `finally` Blocks

If you are using Python 3.10 or earlier, ensure you explicitly cancel and gather pending tasks inside a finally block or try/except handler:

async def main():
    tasks = [asyncio.create_task(coro(i)) for i in range(3)]
    try:
        await asyncio.gather(*tasks)
    except asyncio.CancelledError:
        print("main cancelled, cancelling child tasks...")
    finally:
        for task in tasks:
            task.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)
        print("done main")

Key Takeaways

  • The doc isn't wrong, just concise: SIGINT cancels the main task, but awaiting child tasks propagates that cancellation down the stack.
  • Event loop teardown cancels pending tasks: Any background tasks left running when `main()` exits are cancelled during `asyncio.run()`'s final cleanup step.
  • Use Structured Concurrency: Prefer `asyncio.TaskGroup` in modern Python to ensure all tasks are cleanly cancelled together when an interrupt occurs.