← назад к разделу

Эти ошибки не про незнание синтаксиса. Каждая из них компилируется, проходит простые тесты и всплывает под нагрузкой или при остановке сервиса. Ниже десять граблей с симптомом, причиной и лекарством; если симптом знаком, читайте нужный раздел, а инструменты для их поиска собраны в статье про отладку.

Обязательно

Забытый await

Симптом: код «не сработал», ошибки нет, в логе RuntimeWarning: coroutine 'send_email' was never awaited.

Причина: async def вызвали как обычную функцию. Объект корутины создан и выброшен, тело не выполнялось.

Лекарство: await или create_task. В тестах -W error::RuntimeWarning, в редакторе проверка типов: mypy и pyright помечают неиспользованную корутину, если включить соответствующее правило (unused-coroutine у mypy включено по умолчанию).

Гонка через await

Симптом: при одновременных запросах создаются две записи вместо одной, кеш заполняется десять раз, счётчик врёт.

Причина: между проверкой и действием есть await. Пока первая задача ждала, вторая прошла ту же проверку. В одном потоке это так же реально, как в многопоточном коде, просто точки переключения известны.

Лекарство: критическая секция под asyncio.Lock с повторной проверкой внутри, или Future на ключ, который ждут все, кроме первого, как в статье про синхронизацию. Для данных в базе гонку закрывает не замок в процессе (подов несколько), а уникальное ограничение и on_conflict.

Потерянная задача

Симптом: фоновая работа иногда не доделывается; в логе Task exception was never retrieved или вовсе ничего.

Причина: asyncio.create_task(...) без сохранения ссылки. Цикл держит задачи слабо, сборщик мусора может удалить её на середине; а если она упала, никто не прочитал исключение.

Лекарство: TaskGroup для задач, которые должны завершиться до выхода из блока; множество с add_done_callback(set.discard) для настоящих фоновых задач; имя задачи для дампов.

Проглоченный CancelledError

Симптом: сервис не завершается при остановке, uvicorn ждёт до таймаута, под убивают по SIGKILL; или задача, которую отменили, продолжает писать в базу.

Причина: except BaseException:, голый except: или except asyncio.CancelledError: без raise. Отмена это исключение, и если его съесть, задача живёт дальше.

Лекарство: ловить Exception, а не BaseException; при перехвате отмены ради компенсации всегда перебрасывать. Проверять остановку тестом: отменить задачу и убедиться, что await task поднимает CancelledError за разумное время.

Общий объект с состоянием между задачами

Симптом: another operation is in progress от asyncpg, MissingGreenlet или перемешанные результаты у SQLAlchemy, странные ответы Redis.

Причина: одно соединение, одна AsyncSession, один pipeline используются из нескольких задач одновременно (gather с общей сессией, глобальная сессия на модуль). Объект с состоянием не рассчитан на чередование операций.

Лекарство: делить клиент с пулом, а не объект из пула; сессия и соединение на задачу; параллельные запросы к одной базе чаще не нужны вовсе. Подробнее в статье про асинхронные клиенты.

await без таймаута

Симптом: задачи накапливаются, пул соединений исчерпан, сервис отвечает всё медленнее и в конце концов перестаёт; в дампе десятки задач стоят на одном await.

Причина: ожидание внешней системы без предела: клиент без таймаута, await lock.acquire() без ограничения, await queue.get() в потребителе, которого никто не остановит.

Лекарство: asyncio.timeout вокруг каждого внешнего ожидания, явные таймауты клиентов, бюджет на запрос. Об этом статья про таймауты.

Блокировка цикла

Симптом: задержка всех запросов растёт одновременно, процессор не загружен; под -X dev в логе Executing <Task ...> took 0.8 seconds.

Причина: синхронный вызов внутри async def: requests, синхронный драйвер, time.sleep, чтение большого файла, тяжёлый расчёт.

Лекарство: асинхронный клиент, asyncio.to_thread для того, чему замены нет, процессы для вычислений. Статья про блокирующий код.

Утечка задач и памяти

Симптом: память процесса растёт неделю, число задач в all_tasks() растёт вместе с ней.

Причина: на каждый запрос или сообщение создаётся задача, которая никогда не завершается: ждёт событие, которое не наступит, или подписку, которую не закрыли. Второй вариант: словарь замков или Future по ключу, из которого записи не удаляются.

Лекарство: метрика на число живых задач и алерт на её рост; таймаут на ожидания; удаление записей по ключу в finally. Как искать утечки в живом процессе, рассказывает статья про профилирование и утечки.

gather там, где нужен TaskGroup

Симптом: после ошибки в одном вызове остальные продолжают работать и держат соединения; клиент уже получил 500, а база ещё обрабатывает три запроса от этого обработчика.

Причина: gather перебрасывает первое исключение и не отменяет соседей. Проверено на Python 3.14: соседняя задача остаётся живой.

Лекарство: TaskGroup, который отменяет соседей и ждёт их; gather(..., return_exceptions=True) только там, где частичные результаты нужны. Статья про структурную конкурентность.

asyncio.run внутри цикла

Симптом: RuntimeError: asyncio.run() cannot be called from a running event loop, обычно из библиотеки или из «синхронной обёртки» над асинхронным клиентом.

Причина: код пытается запустить свой цикл внутри уже работающего. Частый источник: синхронная функция, которая внутри зовёт asyncio.run(async_client.get(...)), вызванная из обработчика FastAPI через to_thread или напрямую.

Лекарство: библиотека принимает цикл, а не создаёт; синхронная обёртка либо не нужна (вызывайте асинхронный код напрямую), либо использует asyncio.run_coroutine_threadsafe из отдельного потока с ссылкой на основной цикл. nest_asyncio это обход, а не решение, и в сервисах его не применяют.

Дополнительно: при первом чтении можно пропустить

Глубже: порядок событий, на который нельзя рассчитыватьрасширенное

Несколько ошибок растут из одного заблуждения: что порядок выполнения задач предсказуем. create_task не запускает задачу сразу, она стартует на следующем обороте цикла, поэтому побочного эффекта «сразу после создания» не будет до ближайшего await создателя. Два create_task подряд стартуют в порядке создания, но после первого же await внутри них порядок дальнейших шагов зависит от того, чьё ожидание завершится раньше. gather возвращает результаты в порядке аргументов, но выполняет их в порядке готовности. Код, который рассчитывает на то, что «задача A запишет в словарь раньше задачи B», работает на стенде и ломается под нагрузкой, когда время ответа базы меняется.

Правило: зависимость по порядку выражают явно через await, события или очередь, а не через надежду на расписание цикла. Тест на такую ошибку делают, вставляя await asyncio.sleep(random.random() / 100) в подозрительные места в тестовом режиме: если результат зависит от задержек, зависимость по порядку есть.

Вторая группа заблуждений про contextvars: значение, выставленное внутри задачи, не видно создавшей задаче, потому что create_task копирует контекст на момент создания. Middleware, которое выставляет идентификатор запроса, должно сделать это до создания задач обработчика, и фоновая задача, созданная из обработчика, унаследует контекст на момент своего создания, а не дальнейшие изменения.

Коротко

  • Забытый await: корутина не выполнялась; -W error::RuntimeWarning в тестах и проверка типов.
  • Гонка через await: проверка и действие разделены ожиданием; замок с повторной проверкой или Future на ключ; в базе уникальное ограничение.
  • Потерянная задача: create_task без ссылки; TaskGroup или множество с add_done_callback.
  • Проглоченный CancelledError: сервис не останавливается; ловить Exception, при перехвате отмены перебрасывать.
  • Общая сессия или соединение между задачами: another operation is in progress; объект с состоянием на задачу, делится только пул.
  • await без таймаута копит задачи до исчерпания пула; asyncio.timeout и бюджет на запрос.
  • Блокировка цикла: синхронный вызов в async def; асинхронный клиент, to_thread, процессы.
  • Утечка задач и ключей: метрика на all_tasks, таймауты, очистка в finally.
  • gather не отменяет соседей после ошибки, TaskGroup отменяет; asyncio.run внутри цикла запрещён.
  • Порядок выполнения задач не предсказуем за пределами явных await; contextvars копируются при создании задачи.

Что почитать дальше