Asyncio Cancellation and Timeouts on Low-RAM Targets
Within the Local Spatial Processing Patterns guide, this page zooms in on a single failure mode of the scheduling discipline set out in async execution for spatial workloads: what happens the instant a coroutine has to be abandoned mid-flight rather than allowed to run to completion. A gateway that dispatches CRS transforms and topology checks against a hard deadline will, over enough hours of uptime, hit every one of its timeouts. If cancellation is treated as an afterthought, each of those timeouts leaks a little — a dangling mmap, a ctypes handle that never got released, a task object that outlives its caller — and a device with 256 MB to 2 GB of RAM runs out of headroom long before it runs out of uptime. Correct cancellation is not exception handling bolted onto the happy path; it is the mechanism that keeps a long-running edge service’s memory footprint flat.
The deployment context matches the parent guide: Python 3.11+ on a Linux gateway, an asyncio event loop draining sensor input, and CPU-bound geometry work farmed out to workers or native libraries through FFI. What changes here is the question. Instead of “how do we dispatch work without blocking the loop,” it is “how do we guarantee that work we no longer want still releases everything it was holding.” Getting that guarantee wrong is invisible in a two-hour test run and fatal after a week in the field, which is exactly why it deserves its own treatment separate from device constraints and resource limits.
Cancellation model selection rationale
Python’s cancellation model is cooperative: task.cancel() does not stop a coroutine, it schedules a CancelledError to be raised at the coroutine’s next suspension point — typically the next await. Between cancellation requests and that raise, arbitrary code keeps running. This has two direct consequences for a memory-constrained gateway. First, a coroutine that never awaits inside a long CPU-bound stretch is effectively uncancellable until it chooses to yield, so cancellation only works if the code was written to cooperate with it. Second, and more subtly, CancelledError can arrive at any await, including ones sitting in the middle of a resource-acquisition sequence — which is why cleanup has to be written as if the interrupt could land between any two lines, not just at the end.
Three primitives cover the entire problem, and each maps to a specific constraint:
- Deadlines, not indefinite awaits.
asyncio.wait_for()or, from Python 3.11 onward, theasyncio.timeout()context manager wraps an awaitable with a hard ceiling. An unboundedawaiton a stalled FFI call or a wedged socket read is the single most common cause of an edge process quietly accumulating buffers until the OOM killer intervenes — nothing ever returns, so nothing ever gets released. - Guaranteed cleanup, not best-effort cleanup. A
try/finallyaround any native resource acquisition — anmmap, aGEOSContext, a file descriptor — runs on every exit path, including theCancelledErrorpath. Cancellation must not be treated as a special case; it is just another way thetryblock can end. - Selective immunity, not blanket immunity.
asyncio.shield()protects a specific inner awaitable from a caller’s cancellation without protecting the outer coroutine itself, which is the difference between “let this one commit finish flushing to flash” and “ignore cancellation forever.”
asyncio.timeout() is a Python 3.11 addition and the version this page assumes; on 3.10 and earlier, asyncio.wait_for() is the equivalent and is used interchangeably below where the target still runs an older interpreter.
A cancelled task only counts as safe once the finally block has run and every native handle it held has been released.
The complete implementation
The pattern below wraps a single FFI-backed geometry lookup — an mmap-ed grid lookup plus a native contains check — with a deadline, a shielded critical section for the part that must not be interrupted half-written, and unconditional cleanup. It targets Python 3.11’s asyncio.timeout(); the commented alternative shows the wait_for() form for 3.9/3.10 targets still in the field.
# cancel_safe_lookup.py — deadline-bounded FFI lookup with guaranteed cleanup.
#
# Python 3.11+. On 3.9/3.10, replace the `async with asyncio.timeout(DEADLINE):`
# block with: await asyncio.wait_for(run_lookup(event), timeout=DEADLINE)
import asyncio
import mmap
import logging
DEADLINE_S = 0.75 # per-event deadline; well under the caller's SLA
FLUSH_GRACE_S = 0.2 # extra time granted only to the shielded commit
log = logging.getLogger("cancel_safe_lookup")
class GridHandle:
"""Owns one mmap'd static index and one native FFI context. Both must be
released exactly once, on every exit path, including cancellation."""
def __init__(self, path: str):
self._fh = open(path, "rb")
self._mm = mmap.mmap(self._fh.fileno(), 0, access=mmap.ACCESS_READ)
self._ctx = _open_native_context(self._mm) # ctypes/cffi handle
def close(self):
# Idempotent: safe to call twice if cleanup runs on more than one path.
if self._ctx is not None:
_close_native_context(self._ctx)
self._ctx = None
if self._mm is not None:
self._mm.close()
self._mm = None
self._fh.close()
async def run_lookup(event: dict, grid: GridHandle) -> dict | None:
"""The cancellable part: probe the grid, then run the native predicate.
Wrapped in try/finally so a CancelledError raised at either await still
releases everything the coroutine was holding at the moment it fired."""
try:
candidates = await asyncio.to_thread(grid_probe, grid, event["lon"], event["lat"])
if not candidates:
return None
# The native predicate call itself is the part that must not be torn
# off half-way: shield it so an outer cancellation waits for THIS
# await to return before the CancelledError is delivered here.
match = await asyncio.shield(
asyncio.to_thread(native_contains, grid, candidates, event)
)
return match
except asyncio.CancelledError:
log.warning("lookup cancelled for event %s; releasing grid handle state", event["id"])
raise # re-raise: swallowing CancelledError breaks the task's own cancellation
finally:
# Runs on success, on exception, and on cancellation alike.
# No resource acquired above is allowed to survive past this point
# without being explicitly reclaimed here or owned by the caller.
pass # grid is long-lived and owned by the caller; per-call state, if any, frees here
async def handle_event(event: dict, grid: GridHandle) -> dict | None:
"""Caller-facing entry point: enforces the hard per-event deadline."""
try:
async with asyncio.timeout(DEADLINE_S):
return await run_lookup(event, grid)
except TimeoutError:
# asyncio.timeout() raises the stdlib TimeoutError, not asyncio.TimeoutError,
# once the deadline is exceeded — asyncio.CancelledError fires internally first.
log.warning("event %s exceeded %.2fs deadline; dropped", event["id"], DEADLINE_S)
return None
The asyncio.shield() call is doing narrow, deliberate work: it stops an outer cancellation (the deadline in handle_event) from reaching into the native predicate call before that call has returned, while still letting the shielded awaitable itself be cancelled if something cancels it directly. Shielding the wrong scope — for example wrapping the entire run_lookup body instead of just the native call — quietly defeats the deadline altogether, because everything inside a shield keeps running to completion regardless of the timeout that wraps it.
Constraint validation table
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| RAM | An await with no deadline on a stalled FFI call accumulates queued events behind it until the process is OOM-killed |
Every dispatch is wrapped in asyncio.timeout(); nothing can await indefinitely |
| CPU | Uncooperative cancellation leaves zombie coroutines still holding the event loop’s attention, wasting scheduling cycles | try/finally guarantees native handles are released the moment cancellation lands, not on a delayed GC pass |
| Latency | A caller waiting on handle_event with no deadline inherits whatever latency the native call has, unbounded |
DEADLINE_S bounds worst-case latency per event; shielded work adds at most FLUSH_GRACE_S beyond it |
| Power | Leaked mmap regions and file descriptors keep pages resident and force extra page-cache churn, costing cycles and battery |
Idempotent GridHandle.close() guarantees the mapping is torn down exactly once, never left dangling |
Gotchas and edge cases
- Swallowing
CancelledErrorsilently breaks cancellation for everyone. Catching it to log a message is fine; catching it and not re-raising tells the event loop the task finished normally, which can leave a caller waiting on await_for()that never times out because the inner task claimed success. Alwaysraiseafter handling. asyncio.timeout()raisesTimeoutError, notasyncio.TimeoutError. As of Python 3.11 the two names are the same object, but code written againstwait_for()on older interpreters that catchesasyncio.TimeoutErrorexplicitly should confirm both names resolve identically on the target’s interpreter before relying on it.- Shielding the wrong scope defeats the deadline.
asyncio.shield()protects only the awaitable passed to it, not the coroutine that calls it. Shield the smallest possible critical section — usually a single commit or flush — never the whole handler, or the deadline becomes decorative. finallyblocks can themselves be cancelled. A second cancellation delivered while afinallyblock is already unwinding (for example from a caller that gives up and cancels again) can interrupt cleanup mid-way. Keep cleanup code synchronous and allocation-free where possible so there is nothing left for a second cancellation to interrupt.- RTOS and single-core targets exaggerate the cost of uncooperative code. A tight CPU-bound loop with no
awaitblocks the entire loop on a single-core gateway exactly as described in the parent guide’s GIL discussion; cancellation cannot help a coroutine that never yields. - GPS-driven event bursts outrun a fixed deadline. During a high-rate GNSS burst,
DEADLINE_Smay need to shrink dynamically alongside the chunk-size adjustments described for thermal throttling in the parent guide, or the drop rate under load will climb faster than expected.
Integrating with the ingestion loop
handle_event slots directly into the queue-draining loop from the parent guide’s dispatch pattern: each drained event gets its own asyncio.timeout() scope rather than sharing one deadline across a whole chunk, so a single slow lookup cannot starve its neighbors. Track in-flight tasks with a plain set() so a shutdown signal can cancel and await them cleanly instead of abandoning the process mid-write:
in_flight: set[asyncio.Task] = set()
def dispatch(event: dict, grid: GridHandle):
task = asyncio.create_task(handle_event(event, grid))
in_flight.add(task)
task.add_done_callback(in_flight.discard) # prevents the set from growing without bound
async def shutdown():
for task in list(in_flight):
task.cancel()
# gather with return_exceptions=True so one CancelledError doesn't hide the rest
await asyncio.gather(*in_flight, return_exceptions=True)
The add_done_callback(in_flight.discard) line matters as much as the timeout itself: without it, in_flight grows for the life of the process, and the set of task objects becomes its own slow leak on a device with no memory to spare. On shutdown or a watchdog-triggered restart, cancel every tracked task and gather them before exiting so every finally block gets a chance to run before the process disappears.
Related
- Async Execution for Spatial Workloads — the dispatch and chunking discipline this deadline model plugs into.
- Bounding Process Pool Memory for Spatial Workers — the complementary control for memory owned by worker processes rather than in-loop tasks.
- Device Constraints and Resource Limits — the RAM and CPU ceilings that make unbounded awaits unaffordable in the first place.