Python Global Interpreter Lock (глобальная блокировка интерпретатора), или GIL, простыми словами, представляет собой мьютекс (или блокировку), который позволяет только одному потоку контролировать интерпретатор Python.
Это означает, что в любой момент времени в состоянии выполнения может находиться только один поток. Влияние GIL не заметно для разработчиков, выполняющих однопоточные программы, но оно может стать узким местом в производительности процессорного и многопоточного кода.
Поскольку GIL позволяет одновременно выполнять только один поток даже в многопоточной архитектуре с более чем одним ядром процессора, он приобрел репутацию «печально известной» особенности Python.
Из этой статьи вы узнаете, как глобальная блокировка интерпретатора влияет на производительность ваших 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-компиляторов.
Почему же в Python был использован подход, который, казалось бы, так мешает? Было ли это неудачным решением разработчиков Python?
По мнению Ларри Хастингса, дизайнерское решение GIL — это одна из тех вещей, которые сделали Python таким популярным, каким он является сегодня.
Язык Python появился еще в те времена, когда в операционных системах не было понятия потоков. Python был создан простым в использовании, чтобы сделать разработку более быстрой. В результате его стали применять многие разработчики.
Для существующих библиотек на языке C было написано множество расширений, которые были необходимы в Python. Для предотвращения непоследовательных изменений эти расширения на C требовали потокобезопасного управления памятью, что и обеспечивалось GIL.
GIL прост в реализации и легко добавляется в Python. Он обеспечивает прирост производительности однопоточных программ, поскольку необходимо управлять только одной блокировкой.
Библиотеки языка C, которые не были потокобезопасными, стали легче интегрироваться. И эти расширения языка C стали одной из причин, по которой Python был легко принят различными сообществами.
Как видите, GIL был прагматичным решением сложной проблемы, с которой столкнулись CPython-разработчики на ранних этапах существования 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 не оказывает существенного влияния на производительность многопоточных программ, связанных со вводом/выводом, так как блокировка разделяется между потоками, пока они ожидают ввода/вывода.
Но программа, потоки которой полностью привязаны к процессору, например, программа, обрабатывающая изображение по частям с помощью потоков, не только станет однопоточной из-за блокировки, но и получит увеличение времени выполнения по сравнению со сценарием, когда она была написана полностью однопоточной.
Это увеличение является результатом накладных расходов на установку и освобождение блокировки.
Разработчики Python получают много жалоб по этому поводу. Но такой популярный язык, как Python, не может внести столь существенное изменение, как удаление GIL, не вызвав проблем с обратной совместимостью.
Очевидно, что глобальную блокировку интерпретатора можно удалить. Это неоднократно делалось в прошлом разработчиками и исследователями. Но все эти попытки приводили к поломке существующих расширений на языке C, которые в значительной степени зависели от решений, предоставляемых GIL.
Конечно, существуют и другие решения проблемы, которую решает GIL, но некоторые из них снижают производительность однопоточных и многопоточных программ, связанных со вводом-выводом, а некоторые просто слишком сложны. В конце концов, вы же не хотите, чтобы ваши существующие программы на Python работали медленнее после выхода новой версии?
Создатель и BDFL Python Гвидо ван Россум дал ответ сообществу в сентябре 2007 года в своей статье «Нелегко удалить GIL»:
«Я бы приветствовал набор патчей в Py3k только если производительность для однопоточной программы (и для многопоточной, но связанной со вводом/выводом) не уменьшится«.
И это условие не было выполнено ни в одной из предпринятых с тех пор попыток.
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, пока другим потокам не дадут шанс выполниться.
Механизм глобальной блокировки интерпретатора вызывает у вас проблемы, то можно попробовать несколько подходов.
Самый популярный способ — это использовать подход многозадачности через многопроцессорность, где вы используете несколько процессов вместо потоков.
Каждый процесс 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 имеет несколько реализаций интерпретаторов. Наиболее популярными являются 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)?».
При анализе данных часто требуется быстро найти абсолютное значение набора чисел. Для выполнения этой задачи…
Pydantic - это мощная библиотека проверки данных и управления настройками для Python, созданная для повышения…
Python предлагает набор библиотек, удовлетворяющих различные потребности в визуализации, будь то академические исследования, бизнес-аналитика или…
В Python для представления данных в двоичной форме можно использовать байты. Из этой статьи вы…
В этой статье рассказывается о том, что такое Werkzeug и как Flask использует его для…
При работе с датами часто возникает необходимость прибавлять к дате или вычитать из нее различные…