Что такое глобальная блокировка интерпретатора Python (GIL)?

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

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

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

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

Содержание

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

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

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

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

В приведенном примере количество ссылок на пустой объект-список [] составило 3. На объект-список ссылались ab и аргумент, переданный в sys.getrefcount().

Вернемся к GIL.

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

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

Но добавление блокировки к каждому объекту или группе объектов означает наличие нескольких блокировок. Это может привести к другой проблеме — взаимным блокировкам или дедлокам. Другим побочным эффектом может стать снижение производительности, вызванное многократным получением и освобождением блокировок.

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

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

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

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

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

По мнению Ларри Хастингса, дизайнерское решение GIL — это одна из тех вещей, которые сделали Python таким популярным, каким он является сегодня.

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

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

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

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

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

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

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

Программы, привязанные к процессору (CPU-bound) — это программы, которые доводят процессор до предела. К ним относятся программы, выполняющие математические вычисления, такие как умножение матриц, поиск, обработка изображений и т.д.

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

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

# 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('Time taken in seconds -', end - start)

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

$ python single_threaded.py
Time taken in seconds - 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('Time taken in seconds -', end - start)

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

$ python multi_threaded.py
Time taken in seconds - 6.924342632293701

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

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

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

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

Почему до сих пор не убрали GIL?

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

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

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

Создатель и BDFL Python Гвидо ван Россум дал ответ сообществу в сентябре 2007 года в своей статье «Нелегко удалить GIL»:

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

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

Почему GIL не убрали в Python 3?

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

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

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

Однако в Python 3 было внесено существенное усовершенствование в существующий механизм глобальной блокировки интерпретатора.

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

Известно, что в таких программах GIL Python приводил к «голоданию» потоков, связанных со вводом-выводом, не давая им возможности получить GIL от потоков, связанных с процессором.

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

>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100

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

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

Как работать с GIL в Python

Механизм глобальной блокировки интерпретатора вызывает у вас проблемы, то можно попробовать несколько подходов.

Многозадачность через многопроцессорность против многозадачности через многозадачность

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

Каждый процесс 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('Time taken in seconds -', end - start)

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

$ python multiprocess.py
Time taken in seconds - 4.060242414474487

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

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

Альтернативные интерпретаторы Python

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

Просто подождите

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

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

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

Перевод статьи «What Is the Python Global Interpreter Lock (GIL)?».