Корутины, async/await и событийный цикл
Концептуально разбираем, что такое корутина, что делает await и как событийный цикл всем управляет.
Корутина — функция, объявленная через
async def, выполнение которой можно приостанавливать в точкахawaitи возобновлять позже. Событийный цикл (event loop) — планировщик, который решает, какую корутину продолжить.
Анатомия корутины
Корутина объявляется ключевым словом async. Вызов fetch() не запускает её сразу — он возвращает объект-корутину, который нужно отдать событийному циклу.
import asyncio
async def fetch(name, delay):
print(f"{name}: старт")
await asyncio.sleep(delay) # точка приостановки
print(f"{name}: готово через {delay}с")
return name
async def main():
result = await fetch("Запрос", 1)
print("Результат:", result)
asyncio.run(main())
Этот пример помечен как иллюстративный: в браузерной песочнице asyncio.run() не работает, потому что событийный цикл там уже запущен. На реальной машине вывод был бы таким:
Запрос: старт Запрос: готово через 1с Результат: Запрос
Что делает await
Слово await говорит циклу: «эта операция будет ждать — забери у меня управление и займись чем-нибудь полезным, а когда мой результат будет готов, верни управление сюда». Именно в точках await происходит переключение между корутинами.
Важно: ждать (await) можно только awaitable-объекты — другие корутины, задачи (Task) или future. Обычную блокирующую функцию (например, time.sleep) внутри корутины ставить нельзя — она заблокирует весь цикл, и смысл асинхронности теряется. Для пауз есть asyncio.sleep.
Роль событийного цикла
Цикл — это бесконечный планировщик: он держит очередь готовых к работе корутин, выполняет одну до ближайшего await, запоминает, чего она ждёт, и переключается на следующую. Когда ожидаемое событие наступает (таймер истёк, пришёл ответ), цикл возвращается к приостановленной корутине.
asyncio.run(main()) делает три вещи: создаёт событийный цикл, выполняет в нём корутину main() до конца и закрывает цикл.
Кооперативность: модель на генераторах
Чтобы прочувствовать суть переключения без asyncio, смоделируем кооперативный планировщик обычными генераторами. Каждая «корутина» делает шаг и уступает управление через yield — ровно как настоящая уступает его на await.
def task(name, steps):
for i in range(1, steps + 1):
yield f"{name}: шаг {i}/{steps}" # точка уступки управления
def scheduler(tasks):
tasks = list(tasks)
while tasks:
still_running = []
for t in tasks:
try:
print(next(t)) # продвигаем задачу на один шаг
still_running.append(t)
except StopIteration:
pass # задача завершилась
tasks = still_running
scheduler([task("A", 3), task("B", 2)])
Вывод:
A: шаг 1/3 B: шаг 1/2 A: шаг 2/3 B: шаг 2/2 A: шаг 3/3
Обратите внимание на чередование A и B — это и есть конкурентность: задачи продвигаются по очереди, каждая уступает после своего шага. Настоящий событийный цикл устроен сложнее (ждёт реальные I/O-события), но идея переключения в точках уступки та же самая.
Итог
async defсоздаёт корутину; её вызов возвращает объект, а не запускает код.awaitотдаёт управление событийному циклу и помечает точку переключения.- Событийный цикл — планировщик, переключающий корутины в точках
await; запускается черезasyncio.run().