Что происходит при смешивании синхронного кода с CPU-bound задачами в Python
Что проверяют
Понимаете ли вы, что asyncio это кооперативная многозадачность в одном потоке, а GIL не даёт потокам считать параллельно. Вопрос практический: куда девать тяжёлые вычисления, чтобы сервис не переставал отвечать.
Как отвечать
Цикл событий и все корутины живут в одном потоке. Синхронный вызов, который считает секунду, держит цикл всю эту секунду: другие корутины не двигаются, таймеры сдвигаются, проверка живости отваливается, соединения копятся в очереди. Вытеснения нет, управление возвращается только на await.
Потоки для счёта не спасают: GIL пускает исполнять байт-код одному потоку за раз. Потоки выигрывают на ожидании ввода-вывода, потому что там блокировка отпускается, и на вызовах C-расширений, которые отпускают её явно (numpy, сжатие, хэширование).
Настоящий параллелизм дают процессы: multiprocessing, ProcessPoolExecutor, а из асинхронного кода loop.run_in_executor с процессным пулом. asyncio.to_thread годится только для блокирующего ввода-вывода. Плата за процессы это сериализация аргументов и результата плюс отдельная память, поэтому мелкие задачи дешевле оставить на месте. В свежих сборках CPython появился экспериментальный режим без GIL, но закладываться на него в проде пока рано.
Полезно назвать и диагностику: отладочный режим asyncio логирует колбэки, которые выполнялись слишком долго, и виновник находится сразу.
Где спотыкаются
Оборачивают тяжёлую функцию в async def и считают, что она стала неблокирующей. Корутина без единого await внутри это обычный синхронный вызов, просто с лишней обёрткой; цикл событий стоит столько же.
Ставят на CPU-bound задачи пул потоков и удивляются, что стало медленнее однопоточного варианта. Потоки дерутся за GIL, добавляются переключения контекста, и полезной работы получается меньше.
Меряют выигрыш на игрушечном примере и переносят вывод на боевые данные. В примере в процесс уезжает пара чисел, а в проде большой кадр данных, и сериализация съедает весь выигрыш от параллельности.