What does asyncio.run() do with tasks still pending when your main coroutine returns?
answer
- the entry point does not linger
- pending work is not awaited for you
- cancel first, then await the wreckage
- generators and threads finalized after tasks
- all_tasks, cancel, gather, close
basics
~20 sasyncio.run() does not wait for them. Once the coroutine you passed it returns, it cancels every task still pending, awaits them all, finalizes async generators and the default thread executor, then closes the event loop.
solid answer
~40 s`asyncio.run()` is a wrapper around `asyncio.Runner`, and the teardown lives in `Runner.close()`. It collects `asyncio.all_tasks()`, calls `Task.cancel()` on each one, and awaits them with `asyncio.gather(..., return_exceptions=True)` so one broken cleanup cannot block the rest. Any task that ended with a real exception rather than a cancellation is reported to the loop's exception handler as an unhandled exception during shutdown — it is logged, not re-raised, so `asyncio.run()` still returns your main coroutine's value. Then it awaits `loop.shutdown_asyncgens()`, awaits `loop.shutdown_default_executor()` with a five-minute join budget, and finally closes the loop. The practical rule: work you actually care about must be awaited inside `main()`; anything merely left running is cancelled, not completed.
code
python · 15 linesimport asyncio
async def worker():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print("worker cancelled during shutdown")
raise
async def main():
task = asyncio.create_task(worker())
await asyncio.sleep(0.1)
print("main returns, worker still pending:", not task.done())
asyncio.run(main())go deeper
Recall the one-line rule: the coroutine you hand to asyncio.run() is the program, and anything still pending when it returns is cancelled rather than finished. Be able to say what you would do instead if the work matters.
Explain the mechanics in order — collect the pending tasks, cancel them, await them with return_exceptions=True, finalize async generators and the default executor, close the loop — and say which of those steps still need a live loop.
Show what this costs a real service: work dropped mid-flight, cleanup exceptions that only appear in logs, and a task that swallows cancellation hanging exit forever. Demonstrate how you would notice each in production.
Own the policy question. Decide what your service promises about in-flight work at exit, whether the default cancel-everything is the right contract for that promise, and how the team verifies it rather than assuming it.
## What `asyncio.run()` really is `asyncio.run(main())` is deliberately thin. On CPython 3.14 its body is essentially: ```python with asyncio.Runner(debug=debug, loop_factory=loop_factory) as runner: return runner.run(main) ``` All the interesting behaviour is on the way *out* of that `with` block, in `Runner.close()`. Reading that method is the fastest way to understand what "graceful shutdown" means to the standard library. ## The exact teardown order 1. **Collect and cancel.** `asyncio.all_tasks(loop)` returns every task on that loop that is not yet done, and `Task.cancel()` is called on each. 2. **Await the cancelled tasks.** `loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True))`. The `return_exceptions=True` matters: one task whose cleanup raises must not stop the others from being awaited. 3. **Report failures.** For each task that finished with an exception rather than being cancelled, the loop's exception handler is invoked with a message about an unhandled exception during `asyncio.run()` shutdown. That is a log line, not a raise — it does not change what `asyncio.run()` returns nor the process exit code. 4. **`loop.shutdown_asyncgens()`** — async generators still suspended at a `yield` are closed so their `finally` blocks run while the loop is still alive. 5. **`loop.shutdown_default_executor()`** — the thread pool behind `asyncio.to_thread()` is joined, with a five-minute budget on 3.14; if the threads have not joined by then, a `RuntimeWarning` is emitted and shutdown continues without waiting. 6. **`loop.close()`** — after this nothing asynchronous can run at all. ## Why cancel rather than wait The contract of `asyncio.run()` is "run this coroutine"; the coroutine you pass *is* the program. Anything else still pending is work whose completion nobody expressed interest in by awaiting it. If the entry point waited instead, a single long-lived background task — a poller on a 60-second sleep, a retry loop — would make every clean exit hang. Cancelling is the only default that terminates. This also means the teardown runs on the *failure* path too. `Runner.close()` is invoked from the context manager's exit, so if `main()` raises, tasks are still cancelled and generators still finalized before the exception propagates out of `asyncio.run()`. ## What a task experiences Cancellation is cooperative, not preemptive. `Task.cancel()` arranges for `CancelledError` to be raised at the task's next suspension point. A task parked in `await asyncio.sleep(30)` gets it almost immediately; a task grinding through synchronous CPU work is not interrupted at all — teardown simply waits at step 2 until that code reaches an `await`. A task that catches `CancelledError` and keeps awaiting will make step 2 hang forever, which is how a program ends up unkillable at exit while looking perfectly healthy. Cleanup in a `finally` block still runs, and it runs while the loop is alive, so a `finally` may itself `await` — but only during step 2. Once step 6 has closed the loop, awaiting anything raises. ## Two details that bite `asyncio.all_tasks()` only sees tasks on *that* loop. A task created on another loop in another thread is invisible to this teardown and is not cancelled by it — that thread owns its own shutdown. And the cancel in step 1 is issued to every pending task at once, in no particular order. If task A's cleanup needs task B alive — B holds the connection A wants to flush through — the teardown gives you no way to sequence them. Ordering is something you have to arrange inside your own coroutine, before returning from `main()`; the entry point's teardown is a flat sweep, not a dependency-aware shutdown. ## The everyday consequence ```python async def main(): task = asyncio.create_task(upload_report()) await handle_requests() # returning here cancels the upload mid-flight ``` The fix is not a longer sleep at the end; it is awaiting what you care about: ```python async def main(): task = asyncio.create_task(upload_report()) await handle_requests() await task ``` If the work genuinely may be dropped, cancelling it is correct — just make the drop explicit and make the work's `finally` leave nothing half-written. ## Things people get wrong * **"The process keeps running until the background work finishes."** It does not. `asyncio.run()` returns and the interpreter proceeds to shut down. * **"Cleanup is skipped when a task is cancelled."** `finally` blocks and `async with` exits do run — that is precisely what step 2 awaits. * **"An exception during shutdown fails the program."** It is logged through the loop's exception handler; the exit code is unchanged. If shutdown correctness matters, watch those logs or install your own handler with `loop.set_exception_handler()`. * **"`loop.close()` cleans everything up."** Closing the loop finalizes nothing — all of steps 1–5 are ordinary awaits that must happen while the loop still runs. Driving a loop by hand and closing it skips them.
- How do you make sure that background work actually finishes instead of being cancelled?Await it before `main()` returns. Keep a reference to the task and `await` it, or await several with `asyncio.gather()`. If the work is genuinely optional, decide that explicitly and make its `finally` leave no half-written state — do not rely on the entry point granting it extra time, because it grants none.
- What happens to an exception a task raises while it is being cancelled at shutdown?The gather that awaits the cancelled tasks uses `return_exceptions=True`, so it is collected rather than propagated. Each task that ended with a real exception is then passed to the loop's exception handler with a message about an unhandled exception during shutdown, which logs it. `asyncio.run()` still returns your main coroutine's result and the exit code is unchanged.
- Does the same teardown run if the main coroutine raises instead of returning normally?Yes. `asyncio.run()` uses `asyncio.Runner` as a context manager, so `Runner.close()` runs on the way out whether `main()` returned or raised. Tasks are cancelled and awaited, async generators and the default executor are finalized, the loop is closed, and only then does your exception propagate to the caller.
It is closing time, not last orders: the venue does not wait for the stragglers to finish their drinks, it taps each one on the shoulder, sees them out, then locks up.
saying these in an interview costs you the question
- Says pending tasks keep running after asyncio.run() returns
- Expects the process to block until all background work finishes
- Claims finally blocks are skipped when a task is cancelled at exit
- Thinks the loop is closed before pending tasks are cancelled
- Believes an exception during shutdown fails the process
- Treats closing the loop as the thing that performs cleanup