Как GIL в CPython влияет на выполнение CPU-bound задач в нескольких потоках?
В стандартной сборке CPython с включённым GIL несколько потоков не выполняют Python-байткод одновременно на разных ядрах. Поэтому потоки обычно не ускоряют CPU-bound задачи и могут добавить накладные расходы на переключение и синхронизацию.
Это ограничение не мешает потокам перекрываться при операциях ввода-вывода или в тех расширениях на C, которые явно освобождают GIL. Для настоящего параллельного выполнения независимых вычислений обычно используют процессы либо специализированный код, освобождающий GIL.
GIL, или глобальная блокировка интерпретатора, упрощает защиту внутренних структур CPython от одновременного доступа потоков. Это снижает сложность реализации интерпретатора и делает многие операции внутри него безопаснее с точки зрения управления памятью и взаимодействия с расширениями на C.
Обратная сторона такого решения заключается в том, что в каждый момент времени только один поток стандартной сборки CPython может исполнять Python-байткод. Потоки при этом остаются полезными для задач, где значительная часть времени проводится вне интерпретатора, например в ожидании сети или диска.
Предположим, сервер обрабатывает несколько независимых задач, каждая из которых интенсивно вычисляет хеши, преобразует большие структуры Python или выполняет сложный алгоритм на чистом Python. Наивный запуск каждой задачи в отдельном потоке не обеспечивает ожидаемого ускорения на многоядерной машине.
Потоки конкурируют за GIL, поэтому часть времени тратится на переключение между ними. Кроме того, увеличение числа потоков усложняет синхронизацию и может ухудшить предсказуемость задержек, не добавив вычислительной пропускной способности.
GIL принадлежит процессу CPython. Поток должен владеть этой блокировкой, чтобы исполнять Python-байткод и обращаться к защищаемым внутренним объектам интерпретатора. Когда поток выполняет блокирующую операцию ввода-вывода или вызывает C-код, который освобождает GIL, другой поток может получить возможность выполнять Python-код.
Следовательно, нужно различать два вида параллельности. Конкурентность позволяет продвигать несколько задач, чередуя их выполнение, а параллелизм означает одновременное выполнение на разных ядрах. Потоки в CPython хорошо подходят для конкурентного ввода-вывода, но не являются общим способом распараллелить чисто Python-вычисления.
Для CPU-bound задач часто выбирают multiprocessing или пул процессов. У каждого процесса собственный интерпретатор и собственный GIL, поэтому процессы могут исполняться на разных ядрах. Цена такого подхода — отдельная память, сериализация передаваемых данных, запуск процессов и необходимость явно учитывать обмен результатами.
Есть важные исключения. Некоторые библиотеки выполняют вычисления в нативном коде и освобождают GIL, поэтому их потоки могут использовать несколько ядер. Однако это свойство конкретной операции библиотеки, а не гарантия, которую следует распространять на любой Python-код.
Наличие GIL не означает, что Python-потоки безопасны без синхронизации. Переключения между потоками возможны в разных местах, а составные последовательности действий могут быть прерваны. Для общего изменяемого состояния по-прежнему нужны подходящие примитивы синхронизации.
Следует также учитывать реализацию и сборку Python. Описанное поведение относится прежде всего к обычным сборкам CPython с GIL; другие реализации Python и экспериментальные или специальные сборки могут иметь иные характеристики. Поэтому в техническом решении важно называть не просто Python, а конкретную реализацию и условия запуска.
В сервисе нужно одновременно обрабатывать множество изображений: декодировать файлы, выполнять вычисления на массивах и сохранять результаты. Команда сначала использовала большой пул потоков, ожидая загрузить все ядра процессора, но задержки выросли из-за конкуренции за GIL и накладных расходов переключения.
Рассматривались три варианта. Увеличение числа потоков было простым, но не решало проблему чисто Python-вычислений. Перенос всего пайплайна в процессы обеспечивал параллелизм, однако увеличивал расход памяти и стоимость передачи больших изображений между процессами. Использование библиотеки, выполняющей операции над массивами в нативном коде с освобождением GIL, обещало лучшую эффективность, но требовало изменить представление данных и проверить поведение конкретных операций.
Выбрали комбинацию: файловой ввод-вывод оставили конкурентным, тяжёлые независимые этапы перенесли в пул процессов, а операции, уже реализованные эффективным нативным кодом, выполняли через специализированную библиотеку. После измерений такой вариант дал предсказуемое использование ядер без чрезмерного обмена большими объектами.
Нет, это зависит от конкретной операции и реализации библиотеки. Многие блокирующие операции стандартной библиотеки освобождают GIL, но пользовательский или сторонний C-код может удерживать его во время ожидания.
Поэтому нельзя делать вывод только по тому, что функция кажется вводом-выводом. Нужно смотреть документацию библиотеки, исходный код или измерения, а также учитывать, не выполняется ли перед ожиданием значительная работа на Python.
Нет. GIL защищает внутреннее выполнение интерпретатора, но не превращает произвольную последовательность действий в одну неделимую транзакцию. Например, логика прочитать значение, изменить его и записать обратно может быть нарушена переключением между потоками.
Для согласованного доступа к общему состоянию применяют блокировки, очереди, события или другие средства синхронизации. Даже если отдельная операция выглядит простой и в конкретной версии обычно выполняется без промежуточного переключения, полагаться на это как на контракт надёжного многопоточного алгоритма нельзя.
Нет. Процессы устраняют конкуренцию за один GIL, но добавляют стоимость создания процессов, планирования, сериализации и копирования данных, а также расходуют больше памяти. Если задачи маленькие или между ними передаются большие объекты, выигрыш от параллелизма может исчезнуть.
Выбор нужно подтверждать измерениями: оценить размер задач, долю времени в Python и нативном коде, стоимость передачи данных, число доступных ядер и требования к задержке. Для некоторых вычислений эффективнее библиотека с нативной реализацией, для других — процессы, а при преимущественно сетевом ожидании — потоки или asyncio.