Короткий ответ
Варианты — вынести CPU-bound код в отдельный процесс (run_in_executor с ProcessPoolExecutor), вынести его в отдельный сервис-воркер за очередью задач, либо разбить вычисление на мелкие шаги с периодическими await; выбор зависит от того, насколько тяжёлые и частые эти вычисления относительно общего профиля нагрузки сервиса.
Как это работает подробнее
Event loop однопоточен — любой синхронный участок без await полностью занимает поток выполнения, пока не завершится. run_in_executor(ProcessPoolExecutor, func) выносит вызов в отдельный процесс и возвращает await-able future, не блокируя сам event loop; отдельный сервис-воркер за очередью убирает CPU-bound нагрузку из процесса, обслуживающего запросы, полностью.
- Компромисс.
ProcessPoolExecutorрешает блокировку локально без изменения архитектуры API, но ограничен ресурсами одной машины и добавляет задержку на сериализацию данных между процессами; выделенный воркер-сервис с очередью масштабируется независимо и изолирует нагрузку, но превращает синхронный HTTP-ответ в асинхронный флоу с дополнительной сложностью на клиенте и в мониторинге — решение зависит от частоты и тяжести операций и допустимости асинхронного API для клиентов.
Пример кода
example-283.py
import asyncio
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy_resize(image_bytes):
... # тяжёлая синхронная обработка изображения
async def handle_upload(image_bytes):
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, cpu_heavy_resize, image_bytes)
return result
# Проверка: во время выполнения cpu_heavy_resize в отдельном процессе,
# event loop продолжает обслуживать другие конкурентные запросы без роста их латентностиЧто использовать на практике
- Практика. Для нечастых, но тяжёлых операций
ProcessPoolExecutorчерезrun_in_executor— относительно простое решение без изменения архитектуры сервиса. Для операций, которые доминируют в нагрузке или должны масштабироваться независимо от веб-слоя, выносят их в отдельный воркер-сервис за очередью, что даёт независимое масштабирование, но требует асинхронного API вместо синхронного ответа.
Подводные камни
- Риски. Используют
ThreadPoolExecutorдля чисто CPU-bound кода на чистом Python, ожидая снятия блокировки event loop, но не получая реального параллелизма из-за GIL; недооценивают издержки сериализации данных при передаче в отдельный процесс для не очень тяжёлых, но частых вызовов.