Ответ на вопрос
В Python корутины, создаваемые с помощью async def, реализованы как одноразовые конечные автоматы, управляемые флагами CO_ITERABLE_COROUTINE или нативными корутинами на уровне байт-кода CPython. Когда вы вызываете асинхронную функцию, она сразу возвращает объект корутины, содержащий объект фрейма и состояние выполнения; ожидание приводит этот конечный автомат к завершению, в этот момент внутренний маркер f_lasti (последняя инструкция) достигает конца, и фрейм помечается как исчерпанный. Python явным образом защищает от повторного входа, проверяя этот флаг завершения и вызывая RuntimeError, если происходят последующие ожидания, потому что корутины предназначены для представления единичных, дискретных асинхронных операций с линейным потоком управления. Напротив, функции-генераторы являются фабриками — каждый вызов создает новый PyGenObject со своим независимым стеком вызовов и указателем на инструкцию, позволяя функции генерировать несколько независимых итераторов, каждый из которых поддерживает отдельные контексты выполнения.
Ситуация из жизни
Команда разработчиков создавала надежный клиент WebSocket, которому необходимо было повторно пытаться установить соединение после неудачных попыток с использованием экспоненциальной задержки. Сначала они определили корутину соединения на уровне модуля и пытались повторно использовать её в логике повторных попыток.
import asyncio async def establish_connection(): return await websockets.connect("wss://api.example.com") # Инициализация на уровне модуля connection_coro = establish_connection() async def retry_connect(max_attempts=3): for attempt in range(max_attempts): try: ws = await connection_coro # Ошибка на второй итерации return ws except Exception: await asyncio.sleep(2 ** attempt)
Проблема возникла, когда вторая итерация цикла пыталась снова ожидать connection_coro, что привело к RuntimeError, потому что первая успешная операция ожидания уже исчерпала объект корутины. Команда рассмотрела три архитектурных решения.
Один из подходов заключался в том, чтобы вручную перестраивать объект корутины внутри блока except после перехвата RuntimeError. Хотя это было технически осуществимо, это привело к хрупкому управлению состоянием и сделало код зависимым от обнаружения исчерпания через обработку исключений, что семантически неоднозначно и могло скрыть законные ошибки времени выполнения в самом логике соединения.
Другое решение заключалось в том, чтобы преобразовать establish_connection в класс, реализующий __await__, чтобы создать сбрасываемый объект для ожидания. Это обеспечивало шаблон фабрики, но добавляло ненужное дополнительное оформление и сложность, затемняя простое намерение установить соединение и требуя ручного отслеживания состояния, которое дублировало то, что уже предоставляет Python через вызовы функций.
Выбранным решением было рассматривать асинхронную функцию как фабрику, переместив место вызова внутрь цикла, что гарантировало, что каждая итерация получала новый объект корутины. Переформатировав на ws = await establish_connection(), каждая попытка порождала свежий конечный автомат с независимым управлением ресурсами. Это соответствовало философии дизайна Python, где асинхронные функции являются конструкторами разовых вычислительных будущих, что приводит к чистой, безошибочной логике повторных попыток, правильно изолирующей неудачные попытки соединения от последующих повторов.
Что часто упускают кандидаты
Почему хранение корутины в переменной и забывание о ее ожидании создает утечку ресурсов, и как close() смягчает это?
Кандидаты часто предполагают, что неожиданные корутины просто уходят в сборку мусора без побочных эффектов. Однако, если корутина вошла в своё тело и приостановилась на выражении await (например, удерживая подключение к базе данных или блокировку), фрейм удерживает ссылки на эти ресурсы. Вызов close() на объекте корутины принуждает исключение GeneratorExit пройти через фрейм, заставляя управления контекстом (async with) и блоки try/finally немедленно освободить ресурсы. Без явного close() эти ресурсы остаются удерживаемыми, пока не сработает циклический сборщик мусора, что может быть слишком поздно для сценариев исчерпания пула соединений.
Как inspect.iscoroutine() отличается от inspect.isawaitable(), и почему это различие важно при написании универсальных утилит asyncio?
inspect.iscoroutine() возвращает True только для нативных объектов корутин, созданных функциями async def, в то время как inspect.isawaitable() возвращает True для любого объекта, реализующего __await__, включая корутины, задачи, будущее и пользовательские.awaitable. Кандидаты пропускают, что функции asyncio, такие как ensure_future(), принимают любые ожидаемые объекты, а не только корутины. Написание библиотек, которые строго проверяют iscoroutine(), отказывает в доступе к действительным ожидаемым объектам, таким как asyncio.Queue().get() или пользовательские объекты futures, нарушая полиморфизм в универсальных функциях утилит, предназначенных для планирования произвольных асинхронных операций.
В чем разница между async for и await при потреблении асинхронного генератора, и почему первое требует, чтобы __aiter__ возвращал сам генератор, а не корутину?
await потребляет корутину или будущее до завершения и возвращает одно значение, в то время как async for итерирует по асинхронному итератору, приостанавливая выполнение на каждом yield внутри функции-генератора async def. Кандидаты путают async for с ожиданием списка корутин. Крайне важно, чтобы __aiter__ возвращал объект асинхронного итератора напрямую (а не ожидаемый объект), поскольку среда выполнения Python вызывает __aiter__ синхронно для получения итератора перед началом протокола итерации. Возвращение корутины из __aiter__ вызывает TypeError, поскольку протокол ожидает немедленного доступа к методу __anext__ итератора для управления состоянием асинхронной итерации.