Синхронность, асинхронность и event loop

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

Главная мысль: async не делает код «быстрее» в смысле вычислений. Он позволяет не простаивать, пока программа ждёт ответа от сети или диска, и за это время заняться другим запросом.

Чтобы понять, зачем FastAPI вообще асинхронный, нужно осознать, на что бэкенд тратит время. В типичном API процессор почти не загружен: основная задержка — это ожидание. Запрос пришёл, нужно сходить в базу данных и подождать ответа, дёрнуть внешний платёжный сервис и подождать, прочитать файл и подождать. Пока один запрос «висит» в ожидании базы, синхронный сервер с одним потоком стоит без дела. Асинхронный сервер в этот момент переключается на другой запрос.

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

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

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

Смоделируем кооперативную многозадачность без всякого FastAPI, на чистом stdlib. Две «задачи» по очереди уступают управление через генераторы — ровно так концептуально устроен event loop:

def task(name, steps):
    for i in range(steps):
        print(f"{name}: шаг {i} (ждём ввод-вывод...)")
        yield  # точка, где задача уступает управление (аналог await)
    print(f"{name}: готово")

# простейший планировщик-«event loop»
tasks = [task("запрос-A", 3), task("запрос-B", 2)]
while tasks:
    still_running = []
    for t in tasks:
        try:
            next(t)            # продвигаем задачу на один шаг
            still_running.append(t)
        except StopIteration:
            pass               # задача завершилась
    tasks = still_running

Попробуй сам ▶ Обрати внимание: шаги A и B чередуются, хотя поток один. Так же event loop чередует обработку запросов в моменты await.

В реальном FastAPI это выглядит так. Когда обработчик объявлен async def и внутри делает await db.fetch(...), event loop на время ожидания базы свободен для других запросов:

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    item = await db.fetch_item(item_id)  # тут поток освобождается
    return item

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

Самая частая ошибка — поставить async def, но внутри вызвать блокирующую функцию: обычный синхронный запрос к базе через psycopg2, time.sleep(), тяжёлый цикл по числам. Такой вызов не уступает управление event loop, и весь сервер замирает на это время, обслуживая всех остальных хуже, чем синхронный. Правило: внутри async def всё, что ждёт, должно ждать через await и асинхронные библиотеки.

Вторая ошибка — считать, что асинхронность ускоряет вычисления. Перемножение матриц или хеширование пароля — это работа процессора, а не ожидание; async тут не помогает и даже мешает, такие задачи выносят в отдельные потоки или процессы.

Best practices

  • Используйте async def, когда внутри есть настоящие await к асинхронным библиотекам (БД, HTTP-клиенты, очереди).
  • Никогда не вызывайте блокирующий код напрямую в корутине — оборачивайте через run_in_threadpool или используйте sync-обработчик.
  • CPU-тяжёлые задачи выносите в фоновые воркеры (Celery, процессы), а не в event loop.

Конкурентность и параллелизм — не одно и то же

Эти слова часто путают, а различие принципиальное. Конкурентность (concurrency) — это умение управляться со многими задачами, чередуя их; параллелизм (parallelism) — это одновременное выполнение на нескольких ядрах. Async в Python даёт именно конкурентность: один поток, одно ядро, но иллюзия одновременности за счёт переключения в точках ожидания. Это идеально для задач, ограниченных вводом-выводом (I/O-bound), где время уходит на ожидание сети и диска. Для задач, ограниченных процессором (CPU-bound) — шифрование, обработка изображений, числодробление — нужен настоящий параллелизм через процессы, потому что вычисления не уступают управление сами по себе, и event loop тут бессилен. Запомните дихотомию: I/O-bound лечится асинхронностью, CPU-bound — процессами. Большинство веб-API именно I/O-bound, поэтому ставка FastAPI на async оправдана.

Итог: async — это про ожидание, а не про скорость вычислений. Event loop чередует задачи в точках await. В следующем уроке посмотрим, как FastAPI обращается с обычным def.

Проверьте себя
1. Что делает event loop, когда корутина доходит до await на сетевой запрос?
AЗавершает программу
BБлокирует поток до получения ответа
CОткладывает эту задачу и переключается на другую готовую
DСоздаёт новый процесс операционной системы
2. Что произойдёт, если внутри async def вызвать блокирующий time.sleep()?
AНичего, FastAPI распараллелит автоматически
BЗаблокируется весь event loop, и сервер перестанет обслуживать другие запросы на это время
CPython выбросит синтаксическую ошибку
DЗапрос ускорится