Эти ошибки не про незнание синтаксиса. Каждая из них компилируется, проходит простые тесты и всплывает под нагрузкой или при остановке сервиса. Ниже десять граблей с симптомом, причиной и лекарством; если симптом знаком, читайте нужный раздел, а инструменты для их поиска собраны в статье про отладку.
Забытый 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копируются при создании задачи.
Что почитать дальше
- Отладка асинхронного кода — инструменты, которые находят каждую из этих ошибок.
- Тесты асинхронного кода — как воспроизвести гонку, отмену и таймаут тестом.
- Задачи и отмена — жизненный цикл задачи, из которого следует половина списка.