skip to content

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

level: middleimportance: must knowfreq 50%

answer

  1. One call returns the unfinished ones
  2. It needs a running loop to work
  3. Unnamed tasks make an unreadable dump
  4. A suspended task reports a single frame
  5. 3.14 added a full await-chain printer

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.

solid answer

~40 s

`asyncio.all_tasks()` returns the set of `Task` objects on the running loop that are not yet done; it needs a running loop, so call it from a coroutine or pass one explicitly. For each task, `asyncio.Task.get_name()` identifies it, `asyncio.Task.get_coro()` gives the coroutine object it drives, and `asyncio.Task.print_stack()` prints where it is suspended. That dump is only readable if you named the tasks at creation — `name=` on `asyncio.create_task()` — otherwise you get a wall of `Task-47`. The catch is that a suspended task's `get_stack()` returns just one frame, its own coroutine, not the coroutines it is awaiting; on 3.14 `asyncio.print_call_graph(task)` walks the whole await chain down to the real wait. Take two dumps seconds apart and diff them: one snapshot cannot distinguish stuck from busy.

code

python · 17 lines
python
import asyncio


async def import_rows(delay):
    await asyncio.sleep(delay)


async def main():
    job = asyncio.create_task(import_rows(30), name="payroll-csv-import")
    await asyncio.sleep(0.1)
    for task in asyncio.all_tasks():
        print(task.get_name(), "->", task.get_coro().__qualname__)
        task.print_stack(limit=1)
    job.cancel()


asyncio.run(main())

go deeper

for a junior

Know that a running loop can list its own unfinished tasks with asyncio.all_tasks(), and that giving each task a name at creation is what makes such a dump readable later.

for a middle

Explain the mechanics: the call needs a running loop, returns a set of Task objects, and each one exposes its name, its coroutine and its suspended frame — plus why that frame is only one level deep.

for a senior

Demonstrate the operational habit: named tasks everywhere, a way to trigger a dump on a live process, and diffing two dumps seconds apart rather than drawing conclusions from one.

for a principal

Own the question of what introspection ships by default — whether task naming and an always-available dump are a platform-level convention across services, or something each team improvises during an incident.

## The snapshot `asyncio.all_tasks(loop=None)` returns a `set` of the `Task` objects belonging to a loop that have not finished. With no argument it uses the running loop, which means calling it from ordinary synchronous code with no loop running raises `RuntimeError: no running event loop`. From another thread you either pass the loop object or schedule the dump onto the loop. What comes back is a set of `Task` objects, and each one answers three useful questions: - `asyncio.Task.get_name()` — the task's name, which is `Task-<n>` unless you set one. `asyncio.Task.set_name()` and the `name=` keyword on `asyncio.create_task()` set it. - `asyncio.Task.get_coro()` — the coroutine object the task drives. Its `__qualname__` tells you which `async def` this is. - `asyncio.Task.get_stack()` / `asyncio.Task.print_stack()` — the frames of the task's coroutine. `print_stack()` writes a traceback-shaped rendering; `get_stack()` returns frame objects and takes a keyword-only `limit`. ## Name your tasks or the dump is worthless This is the practical half of the answer. A dump of forty tasks called `Task-12` through `Task-51` tells you a number and nothing else. A dump of `payroll-csv-import`, `heartbeat`, and thirty-eight `upstream-fetch:<service>` tasks tells you which subsystem is stuck and how many of it there are. Naming costs one keyword argument at creation and is the difference between a dump you can act on and one you re-run with instrumentation added. ## The trap in get_stack() For a task that is suspended, `get_stack()` returns **one** frame: the frame of the coroutine the `Task` itself drives. If that coroutine awaited another coroutine, which awaited another, the frames further down are not in that list — they live in the chain of coroutine objects, not in the task's own stack. So a task genuinely parked on a socket read three coroutines deep reports a frame pointing at the outermost `await`, which is often the least informative line in the program. Python 3.14 fixed this with asyncio's call-graph introspection. `asyncio.print_call_graph(task)` follows the awaited-by chain and prints every frame down to the actual suspension point; `asyncio.capture_call_graph()` returns the same information as a structured `FutureCallGraph` object, and `asyncio.format_call_graph()` renders it to a string, which is what you want when the destination is a log line rather than a terminal. ## What the dump cannot see `all_tasks()` knows about `Task` objects on one loop. It does not show: - bare coroutine objects that were never wrapped in a task, - work handed to a thread or process executor — that is running outside the loop entirely, - plain callbacks scheduled with the loop's `call_soon` or `call_later`, - tasks on a different loop in a different thread, - anything at all if the loop's thread is blocked in synchronous code, because then *every* task looks merely suspended and none of them is the culprit. That last point matters: a task dump answers "what is everyone waiting for", not "who is holding the thread". ## Use it as a diff, not a photograph A single dump shows a set of suspended tasks, which is what a healthy loop looks like too — a busy loop is mostly suspended tasks. The signal is in the change. Take a dump, wait a few seconds, take another, and compare: tasks that disappeared finished, tasks that appeared are new work, and a task whose reported frame has not moved across both dumps is the one worth chasing. Wiring the dump to something you can trigger on a live process — an admin endpoint, a signal handler, a periodic log line at a low level — is what turns this from a debugging trick into an operational tool, and it is far cheaper to add before the incident than during it. ## What the dump costs, and how you trigger it Building the set is cheap: the loop already tracks its unfinished tasks, so a dump is a set copy plus whatever formatting you do. `asyncio.current_task()` is the companion call when you want only the task executing right now — inside a logging filter, for instance, so every log line carries the task name. The set comes back unordered, so sort it by name before printing or two dumps taken seconds apart are tedious to compare. The point of dumping is comparison, and comparison wants stable ordering. The harder half is triggering it on a process that is already misbehaving. The usable options are all things you add before the incident: an admin endpoint that returns the dump, a periodic low-level log line, or a handler that dumps on demand. Whichever you choose, it runs *on the loop* — so if the loop's thread is held by synchronous code, your dump does not run either, which is precisely why an in-process dump cannot diagnose a blocked loop and needs a separate signal for that case.

  • Why does asyncio.Task.get_stack() often show a frame that is not where the task is really stuck?
    Because it returns the stack of the coroutine the `Task` itself drives, and for a suspended task that is a single frame. Coroutines awaited further down are separate objects in the await chain, not frames on the task's stack, so a task parked deep inside a nested call reports the outermost await. On 3.14, `asyncio.print_call_graph()` follows that chain and shows the real suspension point.
  • You call asyncio.all_tasks() from a plain function and get a RuntimeError. Why?
    With no argument it resolves the *running* loop, and there is none outside a coroutine — the error is `no running event loop`. Either call it from inside a coroutine, pass the loop object explicitly, or, if you are dumping from another thread, schedule the dump onto the loop rather than reaching into it directly.
  • What kinds of work will never show up in an asyncio.all_tasks() dump?
    Anything that is not a `Task` on that loop: bare coroutine objects never wrapped in a task, work handed to a thread or process executor, plain callbacks scheduled with the loop's call_soon or call_later, and tasks on another loop in another thread. The dump answers what everyone is waiting for, not who is holding the loop's thread.

It is a passenger manifest for the loop: it tells you who is on board and what each one is waiting for, but not who is standing in the doorway blocking everybody.

saying these in an interview costs you the question

  • Thinks the task dump also lists threads and executor work
  • Leaves every task unnamed, then reads Task-12 dumps
  • Believes get_stack shows the whole await chain
  • Calls it outside a coroutine and is surprised by the error
  • Treats one snapshot as proof that a task is stuck
  • Confuses suspended tasks with stuck tasks

context