2026, Jan 06 18:02
Почему GIL блокирует asyncio и как запускать CPU‑код в FastAPI через процессы
Как GIL блокирует цикл asyncio в FastAPI при CPU‑задачах, почему ThreadPoolExecutor не спасает, и как ProcessPoolExecutor сохраняет отзывчивость сервиса.
Задачи, упирающиеся в CPU, и asyncio часто сталкиваются из-за общего ресурса — Global Interpreter Lock (GIL). Если вы отправляете тяжёлые синхронные вычисления в ThreadPoolExecutor из приложения на FastAPI или Starlette, цикл событий всё равно конкурирует за тот же GIL. Когда активных рабочих потоков становится много, цикл задерживается, и сервис ощущается медленным, даже если вы формально «вынесли» работу с главного потока.
Воспроизводим проблему в коде
Ниже — пример CPU-интенсивной задачи, запущенной через ThreadPoolExecutor, и контрастный вариант на ProcessPoolExecutor. Логика абсолютно одинаковая, различается лишь стратегия исполнения.
from fastapi import FastAPI
import concurrent.futures
import asyncio
from multiprocessing import current_process
from threading import current_thread
api = FastAPI()
def heavy_cpu_work():
pid = current_process().pid
tid = current_thread().ident
t_name = current_thread().name
p_name = current_process().name
print(f"{pid} - {p_name} - {tid} - {t_name}")
pow(365, 100000000000000)
# Это ДЕЙСТВИТЕЛЬНО заблокирует цикл событий (из-за pow())
@api.get("/gil-blocking")
async def gil_blocking():
evt = asyncio.get_running_loop()
with concurrent.futures.ThreadPoolExecutor() as exec_pool:
out = await evt.run_in_executor(exec_pool, heavy_cpu_work)
return "OK"
# Это НЕ заблокирует цикл событий
@api.get("/gil-non-blocking")
async def gil_non_blocking():
evt = asyncio.get_running_loop()
with concurrent.futures.ProcessPoolExecutor() as exec_pool:
out = await evt.run_in_executor(exec_pool, heavy_cpu_work)
return "OK"
if __name__ == "__main__":
import uvicorn
uvicorn.run(api)
В обработчике “gil-blocking” pow() выполняет массивное вычисление, которое не освобождает GIL; основной цикл событий не может исполнять байткод Python, пока рабочий поток удерживает блокировку. В отличие от него, “gil-non-blocking” переносит ту же задачу в другой процесс и, следовательно, под другой GIL, сохраняя цикл отзывчивым.
Что на самом деле происходит с GIL
Когда любой поток держит GIL, ни один другой поток — включая тот, который ведёт цикл событий asyncio, — не может исполнять байткод Python до освобождения блокировки, либо добровольно, либо по истечении кванта времени интерпретатора. CPython периодически применяет разделение по времени, и интервал переключения по умолчанию — около 5 мс. Текущую настройку можно посмотреть так:
import sys
print(sys.getswitchinterval()) # 0.005
Это число с плавающей точкой задаёт желаемую длительность тайм-слайса потока и настраивается через sys.setswitchinterval().
Обратите внимание, что фактическое значение может быть выше, особенно если используются длительные внутренние функции или методы. Также то, какой поток будет запланирован по окончании интервала, решает операционная система. У интерпретатора нет собственного планировщика.
Внутреннего приоритета, отдающего предпочтение потоку с циклом событий, нет. Поэтому несколько занятых CPU-связанных рабочих потоков могут снова и снова «выигрывать» GIL, и цикл будет откладываться. Более того, автоматическое освобождение — это попытка «по возможности» и не гарантируется; некоторые нативные операции фактически монополизируют GIL надолго.
Пример pow(365, 100000000000000) показателен именно тем, что он не освобождает GIL во время выполнения, поэтому всё остальное блокируется, пока вычисление не завершится или пока не добавлена граница процесса.
Ответы на практические вопросы
Да, картина верна: когда рабочий поток удерживает GIL, цикл событий не может исполнять байткод Python, пока блокировка не будет отдана по интервалу переключения или добровольно. Тайм-слайсинг может временно «голодать» цикл, если за блокировку спорит много потоков, и в CPython нет планировщика или механизма приоритета, который в такой ситуации предпочитал бы поток с циклом.
Надёжный выход: процессы, а не потоки
Если необходимо выполнять CPU-интенсивный код, переносите его в другой процесс. Отдельный процесс — это отдельный GIL, поэтому цикл событий в основном процессе остаётся отзывчивым. Контраст в примере выше это показывает: логика функции не меняется, меняется только тип исполнителя.
from fastapi import FastAPI
import concurrent.futures
import asyncio
from multiprocessing import current_process
from threading import current_thread
api = FastAPI()
def heavy_cpu_work():
pid = current_process().pid
tid = current_thread().ident
t_name = current_thread().name
p_name = current_process().name
print(f"{pid} - {p_name} - {tid} - {t_name}")
pow(365, 100000000000000)
@api.get("/gil-non-blocking")
async def gil_non_blocking():
evt = asyncio.get_running_loop()
with concurrent.futures.ProcessPoolExecutor() as exec_pool:
out = await evt.run_in_executor(exec_pool, heavy_cpu_work)
return "OK"
Использование нескольких воркеров или процессов сервера даёт тот же эффект: у каждого свой цикл событий и свой GIL, что позволяет нескольким запросам продвигаться параллельно, не блокируя друг друга.
Важные нюансы и компромиссы
Если ваш CPU-код не включает операции вроде примера с pow(), можно использовать ThreadPoolExecutor — цикл всё равно будет получать время благодаря интервалу переключения. Однако ожидать ускорения параллельностью для чисто Python-кода на потоках не стоит. ThreadPoolExecutor силён для блокирующего ввода-вывода, а не для тяжёлых вычислений.
Число потоков в ThreadPoolExecutor по умолчанию вычисляется как min(32, os.cpu_count() + 4), а ProcessPoolExecutor по умолчанию равняется количеству CPU. Эти настройки могут подходить, а могут и нет. Для I/O-задач потоков часто берут больше, чем ядер, но избыток активных потоков ведёт к лишним переключениям контекста. Для CPU-кода процессы обычно лучше соответствуют аппаратуре. Выбирать max_workers разумно по результатам бенчмарков на реалистичной нагрузке.
Когда тяжёлые задания рискуют задерживать обработку запросов, можно ввести очередь для контроля параллелизма, сразу возвращать идентификатор запроса и позволить клиентам опрашивать статус или получать результат через WebSocket. Так вы развязываете путь запрос–ответ от долгого вычисления и сохраняете отзывчивость.
О free-threaded CPython в Python 3.13
Python 3.13 вводит экспериментальный режим свободной многопоточности, где GIL может быть отключён. Функция по умолчанию не включена и требует отдельного исполняемого файла или сборки с --disable-gil. В документации отмечают потенциальное ускорение на многоядерном «железе», но также предупреждают об экспериментальном статусе и заметной просадке производительности в одном потоке.
Это экспериментальная функция и поэтому по умолчанию не включена. Свободнопоточный режим требует другого исполняемого файла, обычно называемого python3.13t или python3.13t.exe. Предварительно собранные бинарники с пометкой free-threaded можно установить как часть официальных установщиков для Windows и macOS, либо собрать CPython из исходников с опцией --disable-gil.
Свободнопоточное исполнение позволяет полностью задействовать доступную вычислительную мощность, запуская потоки параллельно на доступных ядрах CPU. Хотя не всё ПО автоматически получит выгоду, программы, спроектированные с учётом потоков, будут работать быстрее на многоядерных системах. Режим free-threaded экспериментальный, работа над ним продолжается: ожидайте некоторых багов и существенного снижения производительности в одном потоке.
Почему это важно
Асинхронные серверы ровно настолько отзывчивы, насколько отзывчив их цикл событий. Если рабочий поток монополизирует GIL, планирование корутин, колбэки и тайм-ауты сдвигаются. Пики задержек становятся заметны даже при умеренной нагрузке. Понимание того, как GIL и интервал переключения управляют выполнением, помогает избежать скрытых задержек и защитить пользовательскую скорость.
Выводы
Для CPU-связанных задач выбирайте ProcessPoolExecutor или дополнительные процессы-воркеры, чтобы цикл событий не голодал. Помните, что в CPython нет приоритета, отдающего предпочтение потоку цикла. Некоторые нативные вычисления не освобождают GIL и могут блокировать всех остальных. Если остаётесь на потоках, делайте это для I/O-задач и настраивайте max_workers только после измерений на реальной нагрузке. Когда запросы запускают долгие задания, разъединяйте путь ответа через очередь и давайте получать статус или пушить завершение через WebSocket. Следите за свободнопоточным режимом Python 3.13 по мере его взросления, но пока относитесь к нему как к эксперименту.