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

Оглавление

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

# 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, не вызвав проблем с обратной совместимостью.

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

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

Создатель и BDFL Python, Гвидо ван Россум, дал ответ сообществу в сентябре 2007 года в своей статье "It is not Easy to remove the GIL":

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

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

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

В Python 3 многие функции пришлось начинать с нуля, что привело к поломке некоторых существующих расширений на языке С, которые затем потребовали доработки и переноса для работы с 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
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100

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

Эта проблема была исправлена в 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('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 GIL часто считается загадочной и сложной темой. Но имейте в виду, что вас, как питониста, он обычно затрагивает только в том случае, если вы пишете расширения на языке C или используете в своих программах многопоточность, привязанную к процессору.

В таком случае эта статья должна дать вам все необходимое для понимания того, что такое GIL и как с ним работать в своих проектах. А если вы хотите разобраться в низкоуровневой внутренней работе GIL, то я рекомендую вам посмотреть доклад Understanding the Python GIL Дэвида Бизли (David Beazley)

Вернуться на верх