Cancelling All Tasks on KeyboardInterrupt¶
Pressing Ctrl-C in an asyncio program does not raise KeyboardInterrupt at a random line any more. Since Python 3.11, asyncio.run installs its own SIGINT handler: the first Ctrl-C cancels the main task, everything unwinds as a normal cancellation, and asyncio.run raises KeyboardInterrupt to its caller at the end. Traced on Python 3.14 with a main coroutine and two background tasks, each with 0.2–0.3 s of cleanup: on Ctrl-C, main() received CancelledError first and finished its cleanup; only after main() returned were the background tasks cancelled, and their cleanups ran concurrently; then asyncio.run raised KeyboardInterrupt and the script exited normally. A second Ctrl-C 0.1 s after the first cut main()'s cleanup short, though the background tasks' cleanup still ran. This guide builds a script and service shutdown on that sequence, so every task gets its turn to clean up and a second Ctrl-C still means "stop now".
Prerequisites¶
- Python 3.11+ (the
asyncio.RunnerSIGINT handling); traced on 3.14 on Linux. - Cancellation cleanup, from preventing CancelledError leaks in cleanup.
- Signal handling for services, from Graceful Shutdown & Signals.
1. See the order asyncio.run follows¶
The first Ctrl-C becomes a cancellation of the main task. Other tasks are left alone until the main task has finished:
async def background(n: int) -> None:
try:
await asyncio.sleep(100)
except asyncio.CancelledError:
await asyncio.sleep(0.2) # cleanup
raise
async def main() -> None:
tasks = [asyncio.create_task(background(i)) for i in range(2)]
try:
await asyncio.sleep(100)
finally:
await asyncio.sleep(0.3) # main's own cleanup
try:
asyncio.run(main())
except KeyboardInterrupt:
print("interrupted")
Traced timestamps after Ctrl-C at 0.96 s: main got CancelledError at 0.96 s and finished cleanup at 1.26 s; both background tasks got CancelledError at 1.26 s and finished cleanup at 1.47 s; asyncio.run raised KeyboardInterrupt at 1.47 s. The background tasks are cancelled by asyncio.run's own shutdown step, which cancels every remaining task and waits for them. The process exited with code 0 because the script caught KeyboardInterrupt; without the try, Python prints a traceback and exits with code 130.
Verify: add timestamps to each cleanup path in your script; the order matches: main first, then all remaining tasks together.
2. Cancel your own tasks from main, in the order you need¶
Relying on asyncio.run to cancel leftovers works, but it cancels everything at once, in no particular order, after main is done. When shutdown order matters — stop accepting work, drain workers, then close connections — do it in main's finally:
async def main() -> None:
pool = await create_pool()
workers = [asyncio.create_task(worker(pool), name=f"worker-{i}") for i in range(4)]
producer = asyncio.create_task(produce(), name="producer")
try:
await asyncio.gather(producer, *workers)
finally:
producer.cancel("shutdown: stop producing") # 1. no new work
await asyncio.gather(producer, return_exceptions=True)
for w in workers: # 2. stop workers
w.cancel("shutdown: stop workers")
await asyncio.gather(*workers, return_exceptions=True)
await pool.close() # 3. resources last
Cancelling and awaiting in stages makes the order explicit, and return_exceptions=True keeps one task's error from skipping the rest of the shutdown. The messages make the stop reasons visible in logs, as in cancelling tasks with a message. A TaskGroup gives the same structure automatically: when main is cancelled, the group cancels its children and waits for them before main continues unwinding.
Verify: Ctrl-C during a run produces log lines in the intended order, and the connection pool closes last.
3. Treat a second Ctrl-C as "stop now"¶
Users press Ctrl-C again when shutdown seems stuck. Under asyncio.run, the second press during the main task's cleanup interrupts that cleanup:
# traced: second Ctrl-C 0.1 s after the first
# main: CancelledError at 0.95 s
# main: finally at 1.06 s <- cleanup cut short; "cleanup done" never printed
# background tasks: cancelled at 1.06 s, cleanup done at 1.26 s
# asyncio.run raised KeyboardInterrupt
The background tasks' cleanup still ran, because it happens in asyncio.run's shutdown phase after main has gone. That is reasonable default behaviour: the first press asks politely, the second insists. Make sure the cleanup that must not be interrupted — finishing a file write, committing a transaction already in progress — is short and shielded, and that everything else tolerates being cut off:
async def main() -> None:
try:
await run_job()
finally:
await asyncio.shield(checkpoint.save()) # survives a second cancellation of main
await close_connections() # may be cut short; that is acceptable
Verify: pressing Ctrl-C twice still leaves a valid checkpoint, and the process exits promptly.
4. Do not catch KeyboardInterrupt inside coroutines¶
Under asyncio.run, the interrupt reaches coroutines as CancelledError, not KeyboardInterrupt. Code that catches KeyboardInterrupt inside async functions never runs; code that catches BaseException and continues swallows the shutdown:
async def worker():
try:
await process_forever()
except KeyboardInterrupt: # never raised here under asyncio.run
save_progress()
except BaseException:
log.exception("unexpected") # swallows CancelledError: Ctrl-C does nothing
Handle shutdown with except asyncio.CancelledError: ...; raise or finally, and catch KeyboardInterrupt only outside asyncio.run, where it arrives at the end. Older code that drives the loop manually with loop.run_until_complete gets the old behaviour — KeyboardInterrupt raised wherever the loop happened to be — and should move to asyncio.run or asyncio.Runner.
Verify: grep coroutines for except KeyboardInterrupt and except BaseException without re-raise; there should be none.
5. Use the same path for SIGTERM in services¶
Services are stopped with SIGTERM, not Ctrl-C, and asyncio.run does not handle SIGTERM by default — the process just dies. Route SIGTERM into the same cancellation path:
import signal
async def main() -> None:
loop = asyncio.get_running_loop()
main_task = asyncio.current_task()
loop.add_signal_handler(signal.SIGTERM, main_task.cancel, "shutdown: SIGTERM")
try:
await serve_forever()
finally:
await shutdown_in_order()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass # Ctrl-C during development: exit quietly
Now SIGTERM and Ctrl-C both cancel the main task and run the same ordered shutdown. add_signal_handler is available on Unix event loops; on Windows, asyncio.run still handles Ctrl-C, but other signals need different mechanisms. The service-level details — readiness, grace periods, Kubernetes — are in shutting down asyncio pods in Kubernetes.
Verify: kill -TERM <pid> produces the same ordered shutdown logs as Ctrl-C.
Verification¶
Interrupt handling is correct when:
- Ctrl-C cancels main, and main stops its tasks in order before
asyncio.runreturns. - No coroutine catches
KeyboardInterruptor swallowsBaseException. - A second Ctrl-C exits promptly, with must-finish steps shielded.
- SIGTERM follows the same path in services.
Diagnostic Hook: log a line at the start and end of shutdown with the elapsed time, plus one line per task as it stops. A shutdown that never logs its end is stuck in a task that ignores cancellation; tasks logging their stop only after asyncio.run's own cancellation phase are tasks main did not know about — usually fire-and-forget tasks without a registry.
Pitfalls & edge cases¶
- Expecting all tasks to be cancelled at once. Traced: main first, the rest only after main returns.
- Long unshielded cleanup in main. A second Ctrl-C cut it short.
except KeyboardInterruptinside coroutines. It never fires underasyncio.run.- Unhandled SIGTERM.
asyncio.runhandles only Ctrl-C by default.
Frequently Asked Questions¶
What happens when I press Ctrl-C in an asyncio program?
Since Python 3.11, asyncio.run cancels the main task, waits for it to finish, cancels all remaining tasks and waits for them, then raises KeyboardInterrupt. In a traced run, main cleaned up first and background tasks were cancelled only after it returned.
How do I cancel all asyncio tasks on KeyboardInterrupt?
Let asyncio.run do it, or cancel your tasks explicitly in main's finally block in the order you need, awaiting each stage with return_exceptions=True.
Why is my KeyboardInterrupt handler inside a coroutine never called?
Under asyncio.run the interrupt reaches coroutines as CancelledError; KeyboardInterrupt is raised only by asyncio.run itself, after shutdown.
What does a second Ctrl-C do in asyncio?
It interrupts the main task's ongoing cleanup; in testing main's cleanup was cut short while background tasks still cleaned up. Shield anything that must complete.
Related¶
- Cancellation Patterns — up to the topic overview.
- Handling Ctrl-C in asyncio scripts — script-level patterns built on this sequence.
- Resilience, Cancellation & Error Handling — the section overview.