Ловушки асинхронного кода
Прогнал пять классических ошибок подряд и посмотрел, что получается. Забыл await - в переменной оказался объект типа coroutine вместо числа. Запустил фоновую задачу без сохранения ссылки - она не успела доработать. Отменил задачу - блок очистки выполнился. Собрал набор через gather, где одна задача падает, - получил первое же исключение, а остальные результаты не увидел. Добавил параметр «возвращать исключения» - получил список из результатов и объекта ошибки.
Стержень: у асинхронного кода свой набор граблей, и все они проверяются за минуту на пустом файле.
// Формулировки: «что будет, если забыть await?», «как правильно отменять задачи?», «что делает gather при ошибке?»
Забытый await и брошенные задачи
Вызов асинхронной функции без await создаёт объект и не запускает ничего. Мой замер это показывает буквально: тип coroutine вместо ожидаемого числа. Тихая ошибка: код не падает, просто ничего не делает, а при завершении программы в лог прилетает предупреждение «корутина никогда не ожидалась».
Обратная ситуация - задача, запущенную и брошенная. Она начнёт работать, но ссылку на неё никто не держит: у меня она не успела доделать работу, потому что программа завершилась раньше. В долгоживущем сервисе бывает хуже - такую задачу может собрать сборщик мусора прямо посреди работы.
// Лечение: ссылку на фоновую задачу сохранять, а при остановке сервиса дожидаться или отменять. В современных версиях для этого есть группа задач, которая сама дожидается всех своих участников и сама отменяет остальных, если один упал, - это надёжнее ручного управления списком.
- забытый await
- функция не выполняется, в переменной лежит объект корутины
- группа задач
- конструкция, которая дожидается всех участников и отменяет остальных при ошибке
Отмена и завершение
Отмена в асинхронном коде устроена как исключение: в точке ожидания задание получает специальную ошибку. Я проверил - блок очистки при этом выполняется, то есть освободить ресурсы можно и нужно.
Из этого следуют два правила. Первое: поймав эту ошибку в блоке очистки, её надо бросить дальше, а не проглотить, иначе задание формально останется невыполненным, а вызывающий будет ждать вечно. Второе: сама очистка не должна долго ждать - если внутри неё снова await на что-то медленное, отмена растянется.
// Предел ожидания устроен на том же механизме: он отменяет задание, если оно не уложилось. Проверил - вместо секунды ожидание прервалось за 0,1. Практическое следствие: предел ожидания стоит ставить на КАЖДЫЙ внешний вызов, иначе одно зависшее обращение будет держать задание бесконечно, а вместе с ним и соединение, и место в пуле.
- отмена
- особое исключение в точке ожидания; очистку делать можно, глушить нельзя
- предел ожидания
- отменяет задание, если оно не уложилось в отведённое время
Сбор результатов и ошибки
Собрать набор заданий и дождаться всех можно двумя способами, и они по-разному ведут себя при ошибке. По умолчанию первое же исключение пробрасывается наверх, а результаты остальных теряются - я это и получил. С параметром «возвращать исключения» результат приходит списком, где на месте упавшего лежит объект ошибки: у меня вышло «ок», объект ValueError, «ок».
Выбор зависит от смысла. Если набор атомарный (либо всё, либо ничего), первый вариант правильный. Если это независимые обращения (сходить к пяти сервисам и собрать что получится), нужен второй, иначе одна ошибка обесценивает четыре успешных ответа.
// Отдельная тонкость: при первом варианте остальные задания НЕ отменяются автоматически - они продолжают крутиться в фоне. Если это важно (а это обычно важно: они держат соединения), отменять надо явно. Группа задач из современных версий делает это сама и потому предпочтительнее.
- сбор с исключением
- первая ошибка летит наверх, остальные результаты теряются
- сбор с возвратом ошибок
- результат приходит списком, ошибки лежат на своих местах
Как отвечать: «Что произойдёт, если забыть await, и как ловить такие ошибки?»
Функция просто не выполнится. Вызов асинхронной функции создаёт объект корутины, а работа начинается только когда его отдают циклу - я проверял, в переменной оказывается объект типа coroutine вместо числа. Ошибка тихая: код не падает, просто ничего не делает, а при завершении программы в лог прилетает предупреждение, что корутина никогда не ожидалась. Ловится это тремя способами. Проверкой типов - она видит, что асинхронная функция вызвана без ожидания, и это самый дешёвый способ. Режимом отладки цикла событий, который дополнительно ругается на медленные задания. И тестами, потому что молчаливо ничего не делающий код обычно ломает ожидаемый результат. Рядом стоит родственная ошибка: фоновая задача, запущенная без сохранения ссылки, - я мерил, она может просто не успеть доработать.
Ответ описывает поведение, называет три способа поймать и добавляет соседнюю ошибку того же семейства. Указание на проверку типов как самый дешёвый способ - практический совет, а не теория.
На чём валятся
- −Забывают await и получают молча ничего не делающий код.
- −Глушат исключение отмены в блоке очистки, и вызывающий ждёт вечно.
- −Не ставят предел ожидания на внешние вызовы: одно зависшее обращение держит задание навсегда.
- −Используют сбор по умолчанию там, где нужны частичные результаты.
- −Не отменяют оставшиеся задания после первой ошибки: они продолжают держать соединения.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.
async def f(): return heavy_sync()без единого await внутри — что даёт такая «асинхронность»?A)Полноценную неблокирующую работуB)Ничего: без await она блокирует цикл как обычный кодC)Ускорение за счёт обёртки-корутиныD)Автоматический вынос тяжёлого счёта в фоновый поток ОСпоказать ответ и разбор
+B)Ничего: без await она блокирует цикл как обычный код// разбор: Обёртка в async def не делает синхронный код неблокирующим. Без внутренних await f выполнит heavy_sync целиком, не отдав управление циклу, — то есть заблокирует его ровно как sync-функция, ещё и с накладными на корутину. Асинхронность даёт не ключевое слово, а await на реально неблокирующих операциях.
- Как правильно вызвать блокирующую sync-функцию из async-кода, не вешая цикл?A)
await loop.run_in_executor(None, fn, *args)B)Обернуть её вasync defC)Поставитьawaitперед вызовомD)Просто вызвать — asyncio разрулитпоказать ответ и разбор
+A)`await loop.run_in_executor(None, fn, *args)`// разбор: run_in_executor отправляет блокирующий вызов в пул потоков (или процессов) и возвращает awaitable — цикл на время ожидания свободен для других корутин. В 3.9+ есть удобный asyncio.to_thread(fn, *args). «await sync-функция» невозможно (она не awaitable), а обёртка в async def без await не помогает.
- Синхронный psycopg2 в asyncio-сервисе под нагрузкой. Главная проблема?A)asyncio сам заведёт пул под негоB)Блокирующие вызовы драйвера сериализуют весь сервисC)psycopg2 несовместим с Python 3D)Соединения к базе тут не получится переиспользовать
показать ответ и разбор
+B)Блокирующие вызовы драйвера сериализуют весь сервис// разбор: Синхронный драйвер (psycopg2) на каждом запросе к БД блокирует поток цикла — конкурентность рушится, throughput падает до последовательного. В async-стеке берут асинхронный драйвер (asyncpg, psycopg3 async) либо, как компромисс, гоняют sync-драйвер через пул потоков. «Асинхронный» фреймворк от синхронного драйвера не спасает.
- Разработчик написал
fetch_data()без await и удивлён, что запрос не идёт. Что произошло?A)Вызов без await лишь создал объект корутины, не запустив еёB)Корутина исполнилась, но результат молча отбросился без ошибкиC)Функция упала с исключением, которое где-то потерялосьD)Запрос ушёл в фоновом потоке и результат придёт позжепоказать ответ и разбор
+A)Вызов без await лишь создал объект корутины, не запустив её// разбор: Вызов async-функции не исполняет её тело, а возвращает объект корутины. Без await (или create_task/gather) она никогда не попадёт в цикл. Python на такой брошенный объект обычно ругается предупреждением 'coroutine was never awaited'. Классическая ошибка невнимательности.
- В FastAPI-эндпоинте стоит
requests.get(url). Чем это плохо под нагрузкой?A)requests просто медленнее httpx, но цикл не страдаетB)Ничем: FastAPI сам сделает синхронный requests асинхроннымC)requests синхронный и блокирует поток циклаD)requests несовместим с FastAPI и вызовет ошибку импортапоказать ответ и разбор
+C)requests синхронный и блокирует поток цикла// разбор: requests — синхронная библиотека: её вызов блокирует поток до ответа сервера. В async-эндпоинте это замораживает весь цикл событий — параллельные запросы встают в очередь. Нужен async-клиент (httpx.AsyncClient, aiohttp) с await, либо вынос requests в пул потоков через to_thread.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.