arthrop0d

Brief articles on varied topics.

Python Asyncio Task Cancellation

pythonconcurrencysoftware engineering

asyncio.Task.cancel() requests that a running coroutine stop by arranging for asyncio.CancelledError to be raised inside it at its next await point. The coroutine can clean up, re-raise the exception, or—deliberately or accidentally—suppress the cancellation.

Why cancellation exists

Async programs routinely need to stop work that is no longer useful: a client disconnected, a request timed out, an application is shutting down, or a newer operation replaced an older one. Without cancellation, every task would have to finish its network request, sleep, retry loop, or other work before the event loop could move on.

Cancellation is therefore cooperative. It does not kill a thread, interrupt arbitrary Python code, or forcibly unwind a task at the instant cancel() is called. The task must reach a point where the event loop can deliver the exception.

That distinction explains why a log containing asyncio.exceptions.CancelledError often appears during normal shutdown. It usually means the program asked a task to stop, not that the task encountered an unexpected application failure.

What Task.cancel() does

Calling cancel() marks a task as having a cancellation request pending. On a later turn of the event loop, asyncio raises CancelledError in the coroutine, normally when it is suspended at an await:

import asyncio

async def poll_server():
    try:
        while True:
            print("polling")
            await asyncio.sleep(10)
    finally:
        print("stop polling")

async def main():
    task = asyncio.create_task(poll_server())
    await asyncio.sleep(0.1)
    task.cancel()

    try:
        await task
    except asyncio.CancelledError:
        print("poller cancelled")

asyncio.run(main())

The await asyncio.sleep(10) is a suspension point. While the task is sleeping, cancel() causes that await to finish by raising CancelledError. The finally block runs, and because the coroutine does not suppress the exception, awaiting task also raises CancelledError.

If a coroutine is executing a long, ordinary Python loop with no awaits, cancellation cannot be delivered until that code yields control:

async def bad_worker():
    for item in huge_in_memory_collection:
        process(item)  # No await: cancellation is delayed here.

This is one reason CPU-heavy work should be broken up with occasional yields or moved to an appropriate executor or process. A yield gives the event loop an opportunity to deliver pending cancellation, although adding arbitrary yields is not a substitute for designing bounded work.

A task can catch the exception. CancelledError derives from BaseException, rather than ordinary Exception, so a normal except Exception clause does not catch it. Explicitly catching it is usually appropriate when cleanup or logging is needed:

async def worker(resource):
    try:
        await use_resource(resource)
    except asyncio.CancelledError:
        await record_interruption(resource)
        raise  # Preserve the cancellation.
    finally:
        await resource.close()

The raise matters. If the coroutine returns normally after catching CancelledError, the cancellation has been suppressed and the task may appear to have completed successfully. task.cancelled() is true only when the task actually finishes because cancellation escaped the coroutine.

Cancellation through awaited tasks

Tasks are often layered: a request task awaits a database task, which awaits a socket operation. Cancellation follows those relationships.

When a task is cancelled while it is awaiting another task or future, asyncio cancels the operation it is currently awaiting. The child then receives its own CancelledError. Once the child finishes unwinding, the parent receives CancelledError at its await as well. If the parent does not catch it, cancellation continues to whoever is awaiting the parent.

async def child():
    try:
        await asyncio.sleep(60)
    finally:
        print("child cleanup")

async def parent():
    child_task = asyncio.create_task(child())
    try:
        await child_task
    finally:
        print("parent cleanup")

If another part of the program calls cancel() on the task running parent, the cancellation reaches child_task because parent is currently awaiting it. Both finally blocks run. This prevents an abandoned child from continuing in the background after its owner has stopped waiting for it.

The reverse is also important: cancelling a child does not forcibly cancel an unrelated parent. The parent sees CancelledError when its await child_task completes, and it can choose whether to propagate, handle, or suppress that event. Suppressing it should be intentional; otherwise the program can report success while required work was abandoned.

Cleanup rules

Put resource release in finally: close connections, return pooled objects, remove temporary state, and release locks. Cleanup must be safe to run when the main operation is only partly complete.

Do not use broad exception handling that turns cancellation into an ordinary success. If cancellation needs special handling, catch asyncio.CancelledError, perform the minimum necessary cleanup, and re-raise it. A cleanup operation can itself contain an await; that allows asynchronous closing, but it also means cancellation may take until cleanup finishes. Keep that work bounded and avoid starting large new operations during shutdown.

Sometimes a critical cleanup action must be allowed to finish even if the surrounding operation is cancelled. asyncio.shield() can protect the inner awaitable from that cancellation, but it does not make the outer coroutine immune: the outer task can still receive CancelledError. Use shielding sparingly and retain a reference to the protected task so it is managed and awaited later.

The practical model is simple: cancel() sends a request, an await point receives CancelledError, awaited work normally receives cancellation too, and finally blocks get the chance to restore invariants. Treat cancellation as a normal control path in every coroutine that owns resources or starts child tasks.

← All articles