Асинхронный код ломается иначе, чем синхронный: вместо исключения со стеком вы получаете сервис, который «иногда подвисает», предупреждение в логе, которое никто не читает, или задачу, которая тихо умерла неделю назад. Разберём встроенные инструменты asyncio, которые превращают эти симптомы в конкретное место в коде, и приёмы, которые работают на живом процессе в проде.
Режим отладки
Режим отладки включают флагом интерпретатора python -X dev, переменной PYTHONASYNCIODEBUG=1 или аргументом asyncio.run(main(), debug=True). Он делает четыре вещи. Замеряет длительность каждого шага цикла и пишет предупреждение о шагах дольше loop.slow_callback_duration (по умолчанию 0,1 секунды). Запоминает место создания каждой корутины, и предупреждение о никогда не ожидавшейся корутине показывает, где её создали, а не только где собрали. Проверяет, что методы цикла не вызываются из чужих потоков. И включает ResourceWarning на незакрытые транспорты и сокеты.
Проверено на Python 3.14: time.sleep(0.2) внутри корутины под -X dev даёт в stderr строку вида Executing <Task finished name='Task-1' coro=<main() ...> took 0.200 seconds. Этого достаточно, чтобы найти блокирующий вызов на стенде под нагрузочным тестом, потому что в сообщении видна корутина, внутри которой это произошло.
Режим стоит заметной доли производительности и в проде не живёт постоянно, но порог медленного колбэка можно использовать точечно: loop.slow_callback_duration = 0.05 в lifespan стенда, чтобы ловить и более короткие блокировки.
Два предупреждения, которые надо читать
RuntimeWarning: coroutine 'fetch' was never awaited означает, что async def вызвали как функцию и результат выбросили. Код «не сработал», а ошибки нет: отправка уведомления не произошла, запись не сделана. В режиме отладки предупреждение содержит стек создания. В обычном режиме оно появляется при сборке мусора, поэтому в логе стоит далеко от места ошибки. Хорошая практика: в тестах включать -W error::RuntimeWarning, чтобы забытый await ронял тест.
Task exception was never retrieved означает, что задача упала с исключением, а её результат никто не прочитал: ни await task, ни task.result(), ни TaskGroup. Это всегда задача, созданная через create_task и забытая. Сообщение содержит имя задачи и исключение со стеком, и если задачам давать имена, оно само указывает на виновника. Лечение структурное: фоновые задачи в TaskGroup или с add_done_callback, который логирует ошибку, об этом статья про структурную конкурентность.
Дамп задач: что сейчас висит
Когда сервис завис при остановке или перестал отвечать, первый вопрос: какие задачи живы и чего они ждут. asyncio.all_tasks() возвращает все незавершённые задачи цикла, у каждой есть имя, корутина и стек ожидания:
import asyncio, sys
def dump_tasks() -> None:
for task in asyncio.all_tasks():
print(f"--- {task.get_name()} {task.get_coro().__qualname__}", file=sys.stderr)
task.print_stack(limit=8, file=sys.stderr)
Дамп вешают на сигнал (SIGUSR1) или на закрытый административный маршрут и зовут при зависании. Он показывает, например, что десять задач стоят в pool.acquire() (пул базы исчерпан) или одна задача ждёт consumer.getmany() и не реагирует на отмену (проглоченный CancelledError). Стек ожидания в asyncio это цепочка await, и print_stack показывает именно её, до того await, на котором задача приостановлена.
Для процесса, в который нельзя добавить код, есть py-spy dump --pid N: он снимает стеки всех потоков без остановки процесса и показывает, что делает поток цикла событий прямо сейчас. Если он в select или kqueue, цикл свободен и проблема в ожидании; если в вашей функции, цикл заблокирован ею. Как снимать такие дампы и искать утечки, разбирает статья про профилирование и утечки.
Исключения, которые пропали
Исключение внутри обратного вызова (call_soon, add_done_callback) не поднимается наружу: цикл отдаёт его обработчику исключений цикла, который по умолчанию пишет в лог Exception in callback .... Если лог не читают, ошибка невидима. loop.set_exception_handler позволяет отправлять такие ошибки в систему учёта ошибок и в метрику, и в сервисе это стоит сделать в lifespan:
def loop_exception_handler(loop, context):
log.error("asyncio: %s", context.get("message"), exc_info=context.get("exception"))
loop.default_exception_handler(context)
asyncio.get_running_loop().set_exception_handler(loop_exception_handler)
Туда же попадают ошибки в транспортах и «never retrieved» от забытых задач, так что один обработчик закрывает несколько классов невидимых проблем.
Логи, по которым можно отладить
Асинхронный лог без идентификатора запроса бесполезен: строки десятков одновременных запросов перемешаны. Идентификатор кладут в ContextVar в middleware и добавляют к каждой записи фильтром логгера; contextvars переживают await, create_task и to_thread, поэтому идентификатор не теряется ни в задачах, ни в потоках. Как это оформить вместе со структурированными логами, рассказывает статья про логирование на Python.
Вторая полезная вещь в логах: длительность ожидания на границах. Сколько ждали соединение из пула, сколько сам запрос, сколько слот семафора. Без этого таймаут выглядит как «сосед медленный», хотя на самом деле задача полсекунды ждала соединение в своём же пуле.
Тесты как отладка
Многие асинхронные ошибки проще воспроизвести тестом, чем поймать в проде: гонку между двумя задачами, поведение при отмене, утечку задач. Полезная проверка в конце теста: asyncio.all_tasks() содержит только текущую задачу, иначе тест оставил фоновую работу. И режим отладки в тестах стоит держать включённым всегда: PYTHONASYNCIODEBUG=1 в конфигурации pytest ловит блокирующие вызовы и забытые корутины до прода. Приёмы собраны в статье про тесты асинхронного кода.
Глубже: что видно в стеке и чего не виднорасширенное
Стек задачи в asyncio это не стек вызовов в привычном смысле. Когда корутина приостановлена на await, её кадры сохранены в объекте корутины, и print_stack обходит цепочку cr_await от внешней корутины к внутренней, показывая, кто кого ждёт. Поэтому в дампе видно «обработчик ждёт репозиторий, репозиторий ждёт pool.acquire», но не видно, кто вызвал обработчик: задача создана циклом, и выше неё только цикл. Чтобы связать задачу с запросом, нужны имя задачи и контекст; uvicorn и Starlette имён не дают, но middleware может переименовать текущую задачу через asyncio.current_task().set_name(f"http {method} {path} {request_id}"), и после этого дамп задач читается как список запросов в полёте.
Чего нет в стеке: времени. print_stack не говорит, сколько задача стоит на этом await. Для этого в сервисе держат собственную метрику или простую обёртку, которая запоминает время входа в ожидание; у httpx и asyncpg такие цифры есть в их событиях и расширениях, а для ручных await их добавляют сами. И последнее: py-spy видит кадры Python, но в стеке потока цикла при ожидании покажет только select; чтобы увидеть задачи, нужен дамп через all_tasks изнутри процесса, то есть сигнальный обработчик стоит заводить заранее, до инцидента.
Коротко
- Режим отладки (
python -X dev,PYTHONASYNCIODEBUG=1,asyncio.run(debug=True)) пишетExecuting <Task> took N secondsдля шагов дольше 0,1 секунды и показывает место создания забытых корутин. coroutine was never awaitedэто вызовasync defбезawait; в тестах превращать в ошибку через-W error::RuntimeWarning.Task exception was never retrievedэто забытая задача с ошибкой; имена задач делают сообщение полезным,TaskGroupубирает причину.- Дамп
asyncio.all_tasks()сprint_stackпо сигналу показывает, какие задачи живы и на какомawaitстоят;py-spy dumpснимает стеки без остановки процесса. loop.set_exception_handlerловит ошибки колбэков, транспортов и забытых задач и отправляет их в учёт ошибок, а не только в лог.- Идентификатор запроса в
ContextVarпереживаетawait, задачи иto_thread; логировать длительность ожиданий на границах пулов. - В тестах проверять, что
all_tasks()пуст, и держать режим отладки включённым. - Стек задачи это цепочка
awaitбез времени; переименование текущей задачи в middleware делает дамп списком запросов в полёте.
Что почитать дальше
- Типичные ошибки конкурентности — что именно вы найдёте этими инструментами чаще всего.
- Профилирование и утечки на Python —
py-spy, профили и память в живом процессе. - Логирование на Python — идентификатор запроса через
contextvarsи структурированные записи.