Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

Debugging and Troubleshooting Asyncio

Reference for python-asyncio-pattern-advisor · 307 lines

Programming / Pythonv1.0.0Updated OCT 21 20267 references1 script

How to find blocking calls, hangs, deadlocks, lost exceptions and leaks in async code, plus the observability that makes them visible in production before users notice.

Contents

Triage table

SymptomFirst checkLikely causeFix
All requests slow at once, CPU lowloop lag monitor, debug-mode slow callbacksblocking call on the loop (sync HTTP, time.sleep, big file read)async client / to_thread
All requests slow at once, CPU 100% on one corepy-spy dumpCPU-bound work on the loop (JSON of 50 MB, tokenizing, regex)process pool
Throughput flat as concurrency risesSemaphore size, client pool size, provider 429spool smaller than semaphore; rate limit; global lockmatch pool to semaphore, rate limiter
Process never exitsasyncio.all_tasks() dumpnon-daemon thread, executor job stuck, task awaiting forevertimeouts, cancel leftovers, daemon threads
Hang at queue.join()count get vs task_donemissing task_done() on error path, or dead workertask_done in finally, catch per item
Hang on shutdown / Ctrl+C ignoredgrep except BaseException, except CancelledErrorcancellation swallowedre-raise (lint AIO014)
Memory grows steadilyqueue sizes, task count over timeunbounded queue, tasks per item, retained resultsbounded queue, sliding window
Too many open filesopen sockets per processclient per request, unclosed responsesone client, async with
Random RuntimeError: ... different loopwhere primitives/clients are createdobject created under one loop, used under anothercreate inside asyncio.run / lifespan
Results silently wrong / missingreturn_exceptions=True handlingexceptions treated as resultsisinstance(r, BaseException)
Works locally, 429 storm in prodconcurrency x replicasunbounded fan-out, retries in locksteprate limiter, jittered backoff

Run python scripts/async_lint.py <path> first: it finds most of these causes statically.

Error messages decoded

MessageMeaningFix
RuntimeWarning: coroutine 'x' was never awaiteda coroutine object was created and garbage-collected without runningadd await, or schedule it as an owned task
Task exception was never retrieveda task failed and nobody awaited it or called .exception()TaskGroup, or await/gather it, or a done callback that logs
Task was destroyed but it is pending!the loop closed (or the task was GC'd) while the task was still runningkeep references; cancel and await tasks before the loop closes
RuntimeError: asyncio.run() cannot be called from a running event loopnested asyncio.run (common in Jupyter, or sync wrapper called from async code)await the coroutine; in Jupyter use top-level await
RuntimeError: This event loop is already runningloop.run_until_complete inside a coroutineawait
RuntimeError: no running event loopget_running_loop()/create_task() called from sync code with no loopcall from inside a coroutine, or start a loop with asyncio.run
RuntimeError: Event loop is closedsomething used the loop after asyncio.run returned (often a client's __del__)close clients explicitly before the loop ends
... got Future <...> attached to a different loopan asyncio object created under loop A awaited under loop Bcreate clients/primitives inside the loop that uses them
RuntimeError: cannot reuse already awaited coroutinethe same coroutine object awaited twice (e.g. in a retry loop)pass a factory: lambda: client.call(x)
TypeError: object X can't be used in 'await' expressionawaiting a sync function's resultthe client is sync: use the async client or to_thread
RuntimeWarning: Enable tracemalloc to get the object allocation tracebackaccompanies the never-awaited warningrun with -X tracemalloc=20 or debug mode
Executing <Task ...> took 1.234 secondsdebug mode: one step held the loop longer than slow_callback_durationfind the blocking call in that task
Unclosed client session / Unclosed connector (aiohttp)ClientSession never closedasync with aiohttp.ClientSession()
ResourceWarning: unclosed transportstream/transport not closedwriter.close(); await writer.wait_closed()
ExceptionGroup: unhandled errors in a TaskGroup (2 sub-exceptions)two children failedinspect eg.exceptions, handle with except*
asyncio.InvalidStateError: Result is not set.task.result() called before the task finishedawait it first (or read after the TaskGroup block)

Debug mode

Enable any of:

  • PYTHONASYNCIODEBUG=1 python app.py
  • python -X dev app.py (dev mode: asyncio debug + more warnings)
  • asyncio.run(main(), debug=True) or loop.set_debug(True)

What it adds:

  • Logs every callback/task step slower than loop.slow_callback_duration (default 0.1 s) on the asyncio logger: Executing <Task ...> took 0.512 seconds.
  • Never-awaited coroutine warnings include where the coroutine was created.
  • Raises RuntimeError when non-threadsafe loop methods are called from another thread.
  • Logs unclosed transports and slow DNS/IO selector calls; tasks keep their creation traceback.

It costs performance; use it in development, CI and when reproducing a production issue.

python
import asyncio
import logging
import time


class Collect(logging.Handler):
    def __init__(self) -> None:
        super().__init__()
        self.messages: list[str] = []

    def emit(self, record: logging.LogRecord) -> None:
        self.messages.append(record.getMessage())


async def blocking_handler() -> None:
    time.sleep(0.2)               # the bug: blocks the loop for 200 ms
    await asyncio.sleep(0)


async def main() -> None:
    loop = asyncio.get_running_loop()
    loop.slow_callback_duration = 0.05
    await asyncio.create_task(blocking_handler(), name="handle-request-42")


handler = Collect()
logging.getLogger("asyncio").addHandler(handler)
logging.getLogger("asyncio").setLevel(logging.WARNING)
asyncio.run(main(), debug=True)
slow = [m for m in handler.messages if m.startswith("Executing")]
print("slow step logged:", bool(slow), "| names the task:", "handle-request-42" in slow[0])
# Output: slow step logged: True | names the task: True

Naming tasks (create_task(..., name=...), tg.create_task(..., name=...)) makes these logs and task dumps far easier to read.

Finding blocking calls

  1. Static: python scripts/async_lint.py src/ --select AIO001,AIO002,AIO003,AIO004,AIO005,AIO006,AIO016.
  2. Debug mode slow-callback logs (above) name the task; the culprit runs between two of its awaits.
  3. py-spy dump --pid <PID> while the service stalls shows the Python stack of the loop thread; a frame inside requests, socket.recv, time.sleep or a C extension is the blocker. py-spy top for CPU hot spots.
  4. Third-party wrappers sometimes hide sync calls inside async def (an "async" SDK method that calls a sync client internally). The lag monitor below catches those in production.

Event-loop lag monitor

A heartbeat task that measures how late it wakes up. Lag above ~50-100 ms means something is blocking the loop. Export it as a metric (gauge or histogram).

python
import asyncio
import time


async def monitor_loop_lag(samples: list[float], interval: float = 0.02) -> None:
    loop = asyncio.get_running_loop()
    while True:
        start = loop.time()
        await asyncio.sleep(interval)
        samples.append(loop.time() - start - interval)     # how late we woke up


async def main() -> None:
    samples: list[float] = []
    monitor = asyncio.create_task(monitor_loop_lag(samples), name="loop-lag-monitor")
    await asyncio.sleep(0.1)
    time.sleep(0.15)                 # simulate a blocking call somewhere in a handler
    await asyncio.sleep(0.1)
    monitor.cancel()
    try:
        await monitor
    except asyncio.CancelledError:
        pass
    print("max lag over 100 ms:", max(samples) > 0.1)


asyncio.run(main())
# Output: max lag over 100 ms: True

Debugging hangs

Dump every task's stack: where each one is waiting tells you who is waiting on whom. Dump before cancelling: a timeout that cancels the stuck task also removes it from asyncio.all_tasks().

python
import asyncio
import io


async def waits_forever(event: asyncio.Event) -> None:
    await event.wait()                       # nobody ever calls event.set()


def dump_tasks() -> str:
    out = io.StringIO()
    for task in asyncio.all_tasks():
        out.write(f"--- {task.get_name()} done={task.done()}\n")
        task.print_stack(limit=5, file=out)
    return out.getvalue()


async def main() -> None:
    event = asyncio.Event()
    stuck = asyncio.create_task(waits_forever(event), name="index-builder")
    done, _ = await asyncio.wait({stuck}, timeout=0.05)   # wait() does not cancel on timeout
    if not done:
        report = dump_tasks()                            # dump while the task is still stuck
        print("index-builder" in report, "waits_forever" in report)
        stuck.cancel()


asyncio.run(main())
# Output: True True

Tools for live processes:

  • 3.14: python -m asyncio ps <PID> lists all tasks of a running Python process with their names and coroutine stacks; python -m asyncio pstree <PID> shows the await tree (which task awaits which). Both use the remote-debugging support added in 3.14 and may need ptrace permissions (root or /proc/sys/kernel/yama/ptrace_scope = 0).
  • 3.14: asyncio.capture_call_graph(task) / asyncio.print_call_graph(task) print the await chain from inside the process (useful in a debug endpoint).
  • Any version: install a signal handler that logs dump_tasks() (loop.add_signal_handler(signal.SIGUSR1, ...) on Unix), or expose it on an admin-only debug endpoint.
  • faulthandler.dump_traceback_later(timeout, repeat=True) prints all thread stacks periodically; it shows a loop thread stuck in a blocking call even when no asyncio code can run.
  • py-spy dump --pid (all threads, native frames with --native).
  • aiomonitor (third party) gives a telnet console with task listing and cancellation for running apps.

Deadlock patterns

PatternWhy it deadlocksFix
await queue.join() but a worker skipped task_done() on errorunfinished count never reaches 0task_done() in finally
All workers dead (exception escaped), producer blocked on put() to a full queuenobody consumescatch per item; supervise workers (TaskGroup)
threading.Lock held across await, second task acquires itthe loop thread blocks on a lock its own task holdsasyncio.Lock (lint AIO016)
Task A awaits B's result while B waits on an Event A sets latercircular waitrestructure; add timeouts to surface it
asyncio.Lock re-acquired by the same tasknot reentrantsplit the function; pass "already locked" state
Waiting on a future from run_coroutine_threadsafe(...).result() on the loop threadblocks the loop that must run itonly call .result() from other threads; on the loop use await asyncio.wrap_future(fut)
Sync callback calls asyncio.run in a thread whose loop is busynested loopsuse the existing loop via run_coroutine_threadsafe
Bounded Semaphore acquired twice in a nested call pathinner acquire waits for outer releaseacquire at one level only

Every network await with a timeout turns a silent deadlock into a TimeoutError with a stack trace, which is a large part of why rule 4 ("every network await has a deadline") matters.

Lost exceptions and never-awaited coroutines

python
import asyncio
import gc
import warnings


async def save_usage(n: int) -> None:
    await asyncio.sleep(0)


async def main() -> None:
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        save_usage(1)                        # bug: missing await
        gc.collect()
    print([str(w.message) for w in caught])


asyncio.run(main())
# Output: ["coroutine 'save_usage' was never awaited"]
  • Make warnings visible in tests: -W error::RuntimeWarning or pytest filterwarnings = ["error"] turns never-awaited coroutines into test failures.
  • Task exception was never retrieved is logged only when the task object is garbage-collected, which may be much later or at shutdown. Do not rely on it; own tasks and log in a done callback.
  • Route the loop's exception handler to your logging (loop.set_exception_handler, see the cancellation reference) and make sure the asyncio logger is not filtered out.

Resource leaks

  • Enable -X dev or -W default::ResourceWarning to see unclosed sockets, transports, files and sessions.
  • aiohttp logs Unclosed client session with the creation point in debug mode.
  • Count open file descriptors over time (len(os.listdir("/proc/self/fd")) on Linux) as a metric for long-running workers.
  • Async generators that are not exhausted keep their resources until finalized; use contextlib.aclosing.

Observability in production

SignalHowAlert when
Event-loop lagheartbeat task abovep99 > 100 ms
In-flight requests per providergauge inc/dec around the callpinned at the semaphore limit for minutes
Queue depth per pipeline stagequeue.qsize() sampledgrows steadily (consumer too slow)
429 / 5xx / timeout countscounters by provider and model429 rate > 1%
Retry count and give-upscountersrising give-ups
Task countlen(asyncio.all_tasks()) sampledgrows without bound (leak)
Latency per stagehistograms (retrieve, rerank, generate, first token)p95 regression
Tokens and costfrom usagebudget burn rate

Logging: put the request id in a contextvars.ContextVar and add it with a logging filter; it propagates into tasks and to_thread automatically. Tracing: OpenTelemetry's context also uses contextvars, so spans follow create_task/gather without extra work; create one span per external call.

python
import contextvars
import logging

request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")


class RequestIdFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.request_id = request_id.get()
        return True


handler = logging.StreamHandler()
handler.addFilter(RequestIdFilter())
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s [%(request_id)s] %(name)s: %(message)s"))
logging.getLogger().addHandler(handler)

Profiling

  • py-spy record -o profile.svg --pid <PID> samples without code changes; async frames appear as the coroutine functions being resumed.
  • yappi with set_clock_type("wall") and coroutine-aware stats attributes wall time correctly across awaits.
  • cProfile attributes time to the event loop's select call while waiting; useful for CPU hot spots, not for "where is time spent waiting".
  • To measure where a request waits, time each await (stage histograms) rather than profiling.
More in Python

python-async-deadlock-debugger

Diagnose and fix hangs, deadlocks, livelocks and starvation in asyncio, threaded and multiprocessing Python: classify CPU spin vs blocked IO vs deadlock vs lost wakeup vs event loop blocked by sync code, collect evidence with faulthandler, py-spy dump, gdb py-bt, asyncio.all_tasks, python -m asyncio ps/pstree, debug mode and sys._current_frames, then fix lock-order cycles, non-reentrant asyncio.Lock re-acquisition, run_coroutine_threadsafe().result() on the loop thread, Queue.join without task_done, Condition lost wakeups, semaphore leaks, futures nobody resolves, nested event loops, thread pool exhaustion, multiprocessing Queue/fork deadlocks and shutdown hangs. Ships a static hang scanner and a watchdog that dumps every thread and asyncio task.

python-beginner-concept-tutor

Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.

python-comprehension-refactorer

Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.

python-context-manager-writer

Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.

python-dataclass-converter

Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.

python-exception-handling-designer

Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.