PythonПрограммированиеPython Developer

Почему несколько **потоков** в одном **Python** процессе выполняются последовательно на одном ядре ЦП, несмотря на наличие нескольких ядер в системе?

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

Ответ на вопрос

Global Interpreter Lock (GIL) Python — это мьютекс, который защищает доступ к объектам Python, гарантируя, что только один поток выполняет байт-код Python в любой данный момент времени. Это решение было принято в CPython для упрощения управления памятью и предотвращения гонок при подсчете ссылок на объекты. Следовательно, даже на многоядерных процессорах потоки не могут выполнять Python код параллельно; вместо этого они быстро переключают выполнение на одно ядро, что делает многопоточность, зависящую от ЦП, неэффективной.

import threading import multiprocessing import time def cpu_intensive_task(n): """Чистая операция, зависимая от ЦП на Python""" count = 0 for i in range(n): count += i ** 2 return count # Демонстрация ограничения потоков start = time.time() threads = [threading.Thread(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(f"Время потоков: {time.time() - start:.2f}s") # Вывод показывает ~4x время одиночного потока из-за конкуренции GIL start = time.time() processes = [multiprocessing.Process(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for p in processes: p.start() for p in processes: p.join() print(f"Время многообработки: {time.time() - start:.2f}s") # Вывод показывает ~1x время одиночного потока (ускорение в 4 раза)

Ситуация из жизни

Проблема: Нашей аналитической платформе нужно было обработать 10 ГБ файлов журналов с сложными извлечениями с помощью регулярных выражений и статистическими расчетами. Инженерная команда реализовала пул рабочих на основе поточности с использованием concurrent.futures.ThreadPoolExecutor с 16 потоками на сервере с 16 ядрами. Удивительно, но загрузка ЦП оставалась на уровне 6-7% (одно ядро), а процесс обработки занял 3 часа, в то время как последовательная обработка заняла 45 минут. GIL заставлял выполнять последовательное выполнение плюс добавлял накладные расходы на переключение потоков.

Решение 1: Оптимизированная потоковость с C-расширениями Мы оценили возможность переноса тяжелых вычислений в операции NumPy и использования библиотек с ускорением на C, которые освобождают GIL во время выполнения.

Плюсы: Минимальные изменения в коде; общая память исключает затраты на сериализацию; меньший объем памяти, так как потоки делят адресное пространство.

Минусы: Ограничено операциями, поддерживаемыми NumPy; пользовательские алгоритмы по-прежнему требуют выполнения байт-кода Python; отладка взаимодействий C-расширений добавляет сложности.

Решение 2: Параллелизм на основе процессов с многообработкой Мы рассмотрели возможность перехода на multiprocessing.Pool или concurrent.futures.ProcessPoolExecutor, создавая отдельные интерпретаторы Python.

Плюсы: Истинный параллелизм с использованием всех ядер ЦП; линейная масштабируемость для задач, зависящих от ЦП; изоляция полностью предотвращает конкуренцию GIL.

Минусы: Более высокая загрузка памяти (каждый процесс загружает отдельный интерпретатор Python ~50-100МБ); данные должны быть сериализованы/десериализованы для межпроцессной связи; накладные расходы на время запуска процесса.

Выбранное решение: Мы выбрали многообработку с обработкой данных по частям. Журналы были разделены на 16 сегментов, обработанных с помощью ProcessPoolExecutor, а результаты были объединены. Стратегия разделения минимизировала затраты pickle за счет снижения частоты межпроцессной связи.

Результат: Время обработки снизилось с 3 часов до 4 минут (ускорение в 45 раз). Использование ЦП достигло 98% на всех 16 ядрах. Использование памяти увеличилось на 800 МБ на процесс (всего 12.8 ГБ), что было приемлемо на нашем сервере с 128 ГБ. Мы реализовали одиночный пул процессов, чтобы распределить затраты на запуск по нескольким пакетным заданиям.

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


Почему GIL не влияет на производительность поточности, зависящей от I/O?

Многие кандидаты ошибочно считают, что потоки совершенно бесполезны в Python. GIL освобождается во время операций ввода-вывода (сети, чтения с диска, запросы к базе данных) и при вызове C-расширений, которые явно его освобождают (например, операции с матрицами NumPy). Когда поток блокируется для ввода-вывода, другие потоки могут выполнять Python код. Таким образом, поточность остается крайне эффективной для одновременной обработки ввода-вывода, такой как веб-скрейпинг или работа с тысячами одновременных соединений на серверах, основанных на asyncio.


Имеют ли альтернативные реализация Python, такие как PyPy или Jython, GIL?

Кандидаты часто предполагают, что удаление GIL — это просто вопрос использования другого интерпретатора. PyPy (Just-In-Time скомпилированный Python) также реализует GIL для поддержания безопасности потоков, хотя его другая модель объектов может сделать переключение потоков более эффективным. Однако Jython (работает на JVM) и IronPython (работает на .NET CLR) не имеют GIL, так как они полагаются на сборку мусора и примитивы потоков, предоставляемые виртуальной машиной, что позволяет достигать истинного параллелизма на уровне потоков на потоках JVM.


Можно ли вручную освободить GIL, не создавая новые процессы?

Многие разработчики не знают о ручном управлении GIL в C-расширениях. При написании кода на Cython или C вы можете явно освободить GIL с использованием макросов Py_BEGIN_ALLOW_THREADS и Py_END_ALLOW_THREADS вокруг длительных вычислений. Кроме того, Python 3.12+ ввел GIL, специфичный для интерпретатора (PEP 684), позволяя создавать подпроцессы с отдельными GIL в одном процессе, хотя для этого требуется экспериментальный модуль interpreters, и объекты не делятся между интерпретаторами напрямую.