September 24, 2020·4 min read·engineeringpythonasynciocancellation

Python 3.8.6 waits for cancellation even when the timeout is zero

asyncio.wait_for now completes cancellation for nonpositive timeouts. The change makes the timeout boundary consistent with the existing cleanup contract for positive values.

A timeout can expire before the operation it supervises has finished cancelling. That distinction matters when a coroutine releases resources in its cleanup code. Python 3.8.6, released September 24, makes asyncio.wait_for wait for cancellation to complete even when the supplied timeout is zero or negative. Positive timeouts already followed that rule.

The maintenance note for issue 32751 identifies the inconsistency directly. The timeout value should determine when cancellation starts. It should not silently select a different cleanup contract. A caller supplying zero still needs to know what state the wrapped task has reached when control returns.

I respect Python's use of protocols to express resource lifetimes. Asynchronous cancellation makes those lifetimes more difficult to reason about because requesting a state change and reaching that state are separate steps. This fix removes one exception from that reasoning.

The existing contract already allows extra waiting

The task documentation shipped in this release describes wait_for as waiting for an awaitable with a timeout. A coroutine becomes a task automatically. When the timeout occurs, the function cancels that task and raises asyncio.TimeoutError.

The documentation also states that waiting for actual cancellation can make total elapsed time exceed the specified timeout. That is a deliberate consequence of waiting for the operation to reach its cancelled state. A timeout is therefore not a guarantee that the supervising coroutine regains control at an exact wall-clock deadline.

The documented history dates that waiting behaviour to Python 3.7. Before that change, wait_for raised its timeout exception immediately. Python 3.8.6 closes a remaining inconsistency for nonpositive values without introducing the general concept of waiting for cancellation anew.

This matters to the meaning of a timeout parameter. An application may calculate a remaining duration and reach zero after doing other work. Changing from a small positive duration to zero should not create an unexpected period during which the caller proceeds while the cancelled operation is still cleaning up.

The zero-timeout branch handles completed tasks separately

The released asyncio task implementation has a dedicated branch for timeout <= 0. It first ensures that the argument is represented by a future or task. If that future is already done, it returns the future's result. An already completed operation does not need to be cancelled merely because the supplied duration is nonpositive.

For an unfinished future, the branch now awaits _cancel_and_wait. After that helper returns, the branch obtains the result to handle the cancelled state and raises the timeout exception. The explicit wait is the relevant maintenance correction.

This order separates two questions. Has the operation already produced an outcome, and if not, has the requested cancellation finished? The code can return an existing result immediately while still waiting for an unfinished task's cancellation. Treating both cases as an unconditional timeout would discard an outcome that is already available.

The helper registers a completion callback and waits on a separate future after requesting cancellation. Its finally block removes that callback. The source explains that the separate waiter keeps the helper itself cancellable.

Cleanup can contain another suspension

The following example starts a task, waits until its body has begun, then supplies a zero timeout. Its cleanup deliberately suspends once before recording completion. The assertion after wait_for expresses the lifecycle property the fixed branch provides.

import asyncio

async def main():
    started = asyncio.Event()
    cleaned = []

    async def operation():
        try:
            started.set()
            await asyncio.Future()
        finally:
            await asyncio.sleep(0)
            cleaned.append(True)

    task = asyncio.create_task(operation())
    await started.wait()
    try:
        await asyncio.wait_for(task, timeout=0)
    except asyncio.TimeoutError:
        pass
    assert cleaned == [True]
    assert task.cancelled()

asyncio.run(main())

Starting the operation before calling wait_for is deliberate. A task cancelled before its coroutine body begins would not demonstrate cleanup of an entered body. The event gives the example a defined ordering without depending on an arbitrary positive sleep duration.

The cleanup's sleep(0) also matters. It makes the cleanup asynchronous, so merely requesting cancellation cannot establish that the append has happened. The final assertions check completion of this particular cancellation path. They do not claim that every coroutine accepts cancellation in the same manner.

I prefer examples that show the observable contract at the caller. The internal helper's name alone does not explain why an application cares. Here, code following the timeout exception can see that the operation's cleanup has completed and that the task is cancelled.

The wait is part of the operation's lifecycle

Waiting for cancellation means a cleanup routine can extend the duration of wait_for. That is the tradeoff already described in the versioned documentation. An application that needs cancellation completion cannot treat its timeout argument as an upper bound on every subsequent cleanup action.

For nonpositive timeouts, Python 3.8.6 now uses the same cancellation-completion principle as the positive-timeout path. Already finished futures still return their results. Unfinished tasks reach the cancellation wait before the caller receives a timeout outcome. That makes the state after the call less dependent on the sign of the remaining duration.