Программирование PythonКонкурентность и asyncioPython-разработчик серверных приложений

Объясните механизм сохранения локального состояния корутины asyncio между двумя await.

Объясните механизм сохранения локального состояния корутины asyncio между двумя await.

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Корутина сохраняет локальные переменные не в отдельном потоке, а в своём состоянии выполнения — объекте корутины и связанном с ним кадре. При приостановке на await выполнение прекращается, но позиция продолжения и значения локальных переменных сохраняются. Когда asyncio.Task снова получает управление, она возобновляет корутину с этой позиции.

Исторический контекст

Такой подход вырос из идеи приостанавливаемых генераторов: функция могла сохранить своё состояние и продолжить работу позже без создания отдельного потока. Позднее async и await формализовали этот механизм для корутин, а asyncio добавил планировщик, который связывает приостановленные операции ввода-вывода с готовыми к продолжению корутинами.

Исходная проблема заключалась в необходимости обслуживать множество ожидающих сетевых операций без выделения потока на каждое соединение. Сохранение состояния внутри корутины позволило использовать кооперативное переключение и один event loop.

Постановка проблемы

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

На практике один поток event loop поочерёдно выполняет небольшие фрагменты разных корутин. Если состояние не сохранялось бы при приостановке, после завершения ввода-вывода корутина не знала бы, на какой инструкции продолжить работу и какие значения имели её локальные переменные.

Подробное решение

При вызове асинхронной функции создаётся объект корутины, но её тело ещё не выполняется. Task начинает продвигать эту корутину, отправляя ей управление через внутренний протокол ожидания.

Когда выполнение доходит до await, корутина может приостановиться. В этот момент сохраняются текущая позиция выполнения, локальные переменные и состояние вложенных вызовов. В CPython это связано с кадром выполнения корутины; конкретные детали представления являются особенностью реализации, но семантически состояние должно быть доступно для продолжения.

Task связывает корутину с ожидаемым объектом, например операцией ввода-вывода. Когда event loop узнаёт о готовности этого объекта, задача ставит корутину на продолжение. Она возобновляется в том же потоке event loop, если только приложение явно не передало работу в поток или процесс.

import asyncio async def worker(): value = "до ожидания" await asyncio.sleep(0) print(value) value = "после ожидания" await asyncio.sleep(0) print(value) asyncio.run(worker())

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

Корутинное состояние не делает код автоматически безопасным при доступе к общим объектам. Локальные переменные принадлежат конкретному вызову корутины, но изменяемые объекты, на которые они ссылаются, могут быть общими. Кроме того, при отмене задача получает исключение CancelledError в точке ожидания, поэтому промежуточное состояние нужно корректно обрабатывать.

Ситуация из практики

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

Можно было бы запускать каждый запрос в отдельном потоке. Это упрощает линейное мышление, но увеличивает накладные расходы, требует синхронизации общих данных и плохо масштабируется при большом количестве ожидающих соединений. Можно также вручную хранить состояние в общей структуре, но это усложняет обработку отмены и создаёт риск смешать состояния разных запросов.

Рациональное решение — оставить состояние конкретного запроса в локальных переменных корутины, а общие ресурсы защищать подходящими примитивами asyncio. В результате event loop обслуживает множество ожидающих запросов, а состояние каждого вызова сохраняется независимо.

Что кандидаты часто упускают

1. Что именно хранится между двумя await: только значения локальных переменных?

Нет. Сохраняется весь необходимый контекст продолжения: текущая позиция выполнения, локальные переменные и цепочка вложенных вызовов корутины. Если локальная переменная содержит ссылку на изменяемый объект, сохраняется ссылка, а не независимая копия объекта. Поэтому изменения этого общего объекта могут быть видны другим задачам.

2. Освобождается ли состояние корутины сразу после await?

Нет, если корутина всё ещё существует и должна быть возобновлена. Её состояние удерживается объектом корутины и обычно доступно через задачу, которая ожидает завершения операции. После завершения, исключения или отмены задачи состояние может быть освобождено, если на него больше нет ссылок.

3. Может ли одна и та же корутина одновременно выполняться в двух задачах?

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