Сервис на FastAPI обслуживает тысячу одновременных запросов одним потоком, и это выглядит как магия ровно до первого инцидента, когда один запрос остановил все остальные. Магии нет: есть цикл событий, который по очереди даёт поработать корутинам, и корутины, которые добровольно отдают управление на await. Разберём, как это устроено, потому что все дальнейшие статьи раздела опираются на эту модель.
Функция и корутина
Обычная функция выполняется от начала до конца и возвращает значение. Корутина, объявленная через async def, при вызове не выполняется вовсе: вызов возвращает объект-корутину, который умеет приостанавливаться и продолжаться с того же места.
живой пример
import asyncio
async def fetch_price(sku: str) -> int:
await asyncio.sleep(0.1) # здесь корутина приостанавливается и отдаёт управление
return 100
coro = fetch_price("A-1") # ничего не выполнилось, это объект корутины
print(type(coro).__name__) # coroutine
result = asyncio.run(coro) # цикл событий выполнил корутину до конца
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если объект корутины создать и не передать ни в await, ни в задачу, Python при сборке мусора напечатает RuntimeWarning: coroutine 'fetch_price' was never awaited. Это самая частая ошибка новичка: вызвали async def как обычную функцию, и код «не сработал» без исключения.
Кто выполняет корутину
Корутины сами по себе не выполняются. Их запускает цикл событий (event loop): объект, который держит очередь готовых к выполнению шагов и список ожидаемых событий ввода-вывода. asyncio.run(main()) создаёт цикл, выполняет main до конца, отменяет оставшиеся задачи и закрывает цикл. Во FastAPI цикл создаёт uvicorn, и каждый обработчик async def выполняется внутри него.
Цикл работает так: берёт готовый шаг, выполняет его до ближайшего await, на котором корутине нечего делать (ждёт сеть, таймер, другую задачу), запоминает, чего она ждёт, и берёт следующий готовый шаг. Когда операционная система сообщает, что данные пришли, цикл помечает ждущую корутину готовой. Один поток, никаких переключений потоков, никакой параллельности: в каждый момент времени выполняется ровно одна корутина.
Это называется кооперативной многозадачностью. Кооперативной, потому что корутина сама решает, когда отдать управление. Если она не отдаёт, никто другой не выполняется.
Что происходит на await
await x делает две вещи. Во-первых, дожидается результата x, где x это другая корутина, задача или Future. Во-вторых, и это главное, даёт циклу событий возможность переключиться на другие задачи, но только если x действительно нужно ждать. await корутины, которая не делает ввода-вывода, выполнится без переключения, как обычный вызов функции.
async def handler():
data = await db.fetch_one(query) # ждёт сеть: цикл переключится на другие задачи
total = compute(data) # обычная функция: цикл ждёт, пока она закончится
await asyncio.sleep(0) # явно отдать управление, даже если ждать нечего
return total
Отсюда правило, которое объясняет большинство проблем с производительностью асинхронных сервисов: между двумя await код выполняется неразрывно, и всё это время остальные задачи стоят. Тяжёлый расчёт, синхронный запрос к базе через обычный драйвер, time.sleep, чтение большого файла обычным open блокируют цикл целиком. Как находить такие места и что с ними делать, разбирает статья про блокирующий код.
Почему один поток держит тысячи соединений
Сетевой сервис большую часть времени ждёт: клиента, базу, соседний сервис. Поток, который ждёт, не нужен процессору, но занимает память и время на переключение. Цикл событий заменяет тысячу ждущих потоков одним, который спрашивает операционную систему «на каких сокетах есть данные» и обрабатывает только их. Пока запрос ждёт базу, его корутина не занимает ничего, кроме небольшого объекта в памяти.
Это даёт выигрыш только на нагрузке с ожиданием. Если обработчик считает хеши или перемножает матрицы, ждать нечего, и один поток asyncio медленнее пула потоков или процессов. Поэтому asyncio берут для сетевых сервисов, а вычисления выносят, об этом статья про свободные потоки и процессы.
Три объекта, с которыми вы будете работать
Корутина это код, который можно приостановить. Задача (Task) это корутина, поставленная в цикл событий на выполнение; задача выполняется параллельно с другими задачами в смысле чередования, и у неё есть результат, исключение и возможность отмены. Future это обещание результата, низкоуровневый объект, который заполняет кто-то снаружи, например, обратный вызов драйвера; в прикладном коде Future встречается редко, но его интерфейсом обладает и задача.
async def main():
task = asyncio.create_task(fetch_price("A-1")) # задача запущена, main продолжается
other = await fetch_price("B-2") # main ждёт здесь, task выполняется параллельно
first = await task # результат задачи
Разница между await fetch_price() и asyncio.create_task(fetch_price()) принципиальна: первое выполняет корутину прямо сейчас и ждёт, второе планирует её и возвращает управление немедленно. Что происходит с задачами дальше, как их ждать и отменять, рассказывает статья про задачи и отмену.
Чего в asyncio нет
Нет параллельного выполнения кода Python: две корутины никогда не выполняются одновременно, поэтому обычные структуры данных можно менять без блокировок, пока между проверкой и изменением нет await. Нет вытеснения: цикл не прервёт корутину, которая не отдаёт управление. Нет автоматической защиты от блокирующих вызовов: синхронная библиотека внутри async def работает, просто останавливает всех. И нет совместимости циклов: asyncio.run внутри уже работающего цикла падает с RuntimeError: asyncio.run() cannot be called from a running event loop, поэтому библиотеки не запускают свой цикл, а принимают уже существующий.
Глубже: что цикл делает за один оборотрасширенное
Один оборот цикла событий состоит из трёх шагов. Сначала цикл вычисляет, сколько можно ждать: если есть готовые к выполнению колбэки, ноль, иначе до ближайшего таймера. Затем он вызывает селектор операционной системы (kqueue на macOS, epoll на Linux) с этим таймаутом и получает список сокетов, на которых что-то произошло; для каждого ставит в очередь колбэк, который разбудит соответствующую задачу. Потом выполняет всю очередь готовых колбэков, накопленную к началу этого шага, по одному: каждый колбэк это продолжение какой-то корутины до её следующего await. Новые колбэки, появившиеся во время выполнения, ждут следующего оборота, поэтому await asyncio.sleep(0) честно отдаёт управление всем остальным готовым задачам, а не только ближайшей.
Из этого устройства следует, как измеряется «блокировка цикла»: это длительность одного колбэка. В режиме отладки цикл замеряет каждый и пишет предупреждение о колбэках дольше slow_callback_duration, по умолчанию 0,1 секунды. Как включить и читать это, разбирает статья про отладку.
С Python 3.12 есть ещё одна тонкость: asyncio.eager_task_factory позволяет задаче начать выполняться сразу при создании, до первого await, а не на следующем обороте цикла. Для задач, которые часто завершаются без ожидания (попадание в кеш), это экономит оборот цикла и объект задачи, но меняет порядок выполнения, поэтому включается осознанно через loop.set_task_factory.
Коротко
async defпри вызове возвращает объект корутины, а не выполняет код; забытыйawaitдаётRuntimeWarning: coroutine ... was never awaited.- Корутины выполняет цикл событий в одном потоке: в каждый момент работает ровно одна, переключение происходит только на
await, которому есть чего ждать. - Между двумя
awaitкод выполняется неразрывно; тяжёлый расчёт или синхронный ввод-вывод останавливает все задачи процесса. - Выигрыш asyncio только на нагрузке с ожиданием (сеть, база); для вычислений нужны потоки без GIL, процессы или подинтерпретаторы.
await coro()выполняет сейчас и ждёт,asyncio.create_task(coro())планирует и возвращает управление; задача это корутина в цикле с результатом и отменой.- Нет параллелизма и вытеснения: структуры данных безопасны между
await, аasyncio.runвнутри цикла запрещён. - Один оборот цикла: селектор ОС, затем очередь готовых колбэков; длительность колбэка и есть мера блокировки.
Что почитать дальше
- Задачи и отмена — что происходит с задачей при
cancelи почемуCancelledErrorнельзя глотать. - Блокирующий код и потоки — как найти место, которое останавливает цикл, и вынести его.
- Async и конкурентность во FastAPI — как цикл событий выглядит со стороны обработчиков FastAPI.