← назад к разделу

asyncio закрывает ожидание, но не вычисления: пока одна задача считает, остальные стоят, а вынос в поток помогает лишь частично, потому что потоки Python по очереди держат глобальную блокировку интерпретатора. Разберём, что именно делает GIL, что изменилось в Python 3.13 и 3.14 со сборкой без него, какие ещё есть способы задействовать несколько ядер и как выбирать между ними в сервисе.

Обязательно

Что делает GIL

Глобальная блокировка интерпретатора (GIL) разрешает выполнять байткод Python только одному потоку в каждый момент. Поток держит её, пока выполняет код Python, и отдаёт каждые 5 миллисекунд (sys.getswitchinterval()) или когда уходит в ожидание ввода-вывода. Поэтому два потока, которые считают хеши на чистом Python, не быстрее одного: они чередуются на одном ядре, а переключения ещё и стоят времени.

Что GIL не запрещает: ожидание. Поток, который ждёт сокет или файл, GIL отпускает, и десять потоков с сетевыми вызовами действительно работают параллельно. Что ещё: расширения на C могут отпускать GIL на время тяжёлой работы, и hashlib, zlib, bcrypt, NumPy это делают, поэтому to_thread с ними даёт настоящий параллелизм. И что GIL не гарантирует: атомарности составных операций. counter += 1 из двух потоков теряет инкременты, потому что чтение, сложение и запись это разные шаги байткода, между которыми поток могут переключить.

Сборка без GIL

С Python 3.13 есть сборка интерпретатора без GIL, в 3.14 она объявлена поддерживаемой, а не экспериментальной. Это отдельный исполняемый файл python3.14t (буква t от free-threading), в нём потоки выполняют байткод по-настоящему параллельно. Проверить, в какой сборке вы работаете:

живой пример

import sys
print(sys._is_gil_enabled())        # True в обычной сборке, False в python3.14t
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

В свободнопоточной сборке GIL можно включить обратно переменной PYTHON_GIL=1 или флагом -X gil=1; интерпретатор включает его сам, если импортирован модуль расширения, собранный без поддержки свободных потоков, и предупреждает об этом. Цена сборки: однопоточный код работает медленнее на единицы процентов из-за более дорогих счётчиков ссылок и блокировок внутри структур данных, и не все расширения ещё собраны под неё (колёса с тегом cp314t).

Что это меняет для сервиса: потоки становятся способом задействовать ядра для вычислений на чистом Python без процессов, и to_thread для тяжёлого расчёта начинает ускорять, а не только разгружать цикл. Что не меняет: гонки. Без GIL составные операции над общими структурами ломаются чаще, а не реже, потому что потоки действительно одновременны; threading.Lock вокруг общих данных становится обязательным, а не желательным. Встроенные типы (dict, list) защищены внутренними блокировками от повреждения, но не от логических гонок.

Для сервиса на asyncio переход на свободные потоки оправдан, когда есть вычислительная нагрузка внутри процесса и зависимости её поддерживают; для чисто сетевого сервиса выигрыша нет, а стоимость проверки совместимости есть.

Процессы: ProcessPoolExecutor

Классический способ задействовать ядра в любой сборке: процессы. У каждого свой интерпретатор и свой GIL. concurrent.futures.ProcessPoolExecutor подключается к asyncio через run_in_executor:

from concurrent.futures import ProcessPoolExecutor

cpu_pool = ProcessPoolExecutor(max_workers=4, max_tasks_per_child=1000)

async def score(features: list[float]) -> float:
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(cpu_pool, compute_score, features)

Три особенности, которые отличают процессы от потоков. Аргументы и результат сериализуются через pickle: большие массивы копируются, объекты с открытыми соединениями не передаются вовсе. Процессы стартуют методом spawn на macOS и Windows (проверено: multiprocessing.get_start_method() возвращает spawn), а с Python 3.14 и на Linux по умолчанию forkserver вместо fork: дочерний процесс импортирует модуль заново, поэтому код запуска должен быть под if __name__ == "__main__":, а функция для пула должна импортироваться по имени модуля, не быть лямбдой или вложенной. И процессы тяжёлые: десятки мегабайт памяти каждый и десятки миллисекунд на старт, поэтому пул создают один раз в lifespan и держат.

max_tasks_per_child перезапускает рабочий процесс после N задач, что защищает от утечек памяти в библиотеках на C. Пул процессов внутри пода конкурирует за память и ядра с самим сервисом, и его размер согласуют с resources.limits контейнера.

Подинтерпретаторы

Python 3.14 добавил модуль concurrent.interpreters и InterpreterPoolExecutor: несколько интерпретаторов в одном процессе, у каждого свой GIL, без стоимости отдельных процессов. Проверено: в 3.14 concurrent.interpreters.create() создаёт интерпретатор, а concurrent.futures.InterpreterPoolExecutor существует и используется как ProcessPoolExecutor:

from concurrent.futures import InterpreterPoolExecutor

interp_pool = InterpreterPoolExecutor(max_workers=4)
result = await loop.run_in_executor(interp_pool, compute_score, features)

Ограничения похожи на процессы: данные между интерпретаторами передаются копированием (через pickle или специальные разделяемые типы), функция должна импортироваться по имени, и не все расширения на C поддерживают несколько интерпретаторов. Выигрыш в сравнении с процессами: быстрее старт и меньше памяти. Это новый инструмент, и в проде он пока встречается реже процессов; смотреть на него стоит, когда процессы слишком дороги по памяти, а свободнопоточная сборка недоступна из-за зависимостей.

Процессы на уровне сервера

Самый простой параллелизм для сетевого сервиса это несколько процессов uvicorn: uvicorn app:app --workers 4 или gunicorn с рабочими процессами uvicorn. Каждый процесс держит свой цикл событий и использует одно ядро целиком; четыре процесса на четырёх ядрах дают четырёхкратную пропускную способность без изменений в коде. Цена: четыре пула соединений к базе вместо одного, четыре копии кешей в памяти, семафоры и лимиты действуют на процесс, а не на сервис. В Kubernetes вместо воркеров часто берут один процесс на под и масштабируют подами: так лимиты ресурсов и метрики считаются на процесс, и планировщик видит реальную картину. Как это выглядит в контейнере, рассказывает статья про запуск Python в контейнере.

Как выбирать

НагрузкаИнструмент
Ожидание сети и базыasyncio, задачи, асинхронные клиенты
Ввод-вывод через синхронную библиотекуto_thread, пул потоков
Тяжёлый расчёт в C-библиотеке, отпускающей GILto_thread
Тяжёлый расчёт на чистом PythonProcessPoolExecutor, InterpreterPoolExecutor, или потоки в сборке без GIL
Больше запросов в секунду на сетевом сервисеворкеры uvicorn или больше подов

Общее правило: сначала измерить, где время (ожидание или процессор), потом выбирать. Профилировщик для асинхронного сервиса показывает, сколько времени цикл проводит в селекторе, а сколько в коде Python; об этом статья про профилирование.

Дополнительно: при первом чтении можно пропустить

Глубже: что ломается при переходе с потоков на процессы и обратнорасширенное

Код, написанный под потоки, часто неявно рассчитывает на общую память: глобальный кеш, который «виден всем», счётчик метрик в модуле, подключение к базе, созданное при импорте. В процессах всего этого нет: у каждого рабочего процесса своя копия модулей, и кеш, заполненный в основном процессе до создания пула, в рабочих со spawn будет пустым, потому что они импортируют модуль заново. Метрики Prometheus в многопроцессном режиме требуют отдельной настройки с файлами на диске, иначе каждый процесс отдаёт свои счётчики по очереди. Соединения к базе, открытые до fork, в дочернем процессе ведут себя непредсказуемо, и это одна из причин, по которой Python 3.14 на Linux ушёл от fork по умолчанию.

Обратный переход, с процессов на свободные потоки, возвращает общую память и вместе с ней гонки: код, который был безопасен благодаря изоляции процессов, теперь делит словари и списки между потоками. Поэтому путь миграции такой: сначала убрать разделяемое изменяемое состояние (передавать данные явно, результаты собирать в основном потоке), и только потом менять исполнителя. Код, у которого функция для пула получает всё на вход и возвращает всё на выход, одинаково работает в потоках, процессах и интерпретаторах, и смена исполнителя становится настройкой, а не переписыванием.

Коротко

  • GIL пускает к байткоду один поток за раз с переключением каждые 5 мс; ожидание и C-расширения его отпускают, составные операции он не делает атомарными.
  • Сборка без GIL (python3.14t, поддерживаемая с 3.14): настоящий параллелизм потоков, sys._is_gil_enabled() для проверки, PYTHON_GIL=1 для возврата; однопоточный код чуть медленнее, расширения нужны с тегом cp314t, гонки проявляются чаще.
  • ProcessPoolExecutor: свой GIL в каждом процессе, аргументы через pickle, spawn на macOS и forkserver на Linux в 3.14, код под if __name__ == "__main__", пул один на процесс в lifespan, max_tasks_per_child против утечек.
  • InterpreterPoolExecutor и concurrent.interpreters в 3.14: несколько интерпретаторов со своими GIL в одном процессе, дешевле процессов, те же ограничения на передачу данных.
  • Для сетевого сервиса параллелизм дают воркеры uvicorn или поды; лимиты, пулы и кеши при этом множатся на число процессов.
  • Выбор по типу нагрузки: ожидание в asyncio, синхронный ввод-вывод и C-библиотеки в потоки, чистый Python в процессы или интерпретаторы.
  • Код без разделяемого изменяемого состояния одинаково работает в потоках, процессах и интерпретаторах; смена исполнителя становится настройкой.

Что почитать дальше