сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Асинхронность в Python

Ловушки асинхронного кода

Типовые ловушки

Прогнал пять классических ошибок подряд и посмотрел, что получается. Забыл await - в переменной оказался объект типа coroutine вместо числа. Запустил фоновую задачу без сохранения ссылки - она не успела доработать. Отменил задачу - блок очистки выполнился. Собрал набор через gather, где одна задача падает, - получил первое же исключение, а остальные результаты не увидел. Добавил параметр «возвращать исключения» - получил список из результатов и объекта ошибки.

Стержень: у асинхронного кода свой набор граблей, и все они проверяются за минуту на пустом файле.

// Формулировки: «что будет, если забыть await?», «как правильно отменять задачи?», «что делает gather при ошибке?»

Забытый await и брошенные задачи

Вызов асинхронной функции без await создаёт объект и не запускает ничего. Мой замер это показывает буквально: тип coroutine вместо ожидаемого числа. Тихая ошибка: код не падает, просто ничего не делает, а при завершении программы в лог прилетает предупреждение «корутина никогда не ожидалась».

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

// Лечение: ссылку на фоновую задачу сохранять, а при остановке сервиса дожидаться или отменять. В современных версиях для этого есть группа задач, которая сама дожидается всех своих участников и сама отменяет остальных, если один упал, - это надёжнее ручного управления списком.

забытый await
функция не выполняется, в переменной лежит объект корутины
группа задач
конструкция, которая дожидается всех участников и отменяет остальных при ошибке

Отмена и завершение

Отмена в асинхронном коде устроена как исключение: в точке ожидания задание получает специальную ошибку. Я проверил - блок очистки при этом выполняется, то есть освободить ресурсы можно и нужно.

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

// Предел ожидания устроен на том же механизме: он отменяет задание, если оно не уложилось. Проверил - вместо секунды ожидание прервалось за 0,1. Практическое следствие: предел ожидания стоит ставить на КАЖДЫЙ внешний вызов, иначе одно зависшее обращение будет держать задание бесконечно, а вместе с ним и соединение, и место в пуле.

отмена
особое исключение в точке ожидания; очистку делать можно, глушить нельзя
предел ожидания
отменяет задание, если оно не уложилось в отведённое время

Сбор результатов и ошибки

Собрать набор заданий и дождаться всех можно двумя способами, и они по-разному ведут себя при ошибке. По умолчанию первое же исключение пробрасывается наверх, а результаты остальных теряются - я это и получил. С параметром «возвращать исключения» результат приходит списком, где на месте упавшего лежит объект ошибки: у меня вышло «ок», объект ValueError, «ок».

Выбор зависит от смысла. Если набор атомарный (либо всё, либо ничего), первый вариант правильный. Если это независимые обращения (сходить к пяти сервисам и собрать что получится), нужен второй, иначе одна ошибка обесценивает четыре успешных ответа.

// Отдельная тонкость: при первом варианте остальные задания НЕ отменяются автоматически - они продолжают крутиться в фоне. Если это важно (а это обычно важно: они держат соединения), отменять надо явно. Группа задач из современных версий делает это сама и потому предпочтительнее.

сбор с исключением
первая ошибка летит наверх, остальные результаты теряются
сбор с возвратом ошибок
результат приходит списком, ошибки лежат на своих местах

Как отвечать: «Что произойдёт, если забыть await, и как ловить такие ошибки?»

Функция просто не выполнится. Вызов асинхронной функции создаёт объект корутины, а работа начинается только когда его отдают циклу - я проверял, в переменной оказывается объект типа coroutine вместо числа. Ошибка тихая: код не падает, просто ничего не делает, а при завершении программы в лог прилетает предупреждение, что корутина никогда не ожидалась. Ловится это тремя способами. Проверкой типов - она видит, что асинхронная функция вызвана без ожидания, и это самый дешёвый способ. Режимом отладки цикла событий, который дополнительно ругается на медленные задания. И тестами, потому что молчаливо ничего не делающий код обычно ломает ожидаемый результат. Рядом стоит родственная ошибка: фоновая задача, запущенная без сохранения ссылки, - я мерил, она может просто не успеть доработать.

Ответ описывает поведение, называет три способа поймать и добавляет соседнюю ошибку того же семейства. Указание на проверку типов как самый дешёвый способ - практический совет, а не теория.

На чём валятся

  • Забывают await и получают молча ничего не делающий код.
  • Глушат исключение отмены в блоке очистки, и вызывающий ждёт вечно.
  • Не ставят предел ожидания на внешние вызовы: одно зависшее обращение держит задание навсегда.
  • Используют сбор по умолчанию там, где нужны частичные результаты.
  • Не отменяют оставшиеся задания после первой ошибки: они продолжают держать соединения.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.

  1. #async_pitfalls1 / 5
    async def f(): return heavy_sync() без единого await внутри — что даёт такая «асинхронность»?
    A)Полноценную неблокирующую работу
    B)Ничего: без await она блокирует цикл как обычный код
    C)Ускорение за счёт обёртки-корутины
    D)Автоматический вынос тяжёлого счёта в фоновый поток ОС
    показать ответ и разбор
    +B)Ничего: без await она блокирует цикл как обычный код

    // разбор: Обёртка в async def не делает синхронный код неблокирующим. Без внутренних await f выполнит heavy_sync целиком, не отдав управление циклу, — то есть заблокирует его ровно как sync-функция, ещё и с накладными на корутину. Асинхронность даёт не ключевое слово, а await на реально неблокирующих операциях.

  2. #async_pitfalls2 / 5
    Как правильно вызвать блокирующую sync-функцию из async-кода, не вешая цикл?
    A)await loop.run_in_executor(None, fn, *args)
    B)Обернуть её в async def
    C)Поставить 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 не помогает.

  3. #async_pitfalls3 / 5
    Синхронный psycopg2 в asyncio-сервисе под нагрузкой. Главная проблема?
    A)asyncio сам заведёт пул под него
    B)Блокирующие вызовы драйвера сериализуют весь сервис
    C)psycopg2 несовместим с Python 3
    D)Соединения к базе тут не получится переиспользовать
    показать ответ и разбор
    +B)Блокирующие вызовы драйвера сериализуют весь сервис

    // разбор: Синхронный драйвер (psycopg2) на каждом запросе к БД блокирует поток цикла — конкурентность рушится, throughput падает до последовательного. В async-стеке берут асинхронный драйвер (asyncpg, psycopg3 async) либо, как компромисс, гоняют sync-драйвер через пул потоков. «Асинхронный» фреймворк от синхронного драйвера не спасает.

  4. #async_pitfalls4 / 5
    Разработчик написал fetch_data() без await и удивлён, что запрос не идёт. Что произошло?
    A)Вызов без await лишь создал объект корутины, не запустив её
    B)Корутина исполнилась, но результат молча отбросился без ошибки
    C)Функция упала с исключением, которое где-то потерялось
    D)Запрос ушёл в фоновом потоке и результат придёт позже
    показать ответ и разбор
    +A)Вызов без await лишь создал объект корутины, не запустив её

    // разбор: Вызов async-функции не исполняет её тело, а возвращает объект корутины. Без await (или create_task/gather) она никогда не попадёт в цикл. Python на такой брошенный объект обычно ругается предупреждением 'coroutine was never awaited'. Классическая ошибка невнимательности.

  5. #async_pitfalls5 / 5
    В 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.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.