Python Task Cancellation — Deep Dive

Cancellation Mechanics Inside Task

The Task.cancel() method does more than set a flag. Here’s the internal flow from CPython’s tasks.py:

def cancel(self, msg=None):
    if self.done():
        return False
    self._cancel_count += 1
    self._cancel_message = msg

    if self._fut_waiter is not None:
        # Currently awaiting a Future — cancel it
        if self._fut_waiter.cancel(msg=msg):
            return True

    # Not currently awaiting — schedule __step with CancelledError
    self._must_cancel = True
    self._loop.call_soon(self.__step, CancelledError(msg))
    return True

Key detail: _cancel_count tracks how many times cancel() has been called. This is critical for the uncancel() mechanism introduced in Python 3.9.

CancelledError’s Heritage

In Python 3.8 and earlier, CancelledError was a subclass of concurrent.futures.CancelledError. Starting in Python 3.9, it became a subclass of BaseException (not Exception). This was a breaking change — a bare except Exception: no longer catches cancellation.

# Python 3.8: CancelledError is Exception
# Python 3.9+: CancelledError is BaseException

# This code behaves differently across versions:
try:
    await some_operation()
except Exception:
    # Python 3.8: catches CancelledError (bad!)
    # Python 3.9+: does NOT catch CancelledError (correct!)
    pass

This change was deliberate — cancellation should not be accidentally swallowed by generic exception handlers.

The uncancel() Method (Python 3.9+)

Task.uncancel() decrements the _cancel_count. This enables a pattern where intermediate code can temporarily suppress cancellation:

async def with_retry(coro_fn, retries=3):
    for attempt in range(retries):
        try:
            return await coro_fn()
        except asyncio.CancelledError:
            if attempt < retries - 1:
                # Suppress this cancellation for retry
                asyncio.current_task().uncancel()
                continue
            raise

The timeout() context manager in Python 3.11 uses uncancel() internally. When a timeout fires, it cancels the task. When the async with block exits, it calls uncancel() to restore the previous cancellation state — otherwise a timeout inside a larger operation would cancel the outer operation too.

async def outer():
    async with asyncio.timeout(5):
        await inner()  # If this times out...
    # ...we still reach here. timeout() uncancels after catching
    await more_work()  # This runs normally

Cancel Scopes: How timeout() Works Internally

The asyncio.Timeout class (Python 3.11+) implements a cancel scope:

class Timeout:
    def __init__(self, when):
        self._when = when
        self._task = None
        self._cancel_handle = None

    async def __aenter__(self):
        self._task = asyncio.current_task()
        if self._when is not None:
            loop = asyncio.get_running_loop()
            self._cancel_handle = loop.call_at(self._when, self._cancel)
        return self

    def _cancel(self):
        self._task.cancel(msg=_CANCEL_MSG)

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if self._cancel_handle:
            self._cancel_handle.cancel()
        if exc_type is asyncio.CancelledError and self._task.uncancel() == 0:
            raise TimeoutError from exc_val
        return False

The uncancel() return value tells you the remaining cancel count. If it’s 0, the only cancellation was from this timeout — safe to convert to TimeoutError. If it’s >0, an outer cancellation is pending — re-raise CancelledError.

Shielding Deep Dive

asyncio.shield() creates an outer Future that wraps an inner Task:

async def shield(aw):
    inner = ensure_future(aw)
    outer = loop.create_future()

    def _inner_done(inner):
        if outer.cancelled():
            return  # Outer was cancelled, but inner completed
        if inner.cancelled():
            outer.cancel()
        elif inner.exception():
            outer.set_exception(inner.exception())
        else:
            outer.set_result(inner.result())

    inner.add_done_callback(_inner_done)
    # Caller awaits outer; cancelling outer doesn't cancel inner
    return await outer

The pitfall: if the outer (shielded) future is cancelled, _inner_done sees outer.cancelled() and silently drops the result. The inner task completes successfully but nobody collects its result. This can mask exceptions. Always handle the inner task’s result explicitly if shield is cancelled:

inner_task = asyncio.create_task(critical_operation())
try:
    result = await asyncio.shield(inner_task)
except asyncio.CancelledError:
    # Inner task is still running! Wait for it:
    result = await inner_task  # Or handle differently

Building Cancellation-Safe Code

Pattern 1: Atomic Cleanup Sections

Use asyncio.shield() or Task.uncancel() for cleanup that must not be interrupted:

async def process_message(msg):
    result = await compute(msg)
    # Cleanup must complete — protect from cancellation
    try:
        await asyncio.shield(save_result(result))
    except asyncio.CancelledError:
        await save_result(result)  # Retry unshielded if shield was cancelled
        raise

Pattern 2: Cancellation-Aware Loops

async def consumer(queue):
    while True:
        try:
            item = await queue.get()
        except asyncio.CancelledError:
            # Drain remaining items before shutting down
            while not queue.empty():
                item = queue.get_nowait()
                await process_final(item)
            raise

        await process(item)

Pattern 3: Graceful Shutdown with TaskGroup

async def server():
    async with asyncio.TaskGroup() as tg:
        listener = tg.create_task(accept_connections())
        # When the TaskGroup is cancelled, all child tasks are cancelled
        # Each task gets CancelledError and can clean up

Edge Cases and Gotchas

Double Cancellation

If cancel() is called twice before the task processes the first one, _cancel_count is 2. The task needs two uncancel() calls to fully suppress cancellation. This is rare but happens with nested timeouts.

Cancellation During finally

If a task is cancelled while executing a finally block that contains an await, the CancelledError is thrown again at that await. This can interrupt cleanup:

async def risky():
    try:
        await work()
    finally:
        await slow_cleanup()  # CancelledError can land HERE too

Workaround: shield the cleanup or use synchronous cleanup when possible.

Generator-Based Coroutines

Legacy @asyncio.coroutine generators handle cancellation differently — CancelledError is thrown via generator.throw(), which has subtly different semantics for exception chaining. Another reason to use native async def.

One thing to remember: Cancellation in asyncio is a counted, cooperative protocol — cancel() increments a counter, uncancel() decrements it, and the timeout() context manager relies on this counting to distinguish its own cancellation from outer cancellations.

pythonconcurrencyasynciocancellation

See Also

  • Python Actor Model Why treating each piece of your program like a person with their own mailbox makes concurrency way less scary.
  • Python Aiocache Caching aiocache remembers expensive answers so your async Python app doesn't waste time asking the same question twice.
  • Python Aiofiles Async Io aiofiles lets your async Python program read and write files without freezing — because normal file operations secretly block everything.
  • Python Aiohttp Understand Aiohttp through an everyday analogy so Python behavior feels intuitive, not random.
  • Python Anyio Portability AnyIO lets your async Python code work with any async library — write once, run on asyncio or Trio without changes.