- Cancel Async Tasks
- Overview
- This skill provides guidance for implementing robust asyncio task cancellation in Python, particularly when dealing with signal handling (SIGINT/KeyboardInterrupt), semaphore-based concurrency limiting, and ensuring proper cleanup of all tasks including those waiting in queues.
- Key Concepts
- Signal Propagation in Asyncio
- Understanding how signals interact with asyncio is critical:
- KeyboardInterrupt vs CancelledError
-
- When SIGINT is received during
- asyncio.run()
- , the behavior differs from catching exceptions inside async code. The event loop typically converts the interrupt to
- CancelledError
- that propagates through tasks.
- Signal handler context
-
- Signal handlers run in the main thread, but asyncio tasks may be in various states (running, waiting on semaphore, waiting on I/O).
- Event loop state
-
- The event loop's handling of SIGINT depends on whether it's running
- asyncio.run()
- vs manual loop management.
- Task Lifecycle States
- When cancellation occurs, tasks can be in different states:
- Running tasks
-
- Currently executing code
- Awaiting tasks
-
- Blocked on I/O or other coroutines
- Semaphore-waiting tasks
-
- Waiting to acquire a semaphore for concurrency limiting
- Not-yet-started tasks
-
- Created but not yet scheduled
- Each state requires different handling for proper cleanup.
- Potential Approaches
- Approach 1: Task Group with Exception Handling
- Use
- asyncio.TaskGroup
- (Python 3.11+) for automatic cancellation propagation:
- TaskGroup automatically cancels remaining tasks when one fails
- Provides structured concurrency guarantees
- Consider whether this matches the cleanup requirements
- Approach 2: Manual Task Tracking with Shield
- Track all task objects explicitly and handle cancellation:
- Maintain a list of all created task objects
- Use
- asyncio.shield()
- for cleanup operations that must complete
- Implement explicit cancellation loop for all tracked tasks
- Approach 3: Signal Handler Registration
- Register explicit signal handlers for SIGINT/SIGTERM:
- Use
- loop.add_signal_handler()
- to register custom handlers
- Set a cancellation flag or event that tasks check
- Coordinate shutdown through the event loop
- Approach 4: Context Manager Pattern
- Wrap task execution in a context manager that handles cleanup:
- aenter
- sets up tasks and tracking
- aexit
- ensures all tasks are cancelled and awaited
- Handles exceptions uniformly
- Verification Strategies
- Testing with Real Signals
- Critical
- Test with actual signals, not timeouts:
Correct approach: Use subprocess with actual SIGINT
import subprocess import signal import time proc = subprocess . Popen ( [ 'python' , 'script.py' ] ) time . sleep ( 1 )
Let tasks start
proc . send_signal ( signal . SIGINT ) stdout , stderr = proc . communicate ( timeout = 5 )
Verify cleanup messages in output
- Incorrect approach
- (gives false confidence):
- Using
- asyncio.wait_for()
- with timeout does not replicate SIGINT behavior
- Using
- asyncio.CancelledError
- directly differs from signal-triggered cancellation
- Verification Checklist
- Running task cleanup
-
- Verify tasks actively executing receive cancellation
- Waiting task cleanup
-
- Verify tasks blocked on I/O are cancelled
- Semaphore queue cleanup
-
- Verify tasks waiting on semaphore acquisition are cancelled
- Cleanup code execution
-
- Verify finally blocks and cleanup handlers run
- No resource leaks
-
- Verify file handles, connections, etc. are closed
- Exit code verification
-
- Verify process exits with expected code after interrupt
- Test Scenarios to Cover
- Interrupt when all slots are filled (max_concurrent tasks running)
- Interrupt when tasks are queued waiting for semaphore
- Interrupt during cleanup phase itself
- Rapid repeated interrupts
- Interrupt before any task starts
- Common Pitfalls
- Pitfall 1: Catching KeyboardInterrupt Inside Async Functions
- Problem
- :
- KeyboardInterrupt
- doesn't propagate normally through asyncio - it's typically converted to
- CancelledError
- by the event loop.
- Symptom
-
- Exception handlers for
- KeyboardInterrupt
- inside async functions never trigger during actual Ctrl+C.
- Solution
-
- Handle
- CancelledError
- instead, or register explicit signal handlers at the event loop level.
- Pitfall 2: asyncio.gather Doesn't Cancel Queued Tasks
- Problem
-
- When using
- asyncio.gather
- with more tasks than can run concurrently (via semaphore), cancelling gather doesn't automatically cancel tasks waiting to acquire the semaphore.
- Symptom
-
- Tasks that haven't started don't have their cleanup code run.
- Solution
-
- Explicitly track all task objects and cancel them individually, not just rely on gather's cancellation.
- Pitfall 3: Testing with Timeouts Instead of Signals
- Problem
-
- Using
- asyncio.wait_for()
- timeout to simulate interruption doesn't replicate actual signal handling behavior.
- Symptom
-
- Tests pass but actual Ctrl+C behavior differs.
- Solution
-
- Use
- subprocess
- with
- signal.SIGINT
- to test actual signal handling behavior.
- Pitfall 4: Cleanup During Cancellation
- Problem
-
- Cleanup code itself may be cancelled if not protected.
- Symptom
-
- Partial cleanup, resources not released.
- Solution
-
- Use
- asyncio.shield()
- for critical cleanup operations, or handle
- CancelledError
- and re-raise after cleanup.
- Pitfall 5: Duplicate Exception Handling Code
- Problem
-
- Identical cleanup code in multiple exception handlers (
- CancelledError
- ,
- KeyboardInterrupt
- , etc.).
- Symptom
-
- Code duplication, maintenance burden.
- Solution
-
- Use a single handler with
- except (asyncio.CancelledError, KeyboardInterrupt)
- or abstract cleanup into a helper function.
- Pitfall 6: Not Awaiting Cancelled Tasks
- Problem
-
- Cancelling a task and not awaiting it leaves the task in a partially-cleaned-up state.
- Symptom
-
- Resource leaks, warnings about pending tasks.
- Solution
-
- Always
- await asyncio.gather(*cancelled_tasks, return_exceptions=True)
- after cancelling.
- Decision Framework
- When implementing async task cancellation, consider:
- Python version
-
- TaskGroup (3.11+) vs manual management
- Concurrency model
-
- Fixed pool, semaphore-limited, or unlimited
- Cleanup requirements
-
- What must happen before exit?
- Signal handling needs
-
- Just SIGINT, or also SIGTERM, SIGHUP?
- Testing environment
- Can tests send real signals? Debugging Tips Add logging at task entry, exit, and cancellation points Log the task state when cancellation is received Use asyncio.current_task() to identify which task is executing Check task.cancelled() vs task.done() states Enable asyncio debug mode: asyncio.run(main(), debug=True)