asyncio: корутины и цикл событий

⏱ 40 мин · 🧩 заданий: 5

Идея: не ждать впустую

Сервер получает запрос и идёт в базу. Пока база отвечает 50 мс, процессор свободен. Асинхронность позволяет в это время обслужить другие запросы — в одном потоке, переключаясь между задачами в точках ожидания.

import asyncio, time

async def make_coffee(name, seconds):
    print(f"  {name}: начал")
    await asyncio.sleep(seconds)          # «ждём» и отдаём управление другим
    print(f"  {name}: готово")
    return name

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(
        make_coffee("эспрессо", 0.3),
        make_coffee("капучино", 0.5),
        make_coffee("латте", 0.4),
    )
    print(results, f"за {time.perf_counter() - start:.1f} с")   # ≈ 0.5, а не 1.2

asyncio.run(main())

async def и await

  • async def объявляет корутинную функцию. Её вызов ничего не выполняет, а возвращает объект-корутину.
  • await выполняет корутину (или другой awaitable — задачу, Future) и приостанавливает текущую, пока результат не готов. await можно писать только внутри async def.
  • asyncio.run(main()) создаёт цикл событий, выполняет main до конца и закрывает цикл. Это точка входа.
import asyncio, inspect

async def add(a, b):
    await asyncio.sleep(0)
    return a + b

coro = add(2, 3)
print(coro, inspect.iscoroutine(coro))
print(asyncio.run(coro))                   # выполнить

async def main():
    x = await add(1, 2)                    # внутри корутины — через await
    y = await add(x, 10)
    return y

print(asyncio.run(main()))

Цикл событий

┌──────────────── цикл событий (один поток) ────────────────┐ │ готовые задачи: [A, C] ждут: B (сокет), D (таймер) │ │ │ │ 1. взять A → выполнять до ближайшего await → A ждёт сеть │ │ 2. взять C → выполнять до await → C закончилась │ │ 3. спросить ОС (select/epoll): какие сокеты готовы? │ │ 4. B готова → в очередь готовых; таймер D истёк → тоже │ └──────────────────────── повторять ─────────────────────────┘

Многозадачность кооперативная: задача сама отдаёт управление на await. Пока корутина выполняет обычный код без await, никто другой не работает.

Задачи: create_task и gather

Сам по себе await coro() — последовательное выполнение. Чтобы корутины шли конкурентно, их оборачивают в задачи (Task) — задача сразу ставится в цикл событий:

import asyncio, time

async def job(name, delay):
    await asyncio.sleep(delay)
    return f"{name} ({delay} с)"

async def main():
    start = time.perf_counter()
    print(await job("A", 0.3), await job("B", 0.3))          # последовательно
    print(f"  {time.perf_counter() - start:.1f} с")

    start = time.perf_counter()
    task_a = asyncio.create_task(job("A", 0.3))               # уже запущены
    task_b = asyncio.create_task(job("B", 0.3))
    print(await task_a, await task_b)
    print(f"  {time.perf_counter() - start:.1f} с")

    start = time.perf_counter()
    print(await asyncio.gather(job("A", 0.3), job("B", 0.3), job("C", 0.3)))
    print(f"  {time.perf_counter() - start:.1f} с")

asyncio.run(main())

Пройдите пример 👣 Пошагово — видно, как выполнение переключается между корутинами на каждом await.

Не теряйте задачи

Цикл событий хранит только слабые ссылки на задачи. Задачу, созданную через create_task и нигде не сохранённую, может удалить сборщик мусора посреди работы. Сохраняйте ссылки или используйте TaskGroup (следующий урок).

Ловушка: блокирующий код

import asyncio, time

async def bad_worker(name):
    time.sleep(0.3)                      # ✗ блокирует весь поток — и цикл событий
    return name

async def good_worker(name):
    await asyncio.sleep(0.3)             # ✓ отдаёт управление
    return name

async def main():
    start = time.perf_counter()
    await asyncio.gather(*(bad_worker(i) for i in range(3)))
    print(f"time.sleep:          {time.perf_counter() - start:.1f} с")
    start = time.perf_counter()
    await asyncio.gather(*(good_worker(i) for i in range(3)))
    print(f"await asyncio.sleep: {time.perf_counter() - start:.1f} с")

asyncio.run(main())

Блокирующие вызовы в корутинах: time.sleep, requests.get, синхронные драйверы БД (psycopg2), чтение больших файлов, тяжёлые вычисления. Что делать:

Вместо Используйте
time.sleep await asyncio.sleep
requests httpx.AsyncClient, aiohttp
psycopg2, синхронный SQLAlchemy asyncpg, psycopg (async), SQLAlchemy AsyncSession
open().read() больших файлов aiofiles или asyncio.to_thread
синхронная библиотека без async-версии await asyncio.to_thread(func, *args) — в отдельном потоке
тяжёлые вычисления loop.run_in_executor(ProcessPoolExecutor(), ...)

async with и async for

import asyncio

class Connection:
    async def __aenter__(self):              # асинхронный контекстный менеджер
        await asyncio.sleep(0.01)
        print("  подключились")
        return self
    async def __aexit__(self, *exc):
        await asyncio.sleep(0.01)
        print("  отключились")

    async def rows(self):                    # асинхронный генератор
        for i in range(3):
            await asyncio.sleep(0.01)        # «следующая порция из БД»
            yield {"id": i}

async def main():
    async with Connection() as conn:
        async for row in conn.rows():
            print("  ", row)
        print([r["id"] async for r in conn.rows()])   # асинхронное включение

asyncio.run(main())

Так выглядит работа с реальными библиотеками:

async with httpx.AsyncClient() as client:
    response = await client.get("https://api.example.com/users")

async with async_session() as session:
    result = await session.execute(select(User))

Как это устроено

Корутины выросли из генераторов: await работает почти как yield from. Корутина «отдаёт» наружу объект Future («результат будет позже»), цикл событий запоминает, кого разбудить, когда Future получит результат, и продолжает корутину через send():

import asyncio

async def child():
    await asyncio.sleep(0)               # здесь корутина уступает управление
    return 42

coro = child()
print(coro.send(None))                   # 1-й шаг: корутина «отдала» управление (yield внутри sleep(0))
try:
    coro.send(None)                      # 2-й шаг: продолжили — корутина завершилась
except StopIteration as stop:
    print("результат:", stop.value)      # return корутины = StopIteration.value

Цикл событий делает то же самое, но для тысяч корутин, и будит их по готовности сокетов и таймеров.

Вопрос на собеседовании

«Чем asyncio отличается от потоков?» — asyncio работает в одном потоке с кооперативной многозадачностью: задачи переключаются только на await, поэтому нет гонок между двумя await и нет затрат на потоки — можно держать десятки тысяч соединений. Но любой блокирующий вызов останавливает всех, и нужны асинхронные библиотеки. Потоки переключает ОС в любой момент, они работают с обычными блокирующими библиотеками, но дороже и требуют блокировок.

Итог

  • async def создаёт корутину; await ждёт результат, уступая управление; asyncio.run — точка входа.
  • Цикл событий в одном потоке переключает задачи на await (кооперативная многозадачность).
  • await подряд — последовательно; конкурентно — create_task, gather, TaskGroup.
  • Блокирующие вызовы останавливают весь цикл: используйте async-библиотеки или asyncio.to_thread.
  • async with, async for, асинхронные генераторы — для асинхронных ресурсов и потоков данных.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Напишите корутину fetch_all(delays): для каждого числа из delays запустите корутину fake_fetch(i, d) (уже написана — «ждёт» d секунд и возвращает i) конкурентно через asyncio.gather и верните список результатов в исходном порядке. Общее время должно быть ≈ максимальной задержке, а не сумме.

Какие тесты проверяют код (2)
import asyncio; assert asyncio.run(fetch_all([0.05, 0.01, 0.03])) == [0, 1, 2]
import asyncio, time
start = time.perf_counter()
asyncio.run(fetch_all([0.2, 0.2, 0.2, 0.2]))
elapsed = time.perf_counter() - start
assert elapsed < 0.5, f"Задачи выполнялись последовательно: {elapsed:.2f} с вместо ≈0.2 с"

Задание 2 Программа на Python

Напишите асинхронный генератор ticker(n, delay), который выдаёт числа 0..n-1, делая await asyncio.sleep(delay) перед каждым. И корутину collect(n), которая собирает все значения ticker(n, 0.001) через async for и возвращает список.

Какие тесты проверяют код (2)
import asyncio; assert asyncio.run(collect(5)) == [0, 1, 2, 3, 4]
import inspect; assert inspect.isasyncgenfunction(ticker), "ticker должна быть асинхронным генератором (async def + yield)"
Задание 3 Один вариант ответа

Что выведет код?

async def hello():
    return "привет"

print(hello())
Задание 4 Один вариант ответа

В асинхронном обработчике FastAPI вызвали time.sleep(5). Что произойдёт?

Задание 5 Заполнить таблицу

Будут ли корутины выполняться конкурентно?

данет, последовательно
await a(); await b()
await asyncio.gather(a(), b())
t1 = asyncio.create_task(a()); t2 = asyncio.create_task(b()); await t1; await t2
async with asyncio.TaskGroup() as tg: + tg.create_task(a()), tg.create_task(b())
for c in [a(), b()]: await c
Отметьте ячейку — «+», пустая — «−».

Войдите, чтобы отмечать пройденные уроки.