Что такое Global Interpreter Lock (GIL) в Python?

by Ivan Istomin
Что такое Global Interpreter Lock (GIL) в Python?
И следом за одним переводом, перевод другой статьи, но уже про работу GIL в Python.

Проще говоря, Python Global Interpreter Lock или GIL — это мьютекс (или замок), который позволяет только одному потоку контролировать интерпретатор Python.

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

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

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

Какую проблему GIL решил для Python?

Python использует подсчет ссылок для управления памятью. Это означает, что объекты, созданные в Python, имеют переменную счетчика ссылок, которая отслеживает количество ссылок, указывающих на объект. Когда это число достигает нуля, освобождается память, занимаемая объектом.

Давайте рассмотрим краткий пример кода, чтобы продемонстрировать, как работает подсчет ссылок:

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

В приведенном выше примере счетчик ссылок для объекта пустого списка [] равен 3, объект списка ссылается на a, b и аргумент переданный в sys.getrefcount().

Вернемся к GIL:

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

Эта переменная счетчика ссылок может быть сохранена путем добавления блокировок во все структуры данных, которые являются общими для всех потоков, чтобы они не изменялись непоследовательно.

Но добавление блокировки к каждому объекту или группам объектов означает, что будет существовать несколько блокировок, что может привести к другой проблеме deadlock'ов (блокировка может произойти только при наличии нескольких блокировок). Еще одним побочным эффектом было бы снижение производительности, вызванное повторным приобретением и разблокировкой замков.

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

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

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

Почему в качестве решения был выбран именно GIL?

Итак, почему подход, который кажется таким мешающим, использовался в Python? Было ли это плохое решение разработчиков Python?

Ну, по словам Ларри Гастингса, дизайнерское решение GIL сделало Python таким же популярным, как и сегодня.

Python существует со времен, когда операционные системы не имели понятия потоков. Python был разработан для простоты использования, чтобы сделать разработку более быстрой, и все больше и больше разработчиков начали использовать его.

Много расширений было написано для существующих библиотек на языке С, возможности которых были необходимы в Python. Для предотвращения непоследовательных изменений эти расширения C требовали управления памятью, обеспечиваемого GIL, в соответствии с требованиями безопасности потока.

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

Библиотеки языка С, которые не были потоко-безопасными, стало легче внедрять. И эти расширения C стали одной из причин, по которым Python был легко принят различными сообществами.

Как вы видите, GIL был прагматичным решением сложной проблемы, с которой разработчики CPython столкнулись в самом начале жизни Python.

Влияние на многопоточные программы Python

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

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

I/O-связанные программы — это те, которые проводят время в ожидании ввода/вывода, который может исходить от пользователя, файла, базы данных, сети и т.д. Иногда программам ввода-вывода приходится ждать значительное количество времени, пока они получат то, что им нужно от источника, из-за того, что источнику может понадобиться сделать свою собственную обработку, прежде чем ввод/вывод будет готов, например, пользователь подумает о том, что делать во входной запрос или запрос к базе данных, выполняемый в его собственном процессе.

Давайте посмотрим на простую программу, привязанную к процессору и выполняющую обратный отсчет:

# single_threaded.py
# однопоточный
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n > 0:
        n -= 1

start = time.time()
countdown(COUNT)
end = time.time()

print('Время в секундах -', end - start)

Выполнение этого кода на моем компьютере с 4 ядрами дало следующий результат:

$ python single_threaded.py
Время в секундах - 6.20024037361145

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

# multi_threaded.py
# многопоточный

import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n > 0:
        n -= 1

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Время в секундах -', end - start)

И потом, когда я запустил его снова:

$ python multi_threaded.py
Время в секундах - 6.924342632293701

Как видите, обе версии занимают почти одинаковое количество времени для завершения. В многопоточной версии GIL предотвращал параллельное выполнение потоков, связанных с CPU.

GIL не оказывает значительного влияния на производительность многопоточных программ, так как блокировка разделяется между потоками, пока они ожидают ввода/вывода.

Однако программа, потоки ��оторой полностью привязаны к процессору, например, программа, обрабатывающая образ по частям с помощью потоков, не только станет однопоточной из-за блокировки, но и увеличит время выполнения, как показано в приведенном выше примере, по сравнению со сценарием, в котором она была написана как полностью однопоточная.

Это увеличение является результатом увеличения накладных расходов, связанных с блокировкой.

Почему GIL еще не удалили?

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

Очевидно, что GIL может быть удален, и это делалось несколько раз в прошлом разработчиками и исследователями, но все эти попытки сломали существующие расширения C, которые сильно зависят от решения, которое предоставляет GIL.

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

Создатель и BDFL (Великодушный пожизненный диктатор) Python Гвидо ван Россум в своей статье "Нелегко удалить GIL" в сентябре 2007 года дал ответ сообществу:

"Я бы приветствовал набор патчей к Py3k только в том случае, если бы производительность однопоточной программы (и многопоточной, но связанной с вводом/выводом программы) не снизилась"

И это условие не было выполнено ни одной из попыток, предпринятых с тех пор.

Почему его не удалили в Python 3?

У Python 3 был шанс запустить множество функций с нуля, и в процессе работы он сломал некоторые из существующих расширений C, которые затем потребовали обновления и портирования для работы с Python 3. Именно по этой причине ранние версии Python 3 были приняты сообществом не так быстро.

Но почему GIL не был удален вместе с ним?

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

Но Python 3 действительно привнес значительные улучшения в существующий GIL...

Мы обсудили влияние GIL на "только CPU-связанные" и "только I/O-связанные" многопоточные программы, но как насчет программ, где некоторые потоки являются I/O-связанными, а некоторые CPU-связанными?

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

Это произошло из-за встроенного в Python механизма, который заставлял потоки освобождать GIL после фиксированного интервала непрерывного использования, и если никто другой не получал GIL, тот же самый поток мог продолжать его использование.

>>> import sys
>>> # Интервал установлен в 100 команд:
>>> sys.getcheckinterval()
100

Проблема в этом механизме заключалась в том, что большую часть времени процессорный поток перезагружал сам GIL, прежде чем другие потоки могли его получить. Это было исследовано Дэвидом Бизли и визуализации, которую можно найти здесь.

Эту проблему в версии Python 3.2 в 2009 году исправил Антуан Питру, добавив механизм просмотра количества запросов на получение GIL другими потоками, которые были удалены, и не позволив текущему потоку перезагрузить GIL до того момента, пока у других потоков не появится возможность его запустить.

Как справиться с GIL в Python

Если GIL вызывает у вас проблемы, попробуйте несколько подходов:

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

from multiprocessing import Pool
import time

COUNT = 50000000
def countdown(n):
    while n>0:
        n -= 1

if __name__ == '__main__':
    pool = Pool(processes=2)
    start = time.time()
    r1 = pool.apply_async(countdown, [COUNT//2])
    r2 = pool.apply_async(countdown, [COUNT//2])
    pool.close()
    pool.join()
    end = time.time()
    print('Время в секундах -', end - start)

Запуск этой программы в моей системе дал такой результат:

$ python multiprocess.py
Время в секундах - 4.060242414474487

Приличный прирост производительности по сравнению с многопоточной версией, верно?

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

Альтернативные интерпретаторы Python: У Python имеется несколько реализаций интерпретаторов. Наиболее популярными из них являются CPython, Jython, IronPython и PyPy, написанные на C, Java, C# и Python соответственно. GIL существует только в оригинальной реализации Python - CPython. Если ваша программа с библиотеками доступна для одной из других реализаций, то вы также можете попробовать их.

Просто подождите: В то время как многие пользователи Python используют преимущества однопоточной производительности GIL. Многопоточным программистам не нужно беспокоиться, так как одни из самых ярких умов сообщества Python работают над удалением GIL из CPython. Одна из таких попыток известна под названием "Gilectomy".

Python GIL часто рассматривается как загадочная и сложная тема. Но имейте в виду, что как на Pythonista, на вас это обычно влияет, только если вы пишете расширения на C или если вы используете многопоточный процессор в ваших программах.

В этом случае, эта статья должна дать вам все, что вам нужно, чтобы понять, что такое GIL и как с ним обращаться в ваших собственных проектах. И если вы хотите понять низкоуровневую внутреннюю работу GIL, я бы порекомендовал вам посмотреть выступление Дэвида Бизли "Понимание работы GIL в Python".

July 30, 2019
by Ivan Istomin
Translate
Developing