Конкурентность и 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-тяжёлый код занимает единственный поток и останавливает обслуживание всех запросов — выносите такое в потоки или процессы.