Using asyncio.shield to Protect Critical Sections¶
Some operations must not be interrupted halfway. A payment capture, a two-phase commit, a compensating write that undoes a partial failure — cancel one of those between its two statements and you are left with state nobody can reason about. asyncio.shield() exists for exactly this, and it is one of the most misunderstood functions in the standard library, because what it protects is not what most people expect.
The mental model that avoids every trap: shield() protects the inner operation from cancellation, not the caller awaiting it. When the caller is cancelled, the await raises CancelledError immediately while the shielded work keeps running in the background — unreferenced, unawaited, and quite possibly destroyed at garbage-collection time. This guide covers what shield does and does not do, the timeout combination that surprises everyone, the reference discipline that makes it safe, and the alternatives that are usually better.
Prerequisites¶
- Python 3.11+ for
asyncio.timeout(); standard library only. - The cancellation patterns overview, and preventing CancelledError leaks in cleanup for the cleanup-path rules this builds on.
1. Know exactly what shield protects¶
The inner coroutine survives; the awaiting expression does not.
import asyncio
async def critical() -> str:
await asyncio.sleep(2)
print("critical work finished") # still prints after cancellation
return "done"
async def caller() -> None:
try:
result = await asyncio.shield(critical())
except asyncio.CancelledError:
print("caller cancelled — but critical() keeps running")
raise
async def main() -> None:
task = asyncio.create_task(caller())
await asyncio.sleep(0.1)
task.cancel() # cancels the CALLER, not critical()
await asyncio.sleep(3) # long enough to see the print
asyncio.run(main())
# caller cancelled — but critical() keeps running
# critical work finished
Both lines print, in that order, and their order is the whole lesson: cancellation propagates to the caller immediately, and the shielded work completes later with nobody waiting for its result. If critical() raises after the caller has gone, the exception has no consumer and surfaces only as an "exception was never retrieved" warning. Verify: run it; both prints must appear, a second apart.
2. Keep a reference to the shielded work¶
Because the caller is gone, nothing holds the inner task — and the loop keeps only a weak reference.
import asyncio
_inflight: set[asyncio.Task] = set()
def protected(coro) -> asyncio.Future:
"""Shield a coroutine AND keep it alive if the caller is cancelled."""
task = asyncio.ensure_future(coro)
_inflight.add(task)
task.add_done_callback(_inflight.discard)
task.add_done_callback(_log_failure)
return asyncio.shield(task)
def _log_failure(task: asyncio.Task) -> None:
if not task.cancelled() and task.exception() is not None:
log.error("shielded work failed after caller left: %r", task.exception())
Without the strong reference, a shielded task whose caller has been cancelled can be garbage collected mid-flight, which is the precise opposite of what shielding was meant to guarantee. The failure callback matters too: once the caller is gone, an exception in the shielded work has no other route to your logs. Verify: cancel the caller under GC pressure; the shielded work must still complete and log its outcome.
3. Understand shield plus timeout¶
This combination behaves in a way that catches nearly everyone.
import asyncio
async def with_timeout() -> None:
try:
async with asyncio.timeout(1.0):
await asyncio.shield(slow_operation()) # takes 5 s
except TimeoutError:
# We stop waiting after 1 s. slow_operation() runs for the full 5 s.
print("gave up waiting — the work is STILL RUNNING")
The timeout cancels the awaiting task, the shield stops that cancellation reaching slow_operation(), and the operation continues to completion in the background. That is sometimes exactly right — "start the capture, but do not hold the client for it" — and sometimes a resource leak that outlives every request. Decide which you meant, and if the work must actually stop, do not shield it. Verify: log at the start and end of slow_operation(); the end must appear about four seconds after the TimeoutError.
4. Prefer scoping the shield to the smallest region¶
Shield the two statements that must be atomic, not the whole operation.
import asyncio
async def capture_payment(order_id: str) -> None:
auth = await gateway.authorise(order_id) # cancellable: safe to abandon
async def commit() -> None: # must not be interrupted
txn = await gateway.capture(auth.token)
await db.execute("UPDATE orders SET captured = $1 WHERE id = $2",
txn.id, order_id)
await protected(commit()) # only this pair is shielded
await notify_customer(order_id) # cancellable again
A shield around the whole function would keep a long, mostly-cancellable operation running after everyone stopped caring; a shield around the atomic pair protects exactly the window where an interruption would leave money captured with no record of it. The smaller the shielded region, the smaller the amount of work that can outlive its caller. Verify: cancel during authorise (nothing happens, correctly) and during commit (the pair completes, correctly).
5. Consider the alternatives first¶
Shield is rarely the best tool for "this must finish".
import asyncio
# (a) Structured: the caller waits, cancellation is refused during the block
async def with_taskgroup() -> None:
async with asyncio.TaskGroup() as tg: # exits only when children finish
tg.create_task(commit())
# (b) Durable: hand the work to something that survives the process
await outbox.enqueue(commit_payload) # a worker replays it
# (c) Idempotent: let it be cancelled, and make a retry safe
await gateway.capture(auth.token, idempotency_key=order_id)
The durable option is the only one that survives a SIGKILL, a pod eviction, or a machine failure — shielding protects against cancellation, not against the process disappearing. For anything financial or externally visible, an outbox plus an idempotency key is the honest design, with shield as a convenience that narrows the window rather than as the guarantee itself. Verify: kill the process mid-shield; the shielded work is lost, while the outbox variant replays it on restart.
Verification¶
- Shielded work completes. Cancelling the caller during a shielded region still runs the region to completion, and its outcome is logged.
- Nothing is collected mid-flight. Under GC pressure, no shielded task produces a "destroyed but pending" warning.
- Scope is minimal. Auditing every
shield()call shows it wrapping only operations that are genuinely non-interruptible.
Pitfalls and edge cases¶
- Expecting the caller to keep waiting. The
awaitraises immediately on cancellation; only the inner work continues. - No strong reference to the shielded task. It can be garbage collected mid-flight, defeating the purpose entirely.
- Shielding a whole request handler. Cancelled requests then keep consuming resources for their full duration.
- Shield inside a
TaskGroup. The group still waits for its children, so the shield changes little and adds confusion. - Assuming shield survives process death. It protects against cancellation only; use a durable outbox for real guarantees.
- Losing the result. After the caller is cancelled there is nobody to receive a return value or an exception; log both in a callback.
- Shielding cleanup instead of writing it correctly. Cleanup should be written to run under cancellation — see the cancellation-cleanup rules — rather than shielded wholesale.
Frequently Asked Questions¶
What does asyncio.shield actually protect from cancellation?
The inner awaitable, not the code awaiting it. When the surrounding task is cancelled, the await on the shield raises CancelledError immediately while the shielded coroutine continues running in the background. Shield changes what happens to the inner operation, never whether the caller keeps waiting.
Why does my shielded task get destroyed before finishing?
Because nothing holds a strong reference to it once the caller is cancelled, and the event loop keeps only a weak one. Wrap the coroutine in a task, store it in a module-level set, and discard it in a done callback — otherwise the garbage collector can destroy exactly the work you were trying to protect.
What happens when asyncio.shield is combined with a timeout?
The timeout cancels the awaiting task, and the shield prevents that cancellation from reaching the inner operation, so you stop waiting while the work continues to completion. That is correct when you want to start something and not hold the caller for it, and a resource leak when you actually wanted the work to stop.
Is shield the right way to guarantee a write completes?
Only against cancellation, and only within the process's lifetime. It does nothing if the process is killed or the machine fails. For writes that must not be lost, use a durable outbox that a worker replays, and make the operation idempotent so a retry is safe; treat shield as a way to narrow the window, not as the guarantee.
Related¶
- Cancellation Patterns — the parent overview: cooperative cancellation and its rules.
- Preventing CancelledError leaks in cleanup — writing cleanup that runs correctly under cancellation.
- Preventing task garbage collection with strong references — the reference discipline step 2 depends on.