Skip to content
← All Python articles

Python explainer

Cancel a Task and Clean Up After It

About 4 min to read. Allow extra time to work through examples.

Cancel a task, make its cleanup run even when it is cancelled, fix a task that ignores cancel(), and protect work that must finish with shield.

This article uses Python. You can make it your practice mode to browse related content across the site.

Your server is shutting down while a worker task is halfway through a slow download. The worker holds a database connection. You want it to stop, give the connection back, and let the server exit. In asyncio you ask a task to stop with task.cancel():

import asyncio


async def worker() -> None:
    print("worker: started")
    try:
        await asyncio.sleep(10)  # stands in for a slow download
    finally:
        print("worker: cleaning up")


async def main() -> None:
    task = asyncio.create_task(worker())
    await asyncio.sleep(0.1)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("main: worker was cancelled")


asyncio.run(main())
worker: started
worker: cleaning up
main: worker was cancelled

Cancel asks, and the task stops at an await

Cancellation means asking a task to stop. task.cancel() only marks the task. The next time the task pauses at an await, asyncio raises asyncio.CancelledError there. The error propagates like any other exception, so finally blocks and with blocks run before the task ends. Code that awaits the task then gets CancelledError too.

CancelledError inherits from BaseException directly, so an ordinary except Exception: handler does not catch it. That is what you want.

Find out why a task keeps running after cancel()

When cancel() seems to do nothing, the cause is almost always one of two things.

The first is a task stuck in blocking code. A loop doing heavy computing, or a blocking call such as time.sleep() or requests.get(), blocks the event loop until it finishes. In that time the task reaches no await, so asyncio has nowhere to raise CancelledError. Use an async version of the call, or run the blocking call in a thread, which a later article shows.

The second is a task that catches the error and keeps running:

async def worker() -> None:
    while True:
        try:
            await fetch_next()
        except BaseException:  # this also catches CancelledError
            log.exception("fetch failed")

Each cancel() raises CancelledError inside fetch_next(). The handler logs it, and the loop calls fetch_next() again. Any code that waits for this task waits forever. Catch Exception instead. If you catch CancelledError to clean up, end the handler with a bare raise.

Cleanup can await

A cancelled task can still await in its finally block. That is how you close a connection or flush a buffer:

try:
    await download(conn)
finally:
    await conn.close()

The cleanup itself can hang, for example when the server stops answering. Give it a time limit with asyncio.timeout:

try:
    async with asyncio.timeout(2):
        await conn.close()
except TimeoutError:
    log.warning("closing the connection timed out")

When the time is up, asyncio.timeout cancels the code inside the block. At the end of the block, it turns the resulting CancelledError into a TimeoutError, so you catch TimeoutError outside the async with. The next article covers timeouts in detail.

A task cancelled before it starts running skips its whole body, finally included. If you open a resource and then pass it to a new task, close it in the code that opened it.

Check your understanding A handler wraps a database call in async with asyncio.timeout(1):. Inside the block it catches asyncio.CancelledError and returns a default value. What happens when the call takes 5 seconds? Show answer

After one second the timer cancels the call. The handler catches the CancelledError and returns the default, so the block ends normally without raising TimeoutError. To the caller, the call looks like it succeeded. If the task was also being cancelled for shutdown, that request is lost too. Catch TimeoutError outside the block instead, and let CancelledError through.

Shield work that must finish

Sometimes a step, such as writing a payment record, must finish even if the caller gives up. asyncio.shield() lets the caller be cancelled while the shielded task keeps running:

save = asyncio.create_task(save_payment(order))
await asyncio.shield(save)

If the caller is cancelled, it gets CancelledError straight away, and save keeps running. Keep a reference to save and check its result later, as the article on background tasks showed. Shield protects the task only from its caller's cancellation. Code that holds save can still cancel it directly.

Going further

task.cancelling() counts the stop requests that are still pending, and task.uncancel() takes one back. asyncio.timeout and TaskGroup use them to tell their own cancellation apart from yours. Don't call them in your own code.

See the asyncio task documentation for the full cancellation, shielding and timeout API.

step 29 of 56

Concurrency with Threads and asyncio

Up next Run Async Cleanup Without Hiding the Real Error