asyncio.run: событийный цикл под капотом
Одна строка asyncio.run(main()) превращает обычный скрипт в асинхронную программу. Разбираем три работы этой строки: создать цикл, раскрутить корутину, убрать за собой.
Редакция Питоники
В прошлом уроке появилась присказка «раскрутить корутину», и всю дорогу эту работу молча выполнял asyncio.run. Пора заглянуть под капот: что именно происходит между твоим import asyncio и первой строкой main, кто такой «событийный цикл» и почему эта строка в программе ровно одна. Понимание точки входа отличает «я вызываю магию» от «я знаю, кто крутит мой код» — а во втором случае уже не страшно читать чужие боты и сервисы.
asyncio.run: три работы одной строки
asyncio.run(корутина) — функция из стандартной библиотеки, которая выполняет три работы по порядку. Первая: создать событийный цикл — диспетчера, который будет решать, какая корутина сейчас работает, а какая ждёт. Вторая: запустить переданную корутину и крутить цикл, пока она не завершится. Третья: закрыть цикл и подчистить за ним ресурсы. Смотрим на стык двух миров — синхронного и асинхронного:
import asyncio
async def main():
print("старт")
await asyncio.sleep(1) # имитация сетевой задержки
print("финиш")
print("до цикла")
asyncio.run(main())
print("после цикла")
до цикла старт финиш после цикла
Четыре строки — и видно, где проходит граница. «до цикла» — обычный синхронный код: цикла ещё нет, корутина main тоже пока просто объявлена, а не запущена. Потом asyncio.run строит цикл, и внутри него отрабатывают «старт» и «финиш». «после цикла» — снова синхронный мир: цикл закрыт, корутина завершена. Всё асинхронное в программе живёт строго между этими двумя строками.
Вторая работа — крутить корутину — устроена как у обычной функции: asyncio.run возвращает то, что вернула main. Результат проходит сквозь точку входа насквозь:
import asyncio
import time
async def main():
await asyncio.sleep(1) # имитация сетевой задержки
return "отчёт собран"
result = asyncio.run(main())
print("asyncio.run вернул:", result)
asyncio.run вернул: отчёт собран
import asyncio
async def prepare():
await asyncio.sleep(0)
return "данные готовы"
async def report():
data = await prepare()
return f"отчёт из «{data}»"
print(asyncio.run(report()))
отчёт из «данные готовы»
Внутри асинхронного мира корутины запускают друг друга await'ом, как обычные функции вызывают друг друга: report дождалась prepare и вплела её результат в свой. Снаружи всю эту цепочку раскрутил один-единственный asyncio.run — глубина вложенности не требует второй точки входа.
Это делает asyncio.run полноценной заменой обычного вызова в скриптах: собрал данные в main — получил их снаружи и продолжил синхронную часть: печать, сохранение, отправку письма. Два мира не мешают друг другу, у каждого своя половина программы.
Синхронная обвязка вокруг точки входа — не костыль, а нормальная структура. До asyncio.run удобно читать аргументы командной строки, конфиги и пути — это обычный код без ожиданий; после — печатать отчёты, сохранять файлы, возвращать код выхода. Асинхронное ядро получается компактным и однородным: только корутины, только await, никакого смешения миров внутри.
Что цикл делает под капотом
Событийный цикл (event loop) — это диспетчер, живущий в одном потоке. У него две папки: «кто готов работать прямо сейчас» и «кто спит до такого-то времени». Каждый оборот цикл делает одно и то же: берёт из папок готовые корутины и выполняет их до ближайшего await, потом смотрит, кто проснётся раньше всех, и дремлет до этого момента. await asyncio.sleep(1) — это «запиши меня в папку спящих на одну секунду», а await fetch(...) — «выполни fetch, а я подожду у папки готовых».
import asyncio
import time
START = time.monotonic()
async def main():
print("первая пауза на t =", round(time.monotonic() - START))
await asyncio.sleep(1)
print("вторая пауза на t =", round(time.monotonic() - START))
await asyncio.sleep(1)
print("финиш на t =", round(time.monotonic() - START))
asyncio.run(main())
первая пауза на t = 0 вторая пауза на t = 1 финиш на t = 2
Каждая пауза — ровно один оборот цикла: корутина легла в «папку спящих», цикл дремал до срока, разбудил, выполнение продолжилось. Пока корутин мало, цикл в основном спит — занять паузу некому. Но механика уже стоит на ногах: стоит добавить в программу вторую корутину, и её работа поместится в чужие паузы — этим займутся задачи и gather.
Кстати, о дремоте: цикл, которому некуда спешить, не крутит холостых оборотов. Пока ближайший таймер далеко, процессор занят другими программами — asyncio почти ничего не расходует. Именно поэтому десять тысяч простаивающих соединений не съедают сервер: соединение, которое ждёт, дешевле файла, который открыт. Механика папок «готовые» и «спящие» одновременно и про удобство, и про экономию.
| Оборот цикла | Что происходит |
|---|---|
| Взять готовых | выполнить каждую готовую корутину до ближайшего await |
| Записать ждущих | await отправил корутину в папку «спящих до t» или «жду результата» |
| Дремать до срока | цикл спит до ближайшего пробуждения — и снова за дело |
Один asyncio.run на программу
Точка входа асинхронной программы — одна. Весь асинхронный код собирается под одной main: сначала asyncio.run(main()), дальше — корутины, задачи и await'ы внутри. Технически вызвать asyncio.run дважды подряд в простом скрипте можно, и второй вызов даже отработает, но это признак путаницы: программа размазана на кусочки, каждый со своим циклом, а выигрыш asyncio — наложение ожиданий друг на друга — доступен только внутри одного запуска. Одна программа — один цикл — одна точка входа.
Хуже обстоит дело, когда asyncio.run пытаются позвать там, где цикл уже работает — внутри корутины или в тетради Jupyter, где цикл крутится постоянно. asyncio отвечает отказом:
import asyncio
async def inner():
asyncio.run(asyncio.sleep(0)) # цикл уже работает!
asyncio.run(inner())
Traceback (most recent call last): ... RuntimeError: asyncio.run() cannot be called from a running event loop
Сообщение asyncio.run() cannot be called from a running event loop стоит запомнить: его чаще всего видят не в скриптах, а в Jupyter и интерактивных консолях — там цикл уже запущен средой, и твои корутины нужно передавать ему другими способами (например, await прямо в клетке). В обычном скрипте защита срабатывает только при попытке запустить цикл изнутри корутины — как в блоке выше.
Вторая половина правила: цикл живёт ровно столько, сколько работает asyncio.run. Снаружи от него асинхронного мира нет — проверим стандартной функцией asyncio.get_running_loop(), которая возвращает работающий цикл или отказывается:
import asyncio
async def main():
loop = asyncio.get_running_loop()
print("внутри: цикл работает -", loop is not None)
asyncio.run(main())
try:
asyncio.get_running_loop()
except RuntimeError as e:
print("снаружи:", e)
внутри: цикл работает - True снаружи: no running event loop
Внутри main цикл на месте — он и раскручивает наш код. Снаружи той же функцией цикл не найти: asyncio.run закрыл его, и асинхронная жизнь в программе закончилась. Отсюда и практика: всё, что должно быть асинхронным, переезжает внутрь main — либо вызывается из неё.
import asyncio
async def main():
print("работаю")
# asyncio.run(main()) # точку входа закомментировали
print("скрипт дошёл до конца")
скрипт дошёл до конца
Ошибок нет, «работаю» не напечатано: main так и осталась объявленной корутиной. Python не напоминает о незапущенных корутинах из stdout — молчаливое завершение выглядит как успех. Антидот — привычка из первого урока: асинхронный скрипт начинается с asyncio.run(main()), и эта строка — последняя в файле.
import asyncio
async def main():
... # вся асинхронная работа - здесь
asyncio.run(main()) # точка входа - последняя строка файла
Что дальше
Под капотом больше нет магии: asyncio.run создаёт цикл, крутит переданную корутину до конца и закрывает цикл; цикл выполняет готовые корутины до ближайшего await и дремлет до ближайшего пробуждения; точка входа одна, и снаружи неё цикла не существует. Следующий урок — про тёмную сторону той же механики: что происходит, когда await потерялся, почему Python лишь предупреждает вместо ошибки и как ловить корутины-призраки в настоящем коде.
asyncio.run делает три вещи: создаёт событийный цикл, крутит корутину до конца и закрывает цикл — точка входа одна на программу.
Сначала предскажи ответ в голове — это главный навык программиста.
import asyncio
async def main():
return 7 * 6
print("ответ:", asyncio.run(main()))
import asyncio
async def job():
print("работаю")
print("начало")
asyncio.run(job())
print("конец")
import asyncio
async def inner():
await asyncio.sleep(0)
return "изнутри"
async def main():
result = asyncio.run(inner())
return result
print(asyncio.run(main()))
1. Что делает asyncio.run(main())?
2. Что вернёт asyncio.get_running_loop() вне asyncio.run, в обычном синхронном коде?
3. Почему вызов asyncio.run изнутри корутины даёт RuntimeError?
4. Какую корутину можно передать в asyncio.run?
5. Скрипт объявил async def main(), но не вызвал asyncio.run. Что напечатает print внутри main?
6. Как цикл проводит время, когда всем корутинам ещё рано просыпаться?
Собери программу с честной границей миров: корутина main ждёт одну секунду (имитация сетевой задержки) и возвращает строку «готово». Снаружи напечатай «до цикла», затем результат asyncio.run в формате «после цикла: готово».
Как запустить async-функцию в Python?
Через asyncio.run: объяви корутину через async def и запусти её точкой входа asyncio.run(main()) — asyncio.run сам создаст событийный цикл, выполнит корутину до конца и закроет цикл. Передать можно любую корутину, не обязательно main; результат корутины asyncio.run возвращает как обычное значение.
Почему asyncio.run не работает в Jupyter Notebook?
В тетрадях событийный цикл уже запущен средой, а asyncio.run запрещает запускать цикл внутри работающего цикла: RuntimeError: asyncio.run() cannot be called from a running event loop. В клетках Jupyter корутины вызывают напрямую await my_coro() — без asyncio.run.
Сколько раз можно вызывать asyncio.run в программе?
Один раз — это точка входа. Весь асинхронный код собирается под одной main: только внутри одного запуска ожидания налагаются друг на друга, ради чего asyncio и существует. Технически два последовательных asyncio.run в простом скрипте сработают, но это признак размазанной структуры, а в Jupyter или изнутри корутины второй вызов упадёт с RuntimeError.
Где стоит вызывать asyncio.run в скрипте?
Последней строкой файла: сначала все объявления async def, затем asyncio.run(main()). До неё — обычный синхронный код подготовки (аргументы, конфиги), внутри main — вся асинхронная работа, после неё — синхронное завершение. Если скрипт завершился мгновенно и ничего не напечатал, первым делом проверь, что эта строка вообще есть.
Понравился урок? Сошлитесь на него
«asyncio.run делает три вещи: создаёт событийный цикл, крутит корутину до конца и закрывает цикл — точка входа одна на программу.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
asyncio · Урок 2
Первая корутина: async def и await
async def — функция с паузами, await — «дождись результата». Звучит просто, пока не выясняется, что вызов async-функции не выполняет её тело. Разбираем оба ключевых слова на живом коде.
asyncio · Урок 4
Coroutine never awaited: главная ошибка новичка
Программа завершилась успешно и ничего не сделала: классика потерянного await. Разбираем корутин-призраков, честно смотрим на RuntimeWarning и выучиваем три лекарства.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
aiogram · Урок 7
Асинхронность в aiogram: asyncio для бота без страха
Разбираем на живом коде, зачем aiogram асинхронный: async и await, asyncio.sleep против time.sleep и gather для параллельных чатов.
asyncio для начинающихaiogram async
pytest · Урок 1
Первый тест на pytest: assert, запуск и первый отчёт
Первая тест-функция на обычном assert: pytest сам находит её, запускает и печатает отчёт — точка, 1 passed и код выхода 0. Всё прямо в браузере.
pytest.mainкак запустить тест pytest
asyncio · Урок 12
Блокирующий вызов останавливает всё
Одна строка time.sleep внутри async-функции — и весь событийный цикл мёртв: никакие задачи не работают, таймеры не тикают. Смотрим на таймстампах и формулируем правило.
asyncio блокировка цикла time.sleeptime.sleep в async функции