Программирование PythonПамять и производительностьPython-разработчик серверных и высоконагруженных систем

При распараллеливании CPU bound задачи потоками в стандартном CPython время почти не сокращается: какой мех...

При распараллеливании CPU-bound задачи потоками в стандартном CPython время почти не сокращается: какой механизм это объясняет?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

В стандартной сборке CPython потоки не выполняют одновременно произвольный Python-байткод на нескольких ядрах из-за GIL — глобальной блокировки интерпретатора. Поэтому для CPU-bound задач добавление потоков часто не ускоряет работу, а иногда замедляет её из-за переключений контекста и конкуренции за GIL.

Исторический контекст

GIL появился как практический способ упростить потокобезопасность внутреннего состояния CPython, включая подсчёт ссылок и работу с объектами интерпретатора. Это позволило сохранить относительно простой и быстрый однопоточный сценарий, но ограничило параллельное выполнение Python-кода.

GIL относится именно к реализации CPython, а не к языку Python как абстракции. В некоторых других реализациях Python и в экспериментальных свободнопоточных сборках CPython ограничения могут отличаться.

Постановка проблемы

Если задача тратит основное время на вычисления в Python-коде, несколько потоков конкурируют за один GIL. В каждый момент времени только один поток может исполнять соответствующий Python-байткод, поэтому число потоков не превращается напрямую в число одновременно используемых ядер.

Неверный выбор модели параллелизма приводит к росту накладных расходов: потоки переключаются, синхронизируются и конкурируют за блокировку, но полезная работа не распараллеливается. Для I/O-bound задач ситуация иная: пока один поток ожидает ввод-вывод, другой может получить GIL и продолжить выполнение.

Подробное решение

GIL защищает выполнение Python-байткода в конкретном процессе CPython. Поток периодически освобождает блокировку или делает это во время операций ожидания; другой поток может продолжить работу, но два потока не исполняют обычные участки Python-байткода параллельно на разных ядрах.

Для CPU-bound задачи обычно применяют multiprocessing или другой способ запуска отдельных процессов. У каждого процесса собственный интерпретатор и собственный GIL, поэтому вычисления могут выполняться на разных ядрах. Компромиссы — дополнительное потребление памяти, стоимость запуска процессов и необходимость передавать или разделять данные.

Потоки остаются подходящим выбором для I/O-bound задач: сетевых запросов, ожидания файловой системы и других операций, во время которых выполнение блокируется. Они также могут ускорить вычисления, если основная работа выполняется в C-расширении, которое явно освобождает GIL, например в некоторых числовых или криптографических операциях.

Нельзя безоговорочно считать, что любой CPython всегда имеет GIL: существуют свободнопоточные варианты и сборки без этой глобальной блокировки. Однако при собеседовании и анализе обычного CPython нужно сначала уточнить реализацию, версию, способ сборки и то, какая часть времени приходится на Python-байткод.

Ситуация из практики

Сервис обрабатывает изображения: декодирование и вычисления выполняются в Python, а сетевые запросы занимают небольшую часть времени. Команда увеличила число потоков с одного до восьми, но загрузка процессора не дала ожидаемого ускорения, а задержки выросли.

Рассматривались два варианта. Потоки проще внедрить и они дешевле процессов, но не устраняют ограничение GIL для Python-вычислений. Процессы позволяют задействовать несколько ядер, однако требуют настройки пула, контроля объёма передаваемых данных и оценки памяти.

Выбран пул процессов для CPU-bound этапа, а потоки оставлены для сетевого ввода-вывода. Такое разделение соответствует природе нагрузки: процессы параллелят Python-вычисления, а потоки скрывают задержки ожидания. Перед внедрением решение проверяют профилированием и нагрузочным тестом, потому что сериализация крупных объектов может съесть выигрыш от параллелизма.

Что кандидаты часто упускают

  1. Освобождает ли GIL память или только ограничивает выполнение?

GIL — это синхронизационный механизм выполнения, а не сборщик мусора и не механизм возврата памяти операционной системе. Освобождение GIL позволяет другому потоку выполнять Python-код, но не означает освобождение объектов или уменьшение RSS процесса.

Память освобождается по правилам управления объектами CPython: через подсчёт ссылок, сборку циклов и работу аллокатора. Поэтому после завершения вычислений объекты могут быть уничтожены, но процесс при этом не обязан немедленно вернуть выделенные страницы ОС.

  1. Почему ускорение возможно, если GIL не позволяет потокам одновременно исполнять Python-байткод?

Поток может выполнять полезную работу во время ожидания ввода-вывода, пока другой поток использует процессор. Кроме того, C-расширение может освободить GIL на время длительной операции, если оно безопасно делает это.

Следовательно, нужно профилировать не только язык верхнего уровня, но и фактическое место затрат: ожидание I/O, Python-байткод или нативная библиотека. Для последнего варианта потоки иногда действительно масштабируются по ядрам.

  1. Устраняет ли переход на процессы все проблемы производительности CPU-bound задачи?

Нет. Процессы обходят GIL между процессами, но добавляют стоимость запуска, планирования, межпроцессного обмена и сериализации объектов. Если каждому заданию приходится передавать большие структуры данных, копирование и передача могут превысить время вычислений.

Практическое решение — уменьшать объём обмена, использовать подходящий размер заданий, переиспользовать пул процессов и измерять отдельно вычисления и передачу данных. Иногда эффективнее передать большие массивы через совместно используемую память или перенести вычисления в библиотеку, работающую в нативном коде.