Конкурентность и event loop

Урок объясняет, как один однопоточный процесс держит тысячи одновременных запросов, что именно блокирует event loop и как параллелить независимые вызовы через asyncio.gather.

Конкурентность — это умение переключаться между многими задачами, отдавая управление на время ожидания; в отличие от параллелизма (много дел физически одновременно), здесь работает один поток, но он никогда не простаивает впустую.

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

В этом уроке — без FastAPI, на чистом asyncio из стандартной библиотеки: ровно тот же event loop, что лежит под FastAPI, поэтому все примеры тут исполнимы прямо в браузере.

Зачем это на практике

Допустим, обработчик запроса делает три независимых сетевых вызова: к сервису профиля, к сервису заказов и к сервису рекомендаций. Если делать их по очереди (await один за другим), общее время — сумма трёх задержек. Если запустить их конкурентно (asyncio.gather), общее время — максимум из трёх, потому что ожидания накладываются друг на друга. На медленных сетевых вызовах это разница между ответом за 900 мс и за 300 мс.

Та же идея объясняет, почему один процесс обслуживает множество клиентов: пока запрос A ждёт базу, цикл переключается на запрос B, потом C, и так держит десятки тысяч соединений «в полёте».

Один поток, много задач

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

Проследим переключения. await asyncio.sleep(0) — это «уступи управление прямо сейчас». Две задачи будут чередоваться на каждом таком уступе:

import asyncio

order = []

async def task(name, ticks):
    for _ in range(ticks):
        order.append(name)
        await asyncio.sleep(0)     # уступаем управление циклу
    return name

async def main():
    await asyncio.gather(task("A", 3), task("B", 2))
    print("порядок переключений:", " ".join(order))

asyncio.run(main())

Вывод:

порядок переключений: A B A B A

Видно чередование: A поработала до await, уступила — пошла B, уступила — снова A, и так далее. Когда у B закончились тики, осталась только A. Это и есть кооперативная многозадачность: задачи добровольно отдают управление в точках await, и цикл их чередует. Никаких потоков — один поток, но он постоянно занят то одной задачей, то другой.

Последовательно против gather

Теперь — главный практический приём. Сначала три «сетевых вызова» по 0.3 c по очереди:

import asyncio, time

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

async def sequential():
    start = time.perf_counter()
    a = await fetch("A", 0.3)
    b = await fetch("B", 0.3)
    c = await fetch("C", 0.3)
    dur = time.perf_counter() - start
    print(f"последовательно: {[a, b, c]} за ~{round(dur, 1)} c")

asyncio.run(sequential())

Вывод:

старт A
готово A
старт B
готово B
старт C
готово C
последовательно: ['A:0.3', 'B:0.3', 'C:0.3'] за ~0.9 c

Каждый await ждёт завершения предыдущего: 0.3 + 0.3 + 0.3 = 0.9 c. Вызовы независимы, но мы зря ждём их по очереди. Запустим те же три конкурентно через asyncio.gather:

import asyncio, time

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

async def parallel():
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch("A", 0.3),
        fetch("B", 0.3),
        fetch("C", 0.3),
    )
    dur = time.perf_counter() - start
    print(f"параллельно: {results} за ~{round(dur, 1)} c")

asyncio.run(parallel())

Вывод:

параллельно: ['A:0.3', 'B:0.3', 'C:0.3'] за ~0.3 c

Те же данные — за 0.3 c вместо 0.9 c. gather запустил все три корутины, и пока каждая «спала», цикл занимался остальными: ожидания наложились. Результаты возвращаются в порядке аргументов, независимо от того, кто завершился раньше. Это главный инструмент ускорения, когда у вас несколько независимых await-операций в одном обработчике.

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

Под FastAPI лежит ASGI-сервер (uvicorn) с event loop (обычно uvloop — быстрая реализация на C). Каждый входящий запрос становится задачей в цикле. Когда обработчик доходит до await на сетевом сокете или асинхронном драйвере БД, задача снимается с исполнения, а ОС через epoll/kqueue сообщит циклу, когда сокет готов. В это время цикл крутит другие задачи — другие запросы.

asyncio.gather не создаёт потоков и не даёт «настоящего параллелизма» CPU. Он лишь планирует несколько корутин в одном цикле, чтобы их ожидания перекрывались. Поэтому выигрыш есть на I/O-bound работе (сеть, диск, БД), где время уходит на ожидание. Для CPU-bound (тяжёлые вычисления) gather не ускорит — наоборот, такие задачи блокируют цикл, и их выносят в процессы.

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

Делать независимые await по очереди. Если три вызова не зависят друг от друга, await a; await b; await c тратит сумму времён. Объедините их в asyncio.gather — получите максимум, а не сумму.

Блокировать цикл синхронным или CPU-кодом. Любой не-await-ный тяжёлый кусок (числодробилка, time.sleep, синхронный драйвер) занимает единственный поток — и пока он работает, ни один другой запрос не обслуживается. Это убивает всю конкурентность.

Считать gather параллелизмом по CPU. gather перекрывает ожидания, но не считает в несколько потоков. Тяжёлые вычисления он не ускорит; для них нужны процессы (ProcessPoolExecutor) или вынос в воркеры.

Игнорировать ошибки в gather. По умолчанию первое исключение в любой корутине прерывает gather и пробрасывается наверх. Если нужно собрать и успехи, и ошибки, используйте return_exceptions=True и разбирайте результаты.

Итоги

  • Event loop — один поток, который чередует задачи в точках await: пока одна ждёт I/O, исполняются другие, поэтому один процесс держит тысячи соединений.
  • Конкурентность ≠ параллелизм: выигрыш возникает на ожидании (сеть/БД/диск), а не на вычислениях.
  • asyncio.gather запускает независимые корутины так, что их ожидания перекрываются — время становится максимумом, а не суммой.
  • Результаты gather возвращаются в порядке аргументов; по умолчанию первое исключение прерывает всю группу.
  • Любой блокирующий или CPU-тяжёлый код занимает единственный поток и останавливает обслуживание всех запросов — выносите такое в потоки или процессы.
Проверьте себя
1. Три независимых сетевых вызова по 0.3 c. Сколько примерно займёт asyncio.gather на все три?
AОколо 0.9 c — сумма всех задержек
BОколо 0.3 c — максимум из задержек, ожидания перекрываются
CОколо 0.1 c — gather распараллеливает по ядрам
DЗависит от числа потоков в пуле
2. Почему один однопоточный процесс FastAPI способен обслуживать тысячи одновременных запросов?
AОн создаёт по потоку на каждое соединение
BВеб-нагрузка в основном ждёт I/O, и на время ожидания event loop переключается на другие запросы
Cuvicorn запускает отдельный процесс на каждый запрос
DЗапросы выполняются строго по очереди, но очень быстро
3. Что НЕ ускорит asyncio.gather?
AНесколько независимых HTTP-запросов
BНесколько независимых запросов к асинхронному драйверу БД
CТяжёлые CPU-вычисления (числодробилка) без await
DПараллельное чтение нескольких URL