asyncio і FastAPI
Як написати HTTP-сервіс на FastAPI, як asyncio перемикає корутини в одному потоці й чим це відрізняється від async/await у C#, де виконуються ендпоінти і як обрати між потоками, процесами та asyncio. На співбесіді з Python-бекенду це ядро практичних запитань.
Перед цією темою: Мова Python, Асинхронність у .NET.
Як написати простий REST-сервіс на FastAPI?
Section titled “Як написати простий REST-сервіс на FastAPI?”Що це
FastAPI — фреймворк для HTTP API на Python. Ендпоінт — функція з декоратором, який задає метод і шлях: @app.get("/items/{item_id}").
Як користуватися
- Параметр з ім’ям зі шляху (
item_id) — path-параметр. - Параметр простого типу, якого немає у шляху, — query:
?currency=USD. Значення за замовчуванням робить його необов’язковим. - Параметр-модель Pydantic — JSON-тіло запиту.
- Код відповіді задають у декораторі (
status_code=201), помилку — виняткомHTTPException(status_code=404).
Пакет встановлюють командою pip install "fastapi[standard]", запускають fastapi dev main.py, документація відкривається на /docs.
Типова помилка
Перевіряти типи вручну. Якщо оголошено item_id: int, а прийшло abc, FastAPI сам відповість 422 з описом помилки.
Як влаштовано
- Під час старту FastAPI читає сигнатури функцій і будує для кожного маршруту модель параметрів.
- На запит він дістає значення зі шляху, рядка запиту, заголовків і тіла та перевіряє їх через Pydantic.
- Помилки валідації збираються у відповідь 422: де поле (
loc) і що не так (msg). - Результат серіалізується в JSON. Якщо вказано тип результату або
response_model, відповідь перевіряється й фільтрується за цією моделлю.
Звідки береться параметр
| Оголошення | Джерело |
|---|---|
| ім’я є в шаблоні шляху | шлях |
int, str, bool та інші прості типи |
рядок запиту |
| модель Pydantic | JSON-тіло |
Query(), Header(), Body() в Annotated |
вказано явно |
Пастки
- Списки.
list[str]безQuery()вважається тілом, а не query-параметром. - М’яка валідація. За замовчуванням Pydantic приводить типи: рядок
"2"стане числом2. Потрібна суворість —Field(strict=True)абоConfigDict(strict=True). Content-Type. З FastAPI 0.132 JSON-тіло без заголовкаContent-Type: application/jsonвідхиляється з 422.
Правило
Контракт описуйте типами й моделями, а не ручними перевірками: тоді валідація, документація й код не розходяться.
Під капотом
- Клас
FastAPIуспадковуєStarlette: маршрутизація, middleware і робота з HTTP — звідти. FastAPI додає розбір параметрів, залежності й OpenAPI. - Схеми параметрів і відповідей будуються один раз під час старту. На запиті працює валідація в pydantic-core — ядрі Pydantic на Rust.
- З FastAPI 0.130 відповідь із Pydantic-моделлю або оголошеним типом результату серіалізується в JSON прямо в Rust-ядрі Pydantic.
- Документ OpenAPI 3.1 віддається на
/openapi.json, інтерфейси — на/docs(Swagger UI) і/redoc.
Ціна
- Розбір і валідація тіла виконуються на кожному запиті. Величезний JSON помітно навантажує CPU процесу.
- Модель відповіді — друга перевірка: дані валідуються на виході ще раз.
Компроміси
- Тип результату — захист від витоку: поля, яких немає в моделі відповіді, наприклад
password_hash, у JSON не потраплять. Але та сама фільтрація мовчки губить поля, якщо модель забули оновити. - Окремі моделі входу й виходу (
ItemCreate,ItemOut) — те саме, що різні DTO запиту й відповіді в ASP.NET Core. - Публічну документацію в продакшені часто вимикають:
FastAPI(docs_url=None, redoc_url=None).
Як перевірити в продакшені
- Контракт фіксують тестом:
TestClientотримує/openapi.jsonі порівнює зі знімком, тому поломка API видна в CI. - Сплеск 422 у метриках — клієнти надсилають не те, на що розрахована схема.
Підсумок: FastAPI перетворює сигнатуру на контракт API; платять за це валідацією на кожному запиті й дисципліною в моделях відповіді.
# pip install "fastapi[standard]"; запуск: fastapi dev main.pyfrom fastapi import FastAPI, HTTPExceptionfrom pydantic import BaseModel, Field
app = FastAPI()
class Item(BaseModel): # модель тіла запиту name: str price: float = Field(gt=0) # перевірка: ціна більша за нуль
items: dict[int, Item] = {}
@app.post("/items", status_code=201)async def create_item(item: Item) -> dict: # тіло — модель Pydantic item_id = len(items) + 1 items[item_id] = item return {"id": item_id, **item.model_dump()}
@app.get("/items/{item_id}")async def read_item(item_id: int, currency: str = "UAH") -> dict: # path і query if item_id not in items: raise HTTPException(status_code=404, detail="Item not found") return {"name": items[item_id].name, "currency": currency}
# POST /items {"name": "Tea", "price": 42} → 201 {"id": 1, "name": "Tea", "price": 42.0}# GET /items/1?currency=USD → 200 {"name": "Tea", "currency": "USD"}# GET /items/abc → 422: item_id не число; GET /items/2 → 404 {"detail": "Item not found"}- Як зробити query-параметр обов’язковим? — Не задавати значення за замовчуванням.
- Що поверне FastAPI, якщо в тілі немає обов’язкового поля? — 422 зі списком помилок:
loc,msg,type. - Навіщо вказувати тип результату ендпоінта? — Він перевіряє й фільтрує відповідь і потрапляє в OpenAPI-схему.
- Як прийняти список у query-параметрі? —
Annotated[list[str], Query()], інакше список вважається тілом. - Де подивитися й перевірити API без Postman? — На
/docs: Swagger UI будується з тієї самої схеми.
Як працює asyncio і чим він відрізняється від async/await у C#?
Section titled “Як працює asyncio і чим він відрізняється від async/await у C#?”Що це
- Корутина — функція
async def. Її виклик не виконує код, а створює об’єкт, на який треба дочекатися черезawait. - Цикл подій — диспетчер в одному потоці: поки одна корутина чекає відповіді мережі, він виконує іншу.
asyncio.run(main())створює цикл, виконуєmainі закриває цикл.
Як користуватися
awaitпишуть лише всерединіasync def.- Щоб операції йшли одночасно, їх запускають разом:
await asyncio.gather(a(), b())або черезTaskGroup. - Пауза —
await asyncio.sleep(1), а неtime.sleep(1).
Типова помилка
Викликати блокувальну функцію (time.sleep, requests.get) усередині async def. Потік один, тому стануть усі корутини.
Як влаштовано
asyncio.create_task(coro)ставить корутину в чергу циклу. Без задачі абоawaitкорутина не почне роботу.- Задача виконується до
awaitна незавершеній операції, потім цикл бере наступну готову. - Коли введення-виведення завершилося, цикл відновлює задачу, що чекає, з того самого місця.
Порівняння з C#
| Ознака | asyncio | C# |
|---|---|---|
| Де виконується | один потік циклу | продовження в пулі потоків |
| Запуск | корутина лінива | Task стартує під час виклику |
| Чекати кілька | gather, TaskGroup |
Task.WhenAll |
| Таймаут | asyncio.timeout() |
CancelAfter, WaitAsync |
| Скасування | task.cancel() |
CancellationToken |
| Блокувальний виклик | стоїть увесь цикл | зайнятий один потік пулу |
Пастки
- Загублена задача. Цикл тримає на задачі лише слабкі посилання: задачу без збереженого посилання збирач сміття може видалити до завершення.
gatherу разі помилки віддає перший виняток, а решта задач продовжують працювати.TaskGroupскасовує решту й збирає помилки вExceptionGroup, який ловлять черезexcept*.- Проковтнуте скасування.
CancelledErrorуспадковуєBaseException. Зловили його заради очищення — прокиньте далі черезraise.
Правило
Конкурентність — через TaskGroup і asyncio.timeout (3.11+), блокувальний код — через asyncio.to_thread.
Під капотом
- Задача просуває корутину методом
send(). Дійшовши до незавершеногоFuture, корутина віддає його задачі, та підписується на завершення й повертає керування циклу. - Кадр корутини сам зберігає локальні змінні. Компілятор не генерує окремий клас, як state machine у C#.
- Ітерація циклу: виконати готові колбеки, спитати ОС про готове введення-виведення (epoll у Linux, IOCP через
ProactorEventLoopу Windows), запланувати продовження. cancel()не перериває потік, а кидаєCancelledErrorпід час наступного відновлення. Лічильникcancelling()і методuncancel()(3.11) допомагаютьTaskGroupіtimeoutвідрізнити своє скасування від зовнішнього.
Ціна
- Задача — об’єкт у купі без власного стека, тому десятки тисяч задач — норма.
- Код між
awaitвиконується монопольно: розбір JSON на 200 мс затримує всі запити процесу на 200 мс.
Компроміси
- Гонок усередині ділянки без
awaitнемає, але стан, який читають доawaitі змінюють після, потребуєasyncio.Lock. - Синхронний світ — драйвери, SDK — під’єднується через
to_thread, тобто через звичайні потоки з усіма їхніми обмеженнями.
Як перевірити в продакшені
- Режим налагодження (
PYTHONASYNCIODEBUG=1абоasyncio.run(main(), debug=True)) логує колбеки, довші за 100 мс, — так знаходять блокувальні виклики. - З Python 3.14
python -m asyncio ps PIDіpstree PIDпоказують задачі живого процесу й хто кого чекає. Task exception was never retrievedу лозі — задачу запустили й не дочекалися.
Підсумок: asyncio — один потік з явними точками перемикання; його надійність тримається на структурній конкурентності, чесному скасуванні й відсутності блокувальних викликів.
import asyncioimport time
async def fetch(name: str, delay: float) -> str: await asyncio.sleep(delay) # віддає керування циклу подій return name
async def main() -> None: start = time.perf_counter() async with asyncio.TaskGroup() as tg: # Python 3.11+ a = tg.create_task(fetch("a", 1)) b = tg.create_task(fetch("b", 1)) print(a.result(), b.result(), round(time.perf_counter() - start, 1)) # a b 1.0
try: async with asyncio.timeout(0.5): # Python 3.11+ await fetch("slow", 2) except TimeoutError: print("timeout") # timeout
start = time.perf_counter() await asyncio.gather(asyncio.to_thread(time.sleep, 1), fetch("c", 1)) print(round(time.perf_counter() - start, 1)) # 1.0: блокувальний sleep пішов у потік
asyncio.run(main())Якщо викликати time.sleep(1) прямо в корутині, gather з останнього блоку займе 2 секунди: цикл стоїть, поки потік спить.
import asyncio
async def worker() -> None: try: await asyncio.sleep(10) except asyncio.CancelledError: print("cleanup") # звільняємо ресурси raise # і обов'язково прокидаємо далі
async def main() -> None: task = asyncio.create_task(worker()) await asyncio.sleep(0.1) # даємо задачі почати роботу task.cancel() # CancelledError виникне в точці await try: await task except asyncio.CancelledError: print(task.cancelled()) # True
asyncio.run(main())- Що буде, якщо викликати
async def-функцію безawait? — Створиться об’єкт корутини, код не виконається, Python попередитьcoroutine ... was never awaited. - Чим
gatherвідрізняється відTaskGroupу разі помилки? —gatherне скасовує решту задач,TaskGroupскасовує й піднімаєExceptionGroup. - Як викликати синхронну бібліотеку з async-коду? —
await asyncio.to_thread(func, *args). - Чому не можна ковтати
CancelledError? —TaskGroup,timeoutі зупинка сервісу чекають, що скасована задача завершиться. - Чи бувають гонки в однопотоковому asyncio? — Так, якщо стан читають до
await, а змінюють після; потрібенasyncio.Lock.
Як влаштований FastAPI?
Section titled “Як влаштований FastAPI?”Що це
Застосунок FastAPI запускає ASGI-сервер Uvicorn. Сервер тримає з’єднання, FastAPI обробляє запити.
Як користуватися
- Залежності. Спільний код — сесію БД, поточного користувача — пишуть функцією й під’єднують параметром
Annotated[Session, Depends(get_db)]. FastAPI викличе її на кожен запит. async defчиdef. Усередині лишеawait-виклики —async def. Синхронна бібліотека (requests, драйвер БД без async) —def, FastAPI виконає його в пулі потоків.- Запуск.
fastapi dev main.py— розробка з перезавантаженням,fastapi run main.py— продакшен. - Помилки.
raise HTTPException(status_code=404, detail="...")перетворюється на JSON{"detail": "..."}.
Типова помилка
requests.get усередині async def. Поки йде запит, сервер не відповідає нікому.
Як влаштовано
- Uvicorn приймає запит і викликає ASGI-застосунок у своєму циклі подій.
- FastAPI знаходить маршрут і розв’язує залежності. Кожна викликається один раз на запит, навіть якщо потрібна кільком.
- Параметри проходять валідацію Pydantic v2; помилка — 422.
- Ендпоінт і залежності
async defвикликаються в циклі,def— у пулі потоків AnyIO. - Після надсилання відповіді виконується код після
yieldу залежностях.
Порівняння з ASP.NET Core
| ASP.NET Core | FastAPI |
|---|---|
реєстрація в builder.Services |
немає: залежність — функція в сигнатурі |
Scoped з Dispose |
залежність із yield і finally |
Singleton, IHostedService |
об’єкт із lifespan |
ConfigureTestServices у тестах |
app.dependency_overrides |
винятки та ProblemDetails |
HTTPException, @app.exception_handler |
Пастки
- Пул потоків не гумовий.
def-ендпоінти й залежності ділять 40 потоків: якщо всі чекають повільний сервіс, нові запити стоять у черзі. - Lifespan у кожному воркері.
--workers 4— чотири пули з’єднань і чотири копії моделі в пам’яті. exceptбезraiseу залежності зyieldховає виняток ендпоінта від FastAPI.
Правило
async def — коли всі виклики асинхронні, def — коли є блокувальні, важкі обчислення — поза процесом вебсервера.
Під капотом
- Маршрут —
APIRoute: під час старту він будує дерево залежностей і моделі Pydantic для параметрів і відповіді. - Функції
defідуть уrun_in_threadpoolStarlette, тобто вanyio.to_thread.run_syncзі спільним лімітом 40 потоків на процес. uvicorn --workers Nабоfastapi run --workers N— менеджер процесів: кожен воркер імпортує застосунок, виконує lifespan і слухає спільний порт.
Ціна
def— перехід у потік і назад на кожен виклик. Документація FastAPI прямо радить тривіальні функції без блокувального введення-виведення оголошуватиasync def.- 40 потоків — стеля одночасних синхронних викликів у процесі. Її піднімають через
anyio.to_thread.current_default_thread_limiter().total_tokensу lifespan. - Воркери не ділять пам’ять: кеші, пули з’єднань і моделі множаться на N.
Компроміси
- Воркери дають паралелізм за CPU, але в Kubernetes зазвичай запускають один процес на контейнер і масштабують репліками.
- Залежність із
yieldза замовчуванням закривається після надсилання відповіді. З FastAPI 0.121Depends(get_db, scope="function")закриває її одразу після ендпоінта — наприклад, щоб раніше повернути з’єднання в пул.
Як перевірити в продакшені
- Затримка росте, а CPU низький — блокувальний виклик в
async defабо вичерпаний пул потоків дляdef. py-spy dump --pid PIDпоказує стеки всіх потоків: видно, що тримає потік циклу.
Підсумок: FastAPI — Starlette із графом залежностей за сигнатурами; продуктивність вирішують вибір async def чи def, ліміт пулу потоків і кількість процесів.
from contextlib import asynccontextmanagerfrom typing import Annotatedfrom fastapi import Depends, FastAPI, Request
class Model: # дорогий об'єкт: один на процес def predict(self, text: str) -> str: return text.upper()
@asynccontextmanagerasync def lifespan(app: FastAPI): app.state.model = Model() # до першого запиту yield app.state.model = None # під час зупинки сервера
app = FastAPI(lifespan=lifespan)
async def get_model(request: Request) -> Model: return request.app.state.model
async def get_session(): session = {"open": True} # наприклад, сесія БД try: yield session # віддаємо залежність ендпоінту finally: session["open"] = False # закриваємо після відповіді
@app.get("/predict")async def predict( text: str, model: Annotated[Model, Depends(get_model)], session: Annotated[dict, Depends(get_session)],) -> dict: return {"result": model.predict(text), "session": session["open"]}# GET /predict?text=hi → {"result": "HI", "session": true}У тесті модель підміняють без зміни коду: app.dependency_overrides[get_model] = lambda: FakeModel().
- Чому
def-ендпоінт не блокує сервер, аasync defзtime.sleepблокує? —defіде в пул потоків,async defвиконується в потоці циклу. - Скільки разів викличеться залежність, яка потрібна двом іншим? — Один раз за запит;
Depends(dep, use_cache=False)вимикає кеш. - Як підмінити залежність у тесті? —
app.dependency_overrides[get_db] = fake_get_db. - Чому lifespan не спрацьовує в тесті? —
TestClientзапускає його лише в блоціwith TestClient(app) as client:. - Що замінило
@app.on_event("startup")? — Параметрlifespan; події startup і shutdown застаріли.
Потоки, процеси чи asyncio: що обрати для різних задач?
Section titled “Потоки, процеси чи asyncio: що обрати для різних задач?”Що це
- I/O-bound — задача чекає мережу, диск або базу. Процесор простоює.
- CPU-bound — задача рахує: парсинг, стиснення, математика.
- GIL — блокування в CPython: байткод Python у процесі виконує один потік за раз.
Коли що обирати
| Задача | Інструмент |
|---|---|
| багато HTTP-запитів, є async-бібліотека | asyncio |
| блокувальна бібліотека без async | потоки: ThreadPoolExecutor, asyncio.to_thread |
| обчислення на чистому Python | процеси: ProcessPoolExecutor |
Типова помилка
Прискорювати обчислення потоками. У звичайній збірці CPython через GIL чотири потоки рахують не швидше за один.
Як влаштовано
- Потік, який чекає введення-виведення, відпускає GIL, тому потоки годяться для блокувального I/O. Пул
asyncio.to_threadза замовчуванням —min(32, кількість CPU + 4)потоків. - Процес — окремий інтерпретатор зі своєю пам’яттю і своїм GIL. Аргументи й результат передаються через pickle, тому функцію треба оголосити на рівні модуля.
- З async-коду:
await asyncio.to_thread(f, x)— потік,await loop.run_in_executor(pool, f, x)— будь-який пул, зокрема процеси.
Пастки
- Спосіб запуску процесу. У Windows і macOS це
spawn: дочірній процес заново імпортує модуль, тому код запуску ховають підif __name__ == "__main__":. У Linux з Python 3.14 за замовчуваннямforkserverзамістьfork. - Великі дані. Масив на гігабайт в аргументі — це серіалізація й копія. Допомагають
multiprocessing.shared_memoryабо передача шляху до файлу. - Дрібні задачі. Якщо виклик коротший за накладні витрати на pickle, процеси лише сповільнюють. Задачі укрупнюють, наприклад
chunksizeуpool.map. - Пул на кожен запит. Пул процесів створюють один раз під час старту, а не в ендпоінті.
Правило
I/O з async-клієнтом — asyncio, I/O із синхронним клієнтом — to_thread, CPU на чистому Python — пул процесів, створений один раз.
Під капотом
- Багато C-розширень відпускають GIL на час важкої роботи, наприклад NumPy і PyTorch у багатьох операціях. Такий код масштабується і в потоках.
- GPU — спільний ресурс зі своєю чергою команд. Паралельні виклики моделі з різних потоків не прискорюють інференс, а конкурують за відеопам’ять.
Інференс на GPU за HTTP
- Один процес на GPU. Модель завантажується в lifespan один раз; кілька
--workers— кілька копій ваг у відеопам’яті. - Черга. Ендпоінт кладе запит в обмежену
asyncio.Queueі чекаєFuture. Черга повна — одразу 503, а не нескінченне зростання затримки. - Батчинг. Одна задача-воркер забирає з черги до N запитів і запускає модель на пачці через
to_thread, щоб цикл подій далі приймав запити.
Компроміси
- Розмір пачки — пропускна здатність проти затримки: GPU завантажений краще, але перший запит у пачці чекає решту.
- Сервери інференсу на кшталт NVIDIA Triton і vLLM уміють батчинг самі. Власний код виправданий, поки модель одна й логіка проста.
- Free-threaded CPython (експериментальний у 3.13, офіційно підтримується з 3.14 окремою збіркою) дає потокам рахувати паралельно, але потребує підтримки від C-розширень. Докладніше — у темі Мова Python.
Як перевірити в продакшені
- Одне ядро завантажене на 100 %, решта простоюють — код упирається в GIL.
- Утилізація GPU в
nvidia-smiнизька при зростанні черги — пачки надто дрібні або підготовка даних блокує цикл. py-spy dump --pid PIDпоказує стеки всіх потоків: видно, хто зайнятий, а хто чекає.
Підсумок: модель конкурентності обирають за тим, де витрачається час, а GPU обслуговує один процес із чергою й батчингом.
import asyncioimport timefrom concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n: int) -> int: # чистий Python: тримає GIL весь час return sum(i * i for i in range(n))
def blocking_io() -> str: # бібліотека без async API time.sleep(0.5) # під час очікування GIL відпускається return "done"
async def main() -> None: loop = asyncio.get_running_loop() with ProcessPoolExecutor(max_workers=2) as pool: results = await asyncio.gather( loop.run_in_executor(pool, cpu_heavy, 5_000_000), # окремий процес loop.run_in_executor(pool, cpu_heavy, 5_000_000), # і свій GIL asyncio.to_thread(blocking_io), # потік із пулу циклу ) print(results[2]) # done
if __name__ == "__main__": # обов'язково при spawn (Windows, macOS) asyncio.run(main())import asyncio
queue: asyncio.Queue = asyncio.Queue(maxsize=100) # обмежена: зворотний тиск
def run_model(batch: list[str]) -> list[str]: # інференс на GPU, блокувальний виклик return [text.upper() for text in batch]
async def gpu_worker(max_batch: int = 8) -> None: while True: batch = [await queue.get()] # чекаємо перший запит while len(batch) < max_batch and not queue.empty(): batch.append(queue.get_nowait()) # добираємо накопичені results = await asyncio.to_thread(run_model, [t for t, _ in batch]) for (_, future), result in zip(batch, results): if not future.done(): # клієнт міг скасувати запит future.set_result(result)
async def infer(text: str) -> str: # викликається з ендпоінта future = asyncio.get_running_loop().create_future() await queue.put((text, future)) return await future
async def main() -> None: worker = asyncio.create_task(gpu_worker()) # єдиний, хто торкається GPU print(await asyncio.gather(infer("a"), infer("b"), infer("c"))) # ['A', 'B', 'C'] worker.cancel()
asyncio.run(main())У сервісі воркер запускають у lifespan, а помилку моделі передають тим, хто чекає, через future.set_exception.
- Чому потоки прискорюють завантаження, але не обчислення? — Під час очікування введення-виведення GIL відпускається, а байткод Python виконує один потік за раз.
- Що не можна передати в
ProcessPoolExecutor? — Те, що не серіалізується pickle: лямбди, локальні функції, відкриті з’єднання. - Навіщо
if __name__ == "__main__":під час роботи з процесами? — Приspawnдочірній процес імпортує модуль заново й без перевірки знову запустить пул. - Чому для моделі на GPU не ставлять
--workers 4? — Кожен воркер завантажить свою копію ваг у відеопам’ять. - Чим
asyncio.to_threadвідрізняється відrun_in_executor? —to_threadзавжди використовує пул потоків циклу й передає контекстні змінні;run_in_executorприймає будь-який виконавець.