skip to content

Asyncio Core

One event loop on one thread interleaving thousands of coroutines that yield at every await. Interviewers spend the most time here, because this is where candidates block the loop or forget to await.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

page 1 of 2

Why does asyncio.create_task() require you to keep a reference to the Task it returns?

level: juniorimportance: must knowfreq 55%

answer

  1. Ask who else is holding it
  2. The loop's bookkeeping is not ownership
  3. Registry is a weakref.WeakSet
  4. Suspended task collected, work never resumes
  5. Own it in a set, discard when done

basics

~20 s

The event loop registers running tasks only in a weak set, so the Task object create_task() hands back may be the only strong reference. Drop it and the task can be garbage-collected while still suspended, and the work never finishes.

solid answer

~40 s

`asyncio.create_task()` schedules the coroutine and returns a `Task`. The loop's own bookkeeping — what `asyncio.all_tasks()` reads — is a `weakref.WeakSet`, so it does not own the task. While a step is queued to run, or the task is parked on a timer or a socket the loop watches, something else transiently references it, which is why the bug is intermittent rather than constant. But a task whose only remaining references are inside its own cycle can be reclaimed at any moment: the coroutine simply stops mid-flight, and you get at most a `Task was destroyed but it is pending!` line on stderr. The fix is to own the lifetime: put the task in a long-lived set and register `set.discard` with `Task.add_done_callback()` so it is dropped when it completes.

code

python · 17 lines
python
import asyncio

_background = set()

async def flush(name):
    await asyncio.sleep(0.05)
    print("flushed", name)

async def main():
    for i in range(3):
        task = asyncio.create_task(flush(f"batch-{i}"), name=f"flush-{i}")
        _background.add(task)
        task.add_done_callback(_background.discard)
    await asyncio.sleep(0.2)
    print("still held:", len(_background))

asyncio.run(main())

go deeper

for a junior

Recall the one-line rule: keep the object asyncio.create_task() returns, because the loop only holds weak references to tasks. Be able to say what goes wrong if you do not — the task can be collected mid-run and the work is silently lost.

for a middle

Explain the mechanics: a weakref.WeakSet registry, the incidental references (queued step, timer, selector) that make failure intermittent, and the set-plus-add_done_callback(set.discard) idiom that gives the task an owner without growing forever.

for a senior

Demonstrate that you have debugged this in production: work that thins out under load rather than failing, Task was destroyed but it is pending! in the logs, and async cleanup in finally that never completes because the coroutine was closed during garbage collection.

for a principal

Own the convention rather than the incident. Decide where fire-and-forget spawning is even allowed in a codebase, provide a single supervised spawn helper that names and tracks tasks, and treat bare asyncio.create_task() calls at review time as a defect class rather than a style preference.

### What `create_task` actually gives you `asyncio.create_task(coro)` wraps a coroutine object in an `asyncio.Task`, schedules its first step on the running loop, and returns the `Task`. That return value is not a receipt you may throw away — under CPython it is frequently the only strong reference to the running work. ### The registry is deliberately weak asyncio keeps a module-level registry of live tasks so that `asyncio.all_tasks()` can enumerate them. In CPython 3.14 that registry is a `weakref.WeakSet` (plus a small strong set used only for eagerly-started tasks while they run synchronously). A weak reference does not keep its target alive. The design is intentional: if the loop owned every task strongly, an abandoned or leaked task would pin its coroutine frame, its locals and everything they reference for the lifetime of the process, and the loop would become a memory sink. Bookkeeping is not ownership — the library expects the caller to own the task, and the `create_task` documentation says so explicitly. ### Why the bug is intermittent A task usually has *some* other reference, which is why sloppy code survives testing. When a step is queued, the loop's ready queue holds a callback bound to the task. When the coroutine is parked in `asyncio.sleep()`, the loop's timer heap holds a handle that reaches the task. When it is blocked on a socket the loop is watching, the selector registration reaches it. Each of these disappears the moment that particular wait ends. The collectible shape is a task suspended on a future that nothing outside the task itself reaches — a reference cycle, exactly what the generational cycle collector exists to reclaim. Then collection happens at an arbitrary time: after some unrelated allocation churn triggers a gen-2 pass, minutes into a run, on one machine and not another. That is what makes this class of defect so unpleasant. It does not fail; it *thins out*, dropping a small fraction of background work under load while every unit test passes. ### What you see when it happens Usually nothing useful. If the task was still pending, its finalizer reports `Task was destroyed but it is pending!` through the loop's exception handler, which by default logs at ERROR on the `asyncio` logger — a line that is easy to filter away and carries no application context. Worse, finalizing the coroutine object throws `GeneratorExit` in at the suspended `await`, so `finally` blocks run *during garbage collection*, off the loop, at an unpredictable moment. Any `await` inside such a `finally` cannot complete: the interpreter reports that the coroutine ignored `GeneratorExit`, and asynchronous cleanup — closing a connection, flushing and closing a file handle — silently does not happen. So a collected task can leak the very resource its cleanup code was written to release. ### The idiom that fixes it Hold a strong reference for exactly as long as the task runs: ```python _background: set[asyncio.Task] = set() task = asyncio.create_task(work(), name="flush-batch") _background.add(task) task.add_done_callback(_background.discard) ``` The set is the ownership; the done callback is what stops that set from growing without bound, since `Task.add_done_callback()` fires once the task finishes, whether it returned, raised or was cancelled. Note the direction of ownership: registering a callback does **not** keep the task alive — the task holds its callbacks, not the reverse. Passing `name=` costs nothing and makes `Task.get_name()` useful when you later log about the task. Also worth internalising: a task you `await`, or one owned by a scope that awaits its children, is already referenced by the awaiting frame for its whole life, so the hazard is specific to *fire-and-forget* spawning — work you start and walk away from. Where the work's lifetime is genuinely the lifetime of a block, a scoped group is the structurally simpler answer; where it truly outlives the caller, the set-plus-discard idiom is the minimum bar. ### Interview framing The strong answer names three things: the registry is weak, the intermittency comes from incidental references that come and go, and the fix is an owning container with a done callback to drain it. A candidate who says the loop keeps every task alive has an incorrect mental model of asyncio ownership, and that model produces exactly the leaks and dropped work that make async services hard to trust.

  • Is an unreferenced asyncio task guaranteed to be collected?
    No, and that is what makes it dangerous. While a step is queued, or the task waits on a timer or a watched socket, the loop transiently reaches it and it survives. Collection happens only when nothing outside the task's own cycle refers to it and the cycle collector runs. So the same code drops work on one deployment and not another, and never in a short unit test.
  • Does calling Task.add_done_callback() by itself keep the task alive?
    No. The task holds a list of its callbacks, not the other way round, so a callback creates no reference back to the task from anywhere durable. The callback is useful for draining an owning container or retrieving the result, but the strong reference has to be something you keep — typically a module-level or owner-held set.
  • Is a local variable inside the spawning coroutine a sufficient reference?
    Only while that frame is alive. If the spawner assigns the task to a local and then returns, the frame dies and the reference goes with it, which is the usual way this bug is written. The reference has to outlive the spawner: a set on the owning object, or a scope that awaits the task before returning.

The loop's task list is a guest register, not a coat check: it records who is in the building but holds nothing of yours, so if you let go of the ticket, the coat is thrown out.

saying these in an interview costs you the question

  • Claims the event loop keeps a strong reference to every task
  • Says a task, once started, always runs to completion
  • Thinks add_done_callback keeps the task from being collected
  • Assigns the task to a local in the spawner and calls it owned
  • Believes create_task returns nothing worth keeping
  • Assumes finally blocks still do async cleanup after collection

context

open as a page

When asyncio.wait_for() times out, what happens to the wrapped coroutine?

level: juniorimportance: must knowfreq 65%

basics

~10 s

asyncio.wait_for cancels the wrapped awaitable when the deadline passes, waits for that cancellation to finish unwinding, then raises TimeoutError to the caller. The inner work is stopped, not left running in the background.

open as a page

What does calling an `async def` function without `await` actually return?

level: juniorimportance: must knowfreq 85%

basics

~20 s

Calling an async def function returns a coroutine object and runs none of its body. The body executes only when something drives it: an await, or asyncio.run. If nothing ever does, Python emits a RuntimeWarning saying the coroutine was never awaited.

open as a page

What does asyncio.run() do with the event loop, and why prefer it to a hand-made loop?

level: juniorimportance: must knowfreq 78%

basics

~20 s

asyncio.run() creates a brand-new event loop, drives the coroutine you hand it to completion, then cancels leftover tasks, shuts down async generators and the default executor, and closes the loop. It is an async program's single entry point.

open as a page

What does asyncio.run() do with tasks still pending when your main coroutine returns?

level: juniorimportance: must knowfreq 70%

basics

~20 s

asyncio.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.

open as a page

What does asyncio.open_connection return, and what do you do with each object?

level: juniorimportance: must knowfreq 55%

basics

~10 s

Awaiting asyncio.open_connection returns a pair: a StreamReader you await bytes from, and a StreamWriter you push bytes into. Reads are awaited, writes are not, and you finish with close() followed by an awaited wait_closed().

open as a page

What does asyncio.create_task() return, and when does its coroutine actually start running?

level: juniorimportance: must knowfreq 78%

basics

~20 s

asyncio.create_task(coro) returns an asyncio.Task, a Future subclass that wraps the coroutine and schedules it on the running event loop. The coroutine does not run inside that call; it starts when the loop next gets control, at your following await.

open as a page

Why does asyncio.CancelledError inherit from BaseException rather than Exception?

level: middleimportance: must knowfreq 70%

basics

~10 s

Cancellation is a control-flow signal, not a failure, so since Python 3.8 asyncio.CancelledError derives from BaseException and a broad except Exception cannot swallow it. Catch it only to clean up, then re-raise.

open as a page

Why do two `await` calls written one after another run sequentially, not concurrently?

level: middleimportance: must knowfreq 70%

basics

~20 s

Await means wait here until this one awaitable finishes. It suspends the current coroutine so the loop can run other already-scheduled work, but the next line of this coroutine still runs only after the first await completes, so back-to-back awaits add up.

open as a page

Why does a blocking call inside one asyncio coroutine freeze the whole event loop?

level: middleimportance: must knowfreq 72%

basics

~20 s

An asyncio event loop drives every coroutine on one thread and only regains control at an await that suspends. A synchronous call — CPU work, time.sleep, a blocking read — holds that thread, so nothing else runs.

open as a page

What happens inside asyncio.run() when the user presses Ctrl-C?

level: middleimportance: must knowfreq 55%

basics

~20 s

Since CPython 3.11, asyncio.run() installs its own SIGINT handler: the first Ctrl-C cancels the main task instead of raising immediately, so your coroutine sees CancelledError and its finally blocks run; KeyboardInterrupt is raised afterwards. A second Ctrl-C raises at once.

open as a page

How do you list an asyncio loop's pending tasks and see what each one is awaiting?

level: middleimportance: must knowfreq 50%

basics

~10 s

Call asyncio.all_tasks() from inside the running loop for the set of unfinished Task objects, then read each with asyncio.Task.get_name(), asyncio.Task.get_coro() and asyncio.Task.print_stack(). On 3.14, asyncio.print_call_graph() shows the full await chain.

open as a page

Why must you await asyncio's StreamWriter.drain() after calling write()?

level: middleimportance: must knowfreq 60%

basics

~20 s

StreamWriter.write() only queues bytes in the transport's write buffer and returns at once, so a fast producer against a slow peer grows that buffer without bound. Awaiting drain() suspends the producing coroutine until the buffer drops back below its low-water mark.

open as a page

When one task inside an asyncio.TaskGroup raises, what happens to its siblings and what does the block raise?

level: middleimportance: must knowfreq 58%

basics

~20 s

The first non-cancellation failure makes the group cancel every other task it owns and wait for them; the async with block then raises an ExceptionGroup holding the real errors, which a plain except ValueError will not catch.

open as a page

How does asyncio.gather report an exception raised by one of its awaitables?

level: middleimportance: must knowfreq 68%

basics

~20 s

By default asyncio.gather propagates the first exception out of the await immediately, while the other awaitables keep running untouched. With return_exceptions=True it instead waits for everything and returns exception objects in the results list, positionally.

open as a page

How do you enable asyncio debug mode, and what does its slow-callback warning tell you?

level: juniorimportance: should knowfreq 35%

basics

~20 s

Enable it with asyncio.run(main(), debug=True), the PYTHONASYNCIODEBUG environment variable, or the -X dev flag. The loop then logs a warning whenever one callback or task step holds it longer than 0.1 seconds — the fingerprint of blocking code.

open as a page

What does `async with asyncio.TaskGroup()` guarantee about the tasks created inside the block?

level: juniorimportance: should knowfreq 42%

basics

~10 s

asyncio.TaskGroup scopes its children to the block: the async with statement cannot finish until every task started with tg.create_task has finished, so no child task outlives the code that spawned it.

open as a page

When does asyncio log 'Task exception was never retrieved', and why so late?

level: middleimportance: should knowfreq 50%

basics

~20 s

Nothing is reported when the task raises: the exception is stored on the Task and flagged unconsumed. Only when that Task object is finalized without anyone calling result() or exception() does its finalizer log the message.

open as a page

How do asyncio.get_running_loop() and asyncio.get_event_loop() differ, and which should library code call?

level: middleimportance: should knowfreq 48%

basics

~20 s

asyncio.get_running_loop() returns the loop running in this thread and raises RuntimeError if there is none. asyncio.get_event_loop() also accepts the loop installed for the thread, and on Python 3.14 raises when none exists. Library code calls get_running_loop().

open as a page

How do asyncio's StreamReader.read, readline and readexactly differ at end of stream?

level: middleimportance: should knowfreq 42%

basics

~20 s

read returns whatever is available, and empty bytes at EOF. readline returns a partial line without its newline at EOF, never raising. readexactly and readuntil raise asyncio.IncompleteReadError when the stream ends early, carrying the bytes already read.

open as a page

What distinguishes an asyncio.Task from a plain asyncio.Future?

level: middleimportance: should knowfreq 44%

basics

~20 s

asyncio.Task is a subclass of asyncio.Future. A Future is an empty result slot someone else fills with set_result or set_exception; a Task additionally owns a coroutine and drives it step by step on the event loop until it completes.

open as a page

How do you keep fire-and-forget asyncio tasks alive and their failures visible in a 6-hour log-ingest run?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Give background work an owner: a spawn helper that names each task, keeps it in a set, discards it when done, and retrieves its outcome in a done callback so failures are logged with context. Bound the in-flight count.

open as a page

When is asyncio.shield the right tool, and what does it not protect?

level: seniorimportance: should knowfreq 40%

basics

~20 s

asyncio.shield keeps an inner operation running when the code awaiting it is cancelled: the awaiter still raises CancelledError, the shielded task carries on. It protects against a caller's cancellation only, not against direct cancellation or loop teardown.

open as a page

How can shared state change across an `await` when asyncio runs on one thread?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Await points are the only places another coroutine can run, so anything between two awaits is atomic but anything read before an await may be stale after it. A check-then-act sequence split by an await is a race even without threads.

open as a page

An asyncio payroll CSV importer times out intermittently — how do you tell a blocked loop from a hung await?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Check whether everything stalls together. A blocked event loop freezes every task at once and makes timers fire late in a burst; a hung await leaves the loop responsive with one task stuck on the same frame.

open as a page

Why choose asyncio.TaskGroup over asyncio.gather(return_exceptions=True) when fanning out a conversion batch?

level: seniorimportance: should knowfreq 46%

basics

~20 s

They encode opposite failure contracts. A task group is all-or-nothing: the first error cancels the siblings and raises. gather with return_exceptions=True is best-effort and hands failures back as values in a results list, where they are easy to drop.

open as a page

How would you design an asyncio service's shutdown to fit an orchestrator's grace period?

level: principalimportance: should knowfreq 40%

basics

~20 s

Own the sequence rather than inheriting it: catch SIGTERM with loop.add_signal_handler, stop accepting new work, drain what is in flight under a budget well inside the grace period, cancel the rest, then let the runner finalize.

open as a page

What must an object implement for `await obj` to be legal in Python?

level: middleimportance: nice to knowfreq 25%

basics

~10 s

Await accepts any awaitable: a coroutine object, or any object whose await method returns an iterator. The value the iterator finally carries in StopIteration becomes the result of the await. Anything else raises TypeError.

open as a page

Why does asyncio.timeout() call Task.uncancel() when its deadline fires?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

The timeout escapes its block by cancelling the running task, then calls Task.uncancel() to withdraw its own request. The resulting Task.cancelling() count tells it whether the CancelledError was its own, so an outer cancellation is passed through instead.

open as a page

How do you schedule a callback onto a running asyncio event loop from another thread?

level: seniorimportance: nice to knowfreq 24%

basics

~10 s

Use the loop's call_soon_threadsafe() — one of the few loop methods legal from another thread. It queues the callback and wakes the loop from its selector poll; plain call_soon() and call_later() are thread-affine.

open as a page

showing 1–30 of 34