При распараллеливании CPU-bound задачи потоками в стандартном CPython время почти не сокращается: какой механизм это объясняет?
В стандартной сборке 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-вычисления, а потоки скрывают задержки ожидания. Перед внедрением решение проверяют профилированием и нагрузочным тестом, потому что сериализация крупных объектов может съесть выигрыш от параллелизма.
GIL — это синхронизационный механизм выполнения, а не сборщик мусора и не механизм возврата памяти операционной системе. Освобождение GIL позволяет другому потоку выполнять Python-код, но не означает освобождение объектов или уменьшение RSS процесса.
Память освобождается по правилам управления объектами CPython: через подсчёт ссылок, сборку циклов и работу аллокатора. Поэтому после завершения вычислений объекты могут быть уничтожены, но процесс при этом не обязан немедленно вернуть выделенные страницы ОС.
Поток может выполнять полезную работу во время ожидания ввода-вывода, пока другой поток использует процессор. Кроме того, C-расширение может освободить GIL на время длительной операции, если оно безопасно делает это.
Следовательно, нужно профилировать не только язык верхнего уровня, но и фактическое место затрат: ожидание I/O, Python-байткод или нативная библиотека. Для последнего варианта потоки иногда действительно масштабируются по ядрам.
Нет. Процессы обходят GIL между процессами, но добавляют стоимость запуска, планирования, межпроцессного обмена и сериализации объектов. Если каждому заданию приходится передавать большие структуры данных, копирование и передача могут превысить время вычислений.
Практическое решение — уменьшать объём обмена, использовать подходящий размер заданий, переиспользовать пул процессов и измерять отдельно вычисления и передачу данных. Иногда эффективнее передать большие массивы через совместно используемую память или перенести вычисления в библиотеку, работающую в нативном коде.