Event loop, async/await и корутины

Тысячи задач в одном потоке без единой блокировки: как работает asyncio.

Event loop (цикл событий) — планировщик в одном потоке, который запускает множество корутин, переключаясь между ними в точках ожидания; корутина — функция, умеющая приостанавливаться и возобновляться.

asyncio решает проблему I/O-конкурентности иначе, чем потоки. Вместо того чтобы ОС вытесняла потоки, корутины сами добровольно уступают управление в точке await. Это называется кооперативной многозадачностью, и работает она в одном потоке без GIL-борьбы и без блокировок.

async и await

async def объявляет корутину. await — это точка «здесь я могу подождать; пока жду, отдаю управление циклу событий, пусть он займётся другими корутинами».

import asyncio

async def fetch(name, delay):
    print(f"{name}: старт")
    await asyncio.sleep(delay)   # уступаю циклу на время ожидания
    print(f"{name}: готово")
    return name

async def main():
    # запускаем три корутины «внахлёст»
    await asyncio.gather(
        fetch("A", 2), fetch("B", 1), fetch("C", 3)
    )

asyncio.run(main())

Этот код использует реальный event loop asyncio, поэтому в браузере мы его не запускаем. Но саму идею кооперативного чередования легко смоделировать.

Модель кооперативного чередования

# корутины как список «шагов до await»; цикл крутит их по очереди
tasks = {"A": ["старт", "ждёт", "готово"],
         "B": ["старт", "готово"],
         "C": ["старт", "ждёт", "готово"]}

step = 0
while any(step < len(s) for s in tasks.values()):
    for name, steps in tasks.items():
        if step < len(steps):
            print(f"loop tick {step}: {name} -> {steps[step]}")
    step += 1

Вывод:

loop tick 0: A -> старт
loop tick 0: B -> старт
loop tick 0: C -> старт
loop tick 1: A -> ждёт
loop tick 1: B -> готово
loop tick 1: C -> ждёт
loop tick 2: A -> готово
loop tick 2: C -> готово

Как работает под капотом

Event loop хранит очередь готовых к выполнению корутин. Дойдя до await на чём-то ещё не готовом (например, сетевой ответ не пришёл), корутина замораживается: цикл запоминает, на чём она остановилась, и берёт следующую готовую. Когда ожидаемое событие происходит, корутину возвращают в очередь. Поскольку всё в одном потоке и переключение только в точках await, гонок на общих данных между await почти не бывает — это большое упрощение. Но есть и обратная сторона: одна корутина с тяжёлым счётом без await «застопорит» весь цикл.

Частые ошибки

  • Звать блокирующую функцию внутри корутины. Обычный time.sleep() или синхронный запрос заморозит весь loop — нужны асинхронные аналоги (asyncio.sleep).
  • Считать loop магически параллельным. Это один поток: CPU-bound код его блокирует, асинхронность помогает только с ожиданием.
  • Забыть await. Корутина без await не запустится — вы получите объект корутины, а не результат.

Итог

  • asyncio — кооперативная многозадачность в одном потоке.
  • Корутины уступают управление добровольно в точках await.
  • Подходит для I/O: тысячи ожиданий без потоков и блокировок.
  • Блокирующий или CPU-bound код останавливает весь event loop.
Проверьте себя
1. Чем многозадачность asyncio отличается от потоков ОС?
AAsyncio использует много ядер сразу
BКорутины уступают управление добровольно в точках await (кооперативно), в одном потоке
CAsyncio запускает по процессу на корутину
DAsyncio полностью убирает ожидание
2. Что произойдёт, если внутри корутины вызвать блокирующий time.sleep() или тяжёлый счёт?
AНичего, loop сам распараллелит
BВесь event loop остановится, пока эта операция не завершится
CСоздастся новый поток
DКорутина превратится в процесс