Перейти к содержанию

192 уроков, 14 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 3 из 20 Начальный 30 мин 110 XP

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
Блок не запускается намеренно: он вызывает ошибку. В настоящем traceback строк с файлами и номерами много — показан только финал: последняя строка и есть суть.

Сообщение 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())            # точка входа - последняя строка файла
Каркас для чтения: наполнение 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()))
Проверь себя
0 / 6

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. Как цикл проводит время, когда всем корутинам ещё рано просыпаться?

Карточки терминов
Запомнено: 0 / 6
Практика

Собери программу с честной границей миров: корутина main ждёт одну секунду (имитация сетевой задержки) и возвращает строку «готово». Снаружи напечатай «до цикла», затем результат asyncio.run в формате «после цикла: готово».

practice.py
Вопросы и ответы по уроку

Как запустить 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.