Global Interpreter Lock (GIL) в Python
Global Interpreter Lock (GIL) в Python был представлен, чтобы сделать обработку памяти CPython проще и обеспечить наилучшую интеграцию с языком C (например, с расширениями).
GIL — это механизм блокировки (mutex), которая не позволяет нескольким потокам выполнить один и тот же байткод. Эта блокировка, к сожалению, является необходимой, так как система управления памятью в CPython не является потокобезопасной.
Краткие сведения о GIL:
- Одновременно может выполняться только один поток.
- Интерпретатор Python переключается между потоками для достижения конкурентности.
- GIL применим к CPython (стандартной реализации). Но такие как, например, Jython и IronPython не имеют GIL.
- GIL делает однопоточные программы быстрыми.
- Операциям ввода/вывода, обычно GIL не мешает.
- GIL позволяет легко интегрировать непотокобезопасные библиотеки на языке C
- Благодаря GIL есть много высокопроизводительных расширений/модулей, написанных на языке C.
Для CPU зависимых задач интерпретатор делает проверку каждые N тиков и переключает потоки. Таким образом один поток не блокирует другие.
GIL это не проблема языка Python, а проблема реализации интерпретатора CPython. К счастью, в настоящее время существует несколько способов решения проблем GIL.
Многие видят слабость в GIL. Хотя это можно рассматривать это как благо, ведь были созданы такие библиотеки как | NumPy |, | Pandas |, которые занимают особое, уникальное положение в научном обществе.
GIL для каждого интерпретатора
PEP 684 вводит GIL для каждого интерпретатора, так что теперь можно создавать вспомогательные интерпретаторы с уникальным GIL для каждого интерпретатора. Это позволяет программам на Python в полной мере использовать преимущества нескольких процессорных ядер. В настоящее время это доступно только через C-API, хотя ожидается, что Python API появится в версии 3.13.
Для создания интерпретатора с собственным GIL нужно использовать новую функцию Py_NewInterpreterFromConfig() :
PyInterpreterConfig config = .check_multi_interp_extensions = 1, .gil = PyInterpreterConfig_OWN_GIL, >; PyThreadState *tstate = NULL; PyStatus status = Py_NewInterpreterFromConfig(&tstate, &config); if (PyStatus_Exception(status)) return -1; > /* Новый интерпретатор теперь активен в текущем потоке. */
- КРАТКИЙ ОБЗОР МАТЕРИАЛА.
- Global Interpreter Lock (GIL)
Глава 15. Глобальная блокировка интерпретатора
Одним из основных игроков в параллельном программировании Python выступает GIL ( Global Interpreter Lock , Глобальная блокировка интерпретатора). В данной главе мы обсудим само её определение, а также основные цели имеющейся GIL и того как она оказывает влияние на параллельные приложения в Python. Также будут рассмотрены основные проблемы, которые GIL предлагает для систем совместной обработки Python и те дебаты, которые ведутся вокруг её реализации. Наконец, мы упомянем некоторые соображения, о которых следует задумываться программистам и разработчикам Python и о том как взаимодействовать с существующей GIL.
Данная глава рассмотрит следующие вопросы:
- Некое краткое введение в GIL: что её породило и основные проблемы, которые она вызывает
- Усилия, предпринимаемые в Python для удаления/ исправления GIL
- Как действенно работать с GIL в параллельных программах Python
< Прим. пер.: тем, кому интересно внутреннее устройство GIL и почему она всё- же позволяет ускорять одновременное выполнение потоков, рекомендуем наш перевод Внутреннее устройство CPython Энтони Шоу, изданной в январе 2021 RealPython. Тут же вы можете ознакомиться с новой реализацией параллельности в Python, появившейся начиная с версии 3.9, подчинённым интерпретатором. >
< Прим. пер.: ещё одним существенным моментом внутреннего устройства Python выступают сборка мусора и подсчёт ссылок, которые предоставляют элегантное, но весьма затратное решение (как в отношении ресурсов, так и в отношении времени исполнения), существует подход к ускорению кода без применения сборки мусора в программах на Rust, которые способны интегрироваться с кодом Python, что описывается в нашем переводе Ускоряем ваш Python при помощи Rust Максвелл Флиттон, Packt, 2022. Написание кода на Rust может стать хорошей альтернативой расширениям на C или C++. >
Технические требования
Вот перечень предварительных требований для данной главы:
- Убедитесь что на вашем компьютере уже установлен Python 3
- Вам следует иметь установленными OpenCV и NumPy для вашего дистрибутива Python 3
- Выгрузите необходимый репозиторий из GitHub
- На протяжении данной главы мы будем работать с вложенной папкой, имеющей название Chapter15
- Ознакомьтесь со следующими видеоматериалами Code in Action
Введение в глобальную блокировку интерпретатора
Сам GIL достаточно популярна в сообществе параллельного программирования Python. Будучи разработанной как блокировка, которая будет допускать только один поток для доступа и управления со стороны интерпретатора Python в любой определённый момент времени, GIL в Python часто известна как печально известная GIL, которая препятствует многопоточным программам достижения их полностью оптимальной скорости. В этом разделе мы обсудим ту концепцию, которая стоит за GIL, а также её цели: зачем она была разработана и реализована и то как она воздействует на многопоточное программирование в Python.
Анализ управления памятью в Python
Прежде чем мы ринемся в особенности GIL и её последствия, давайте рассмотрим те проблемы, с которыми сталкивались разработчики ядра Python в ранние дни Python и что породило необходимость самой GIL. В частности, существует существенная разница между программированием на Python и программированием на прочих популярных языках программирования относительно управления объектами в имеющемся пространстве оперативной памяти.
Например, в программном языке C++ некая переменная на самом деле какое- то местоположение в имеющемся пространстве оперативной памяти в котором будет записано некое значение. Такая установка ведёт к тому факту, что при назначении некоторого определённого значения переменной не являющейся указателем, данный язык программирования действенно копирует это определённое значение в данное местоположение оперативной памяти (то есть в саму переменную). Кроме того, когда некоей переменной назначается другая переменная (которая не является указателем), само местоположение оперативной памяти последней переменной будет скопировано в местоположение первой; никакой связи между этими двумя переменными не будет поддерживаться после данного назначения.
С другой стороны Python рассматривает некую переменную просто как некое название, в то время как реальные значения его переменных изолированы в некоторой другой области в имеющемся пространстве памяти. Когда некое значение назначается какой- то переменной, эта переменная действенно получает некую ссылку на то самое местоположение в имеющемся пространстве памяти, где содержится это значение (даже хотя сам термин ссылки не применяется в том же самом смысле, как это имеет место в случае ссылки C++). Управление памятью в Python таким образом фундаментально отличается от той модели помещения некоторого значения в пространстве оперативной памяти, которое мы наблюдаем в C++.
Это означает, что при исполнении некоторой операции назначения Python просто взаимодействует со ссылками и переключается между ними — вместо того чтобы делать это со значениями. Кроме того, по этой же причине множество переменных может ссылаться на одно и то же значение, а изменения выполняемые одной переменной отражаются во всех прочих связанных переменных.
Давайте проанализируем это свойство Python. Если вы уже выгрузили необходимый код данной книги с её страницы GitHub, пройдите далее и переместитесь в каталог Chapter15 . Давайте рассмотрим следующий файл Chapter15/example1.py :
# Chapter15/example1.py import sys print(f'Reference count when direct-referencing: .') a = [7] print(f'Reference count when referenced once: .') b = a print(f'Reference count when referenced twice: .') ########################################################################### a[0] = 8 print(f'Variable a after a is changed: .') print(f'Variable b after a is changed: .') print('Finished.')
В этом примере мы рассматриваем управление соответствующим значением [7] (некий список из одного элемента: целого значения 7 ). Мы уже упомянули, что значения в Python хранятся независимо от переменных, а управление значениями в Python просто ссылается на переменные для соответствующих значений. Имеющийся в Python метод sys.getrefcount() получает некий объект и возвращает значение счётчика всех ссылок, которые связаны с данным объектом. В нашем случае мы вызываем sys.getrefcount() три раза: для самого реального значения, [7] ; для переменной a , которой назначено это значение; и, наконец, для переменной b , которой назначена переменная a .
Кроме того мы изучаем сам процесс мутации этого значения применяя некую ссылающуюся на него переменную и получаемые в результате значения всех тех переменных, которые связаны с этим значением. В частности, мы изменяем самый первый элемент данного списка через переменную a и выводим на печать получаемые значения обеих переменных, a и b . Запустите этот сценарий и вы получите следующее:
> python3 example1.py Reference count when direct-referencing: 1. Reference count when referenced once: 2. Reference count when referenced twice: 3. Variable a after a is changed: [8]. Variable b after a is changed: [8]. Finished.
Как вы можете видеть, этот вывод согласуется с тем что мы обсуждали: для самого первого вызова функции sys.getrefcount() имеется только одно значение счётчика для ссылки на значение [7] , и эта ссылка была создана когда мы напрямую сослались на неё; после того как мы назначили данный список переменной a , его значение составило две ссылки, так как a теперь ассоциирована с этим значением; наконец, когда a было назначено b , на [7] дополнительно ссылается и b и значением счётчика теперь является три.
В получаемом выводе из второй части программы мы можем видеть, что после того как мы изменили то значение, на которое ссылалась переменная a , вместо значения переменной a мутацию претерпело [7] . Как результат, переменная b , которая также ссылалась на то же самое значение, что и a также получила изменение своего значения.
Следующая схема иллюстрирует данный процесс. В программах Python переменные ( a и b ) просто представляют ссылки на свои реальные значения (объекты) и некоторый оператор назначения (скажем, a = b ) выдаёт инструкцию Python, чтобы тот имел две переменные, ссылающиеся на один и тот же объект ( в противоположность копированию реального значения в другое местоположение в памяти, как это делается в C++).
Рисунок 15-1
Диаграмма схемы ссылок в Python
05.03.2020 — Про Python
Изначально я хотел сразу начать отвечать на вопрос с собеса про память в Python, но поняв, что выйдет очень длинно, решил разбить на несколько постов.
Сейчас поговорим про GIL, в следующих статьях про GC, закончим уже поверхностно про пуллы памяти, арены.
GIL — это просто лок, который разрешает только одному потоку удерживать контроль на интерпретатором Python’a (собсна такое определение можно было дать и из названия).
Не просто так конечно выделил жирным про один поток. Да, в этом вся печаль и рофел. Никакого параллельного выполнения потоков у нас НЕТ! Когда речь идет о однопоточной программе, то вообще всё равно, но если мы работаем в нескольких потоках, то это бьёт нам по производительности. Всё это можно сравнить с ядрами нашего ЦП. Мы всегда исключительно используем одно, а другие просто наблюдают как другой напрягается.
GIL нужен для решения проблемы race conditions. Сразу на примере, но придется затронуть чутка сборщик мусора. У каждого объекта (PyObject) есть Py_REFCNT где хранится количество ссылок на этот объект. Когда количество ссылок становится равным нулю — происходит освобождение памяти. Если количество ссылок == 0 , то, понятное дело, объект нигде не используется и достучаться до него из кода уже невозможно. Возвращаемся к проблеме. Когда у нас многопоточное приложение, то один поток может удалить ссылку и сделать -= 1 к данной переменной, а другое наоборот захочет сделать += 1 . Желание у них появилось одновременно и непонятно какое значение у переменной будет в конце. В итоге это приведет к непоняткам. Или объект случайно удалится GC, хотя на него есть ссылка где-то, или наоборот, никогда не удалится. Будет дичь.
Решением это дичи будет добавление локов к каждому объекту, что использует эту переменную с количеством ссылок. Дабы организовать последовательное обращение к переменной. Стоит лок — ждем. Свободно — заходим и лочимся за собой. Когда уходим — разлокиваем. Звучит просто, но лок в каждом объекте приводит во-первых к их огромному количеству, а во-вторых к дедлокам. Дедлок — это такая ситуация, что мы сидим, ждем пока там освободят эту переменку, но она не освобождается. Почему? Потому что кто её ждет сам её и удерживает.
Поэтому GIL -> Global -> single и так мы пришли да, к одному общему, глобальному локу для всех.
Данный лок элементарно и круто справляется со своей задачей. У нас никого не возникает проблем. Никаких гонок, никаких дедлоков, ляпота же. Только вот мы теряем производительность. Сам этот GIL был написан чертовски давно. То, что используется сейчас, было написано ещё 10 лет назад (в 2009) и особых изменений с того времени не было.
GIL был выбран ещё на этапах проектирования Python’a. Как обычно в моих статьях, не могу не сказать про то, что Python является простым и для всех, для любых задач. Собсна тогда (да и сейчас) многие расширения на C требовали безопасного менеджмента памяти, а GIL это давал с помощью очень простого решения. В общем, так исторически сложилось и сейчас он так сильно врос, что от него сложно избавиться, но процесс уже запущен (сабинтепретаторы в Python 3.8). Нельзя просто так взять и выбросить GIL (можно, это уже делали), ибо это сломает огромное количество всё тех же расширений C.
Мы получаем в CPython какой-то флаг (или семафор) и каждый поток должен запрашивать у нашего GIL’a доступ. Сама блокировка находится в основном цикле нашего байткода, о байткоде я писал в одной из прошлой статье. В текущей реализации нет приоритетов, нет четкой последовательности какой поток получит следующим доступ. Ничего этого нет. Всё это перекладывается на ОС.
Вверху всё было про то, что уже есть. Однако в последних версиях Python’a, как я уже упомянул чуть выше, есть subinterpreters. Суть проста: у нас есть процесс, у него есть несколько интерпретаторов (о то что это тоже в прошлой статье), у каждого интерпретатора конечно же свой GIL, а уже у интерпретаторов свой набор потоков. Конечно нам нужен доступ какой-то общий между двумя интерпретаторами, но у нас ведь два GIL’a. Поэтому предлагается добавить общую память между ними и использовать указатели для поиска объектов. В данном случае управлять блокировками между интерпретаторами будет сам процесс. Но как я понял, какого-то API для такого шардирования ещё нет.
Ниже классный старый видосик для куда более тонкого понимания GIL’a, но мне хватит и текущих знаний (пока что), а Вам?
Зачем нужен Python Global Interpreter Lock и как он работает
Python Global Interpreter Lock (GIL) — блокировка, позволяющая только одному потоку управлять интерпретатором Python. Рассмотрим, как она работает.
Python Global Interpreter Lock (GIL) — это своеобразная блокировка, позволяющая только одному потоку управлять интерпретатором Python. Это означает, что в любой момент времени будет выполняться только один конкретный поток.
Работа GIL может казаться несущественной для разработчиков, создающих однопоточные программы. Но во многопоточных программах отсутствие GIL может негативно сказываться на производительности процессоро-зависымых программ.
Поскольку GIL позволяет работать только одному потоку даже в многопоточном приложении, он заработал репутацию «печально известной» функции.
В этой статье будет рассказано о том, как GIL влияет на производительность приложений, и о том, как это самое влияние можно смягчить.
Что за проблему в Python решает GIL?
Python подсчитывает количество ссылок для корректного управления памятью. Это означает, что созданные в Python объекты имеют переменную подсчёта ссылок, в которой хранится количество всех ссылок на этот объект. Как только эта переменная становится равной нулю, память, выделенная под этот объект, освобождается.
Вот небольшой пример кода, демонстрирующий работу переменных подсчёта ссылок:
>>> import sys >>> a = [] >>> b = a >>> sys.getrefcount(a) 3
В этом примере количество ссылок на пустой массив равно 3. На этот массив ссылаются: переменная a , переменная b и аргумент, переданный функции sys.getrefcount() .
Проблема, которую решает GIL, связана с тем, что в многопоточном приложении сразу несколько потоков могут увеличивать или уменьшать значения этого счётчика ссылок. Это может привести к тому, что память очистится неправильно и удалится тот объект, на который ещё существует ссылка.
Счётчик ссылок можно защитить, добавив блокираторы на все структуры данных, которые распространяются по нескольким потокам. В таком случае счётчик будет изменяться исключительно последовательно.
Но добавление блокировки к нескольким объектам может привести к появлению другой проблемы — взаимоблокировки (англ. deadlocks), которая получается только если блокировка есть более чем на одном объекте. К тому же эта проблема тоже снижала бы производительность из-за многократной установки блокираторов.
GIL — эта одиночный блокиратор самого интерпретатора Python. Он добавляет правило: любое выполнение байткода в Python требует блокировки интерпретатора. В таком случае можно исключить взаимоблокировку, т. к. GIL будет единственной блокировкой в приложении. К тому же его влияние на производительность процессора совсем не критично. Однако стоит помнить, что GIL уверенно делает любую программу однопоточной.
Несмотря на то, что GIL используется и в других интерпретаторах, например в Ruby, он не является единственным решением этой проблемы. Некоторые языки решают проблему потокобезопасного освобождения памяти с помощью сборки мусора.
С другой стороны это означает, что такие языки часто должны компенсировать потерю однопоточных преимуществ GIL добавлением каких-то дополнительных функций повышения производительности, например JIT-компиляторов.
Почему для решения проблемы был выбран именно GIL?
Итак, почему же это не очень «хорошее» решение используется в Python? Насколько для разработчиков это решение критично?
По словам Larry Hastings, архитектурное решение GIL — это одна из тех вещей, которые сделали Python популярным.
Python существует с тех времён, когда в операционных системах не существовало понятия о потоках. Этот язык разрабатывался в расчёте на лёгкое использование и ускорение процесса разработки. Всё больше и больше разработчиков переходило на Python.
Много расширений, в которых нуждался Python, было написано для уже существующих библиотек на C. Для предотвращения несогласованных изменений, язык C требовал потокобезопасного управления памятью, которое смог предоставить GIL.
GIL можно было легко реализовать и интегрировать в Python. Он увеличивал производительность однопоточных приложений, поскольку управление велось только одним блокиратором.
Те библиотеки на C, которые не были потокобезопасными, стало легче интегрировать. Эти расширения на C стали одной из причин, почему Python-сообщество стало расширяться.
Как можно понять, GIL — фактическое решение проблемы, с которой столкнулись разработчики CPython в начале жизни Python.
Влияние GIL на многопоточные приложения
Если смотреть на типичную программу (не обязательно написанную на Python) — есть разница, ограничена ли эта программа производительностью процессора или же I/O.
Операции, ограниченные производительностью процессора (англ. CPU-bound) — это все вычислительные операции: перемножение матриц, поиск, обработка изображений и т. д.
Операции, ограниченные производительностью I/O (англ. I/O-bound) — это те операции, которые часто находятся в ожидании чего-либо от источников ввода/вывода (пользователь, файл, БД, сеть). Такие программы и операции иногда могут ждать долгое время, пока не получат от источника то, что им нужно. Это связано с тем, что источник может проводить собственные (внутренние) операции, прежде чем он будет готов выдать результат. Например, пользователь может думать над тем, что именно ввести в поисковую строку или же какой запрос отправить в БД.
Ниже приведена простая CPU-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('Затраченное время -', end - start)
Запустив это на 4х-ядерном компьютере получим такой результат:
Затраченное время
Ниже приведена та же программа, с небольшим изменением. Теперь обратный отсчёт ведётся в двух параллельных потоках:
# 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
Как видно из результатов, оба варианта затратили примерно одинаковое время. В многопоточной версии GIL предотвратил параллельное выполнение потоков.
GIL не сильно влияет на производительность I/O-операций в многопоточных программах, т. к. в процессе ожидания от I/O блокировка распространяется по потокам.
Однако программа, потоки которой будут работать исключительно с процессором (например обработка изображения по частям), из-за блокировки не только станет однопоточной, но и на её выполнение будет затрачиваться больше времени, чем если бы она изначально была строго однопоточной.
Такое увеличение времени — это результат появления и реализации блокировки.
Почему GIL всё ещё используют?
Разработчики языка получили уйму жалоб касательно GIL. Но такой популярный язык как Python не может провести такое радикальное изменение, как удаление GIL, ведь это, естественно, повлечёт за собой кучу проблем несовместимости.
В прошлом разработчиками были предприняты попытки удаления GIL. Но все эти попытки разрушались существующими расширениями на C, которые плотно зависели от существующих GIL-решений. Естественно, есть и другие варианты, схожие с GIL. Однако они либо снижают производительность однопоточных и многопоточных I/O-приложений, либо попросту сложны в реализации. Вам бы не хотелось, чтобы в новых версиях ваша программа работала медленней, чем сейчас, ведь так?
Создатель Python, Guido van Rossum, в сентябре 2007 года высказался по поводу этого в статье «It isn’t Easy to remove the GIL»:
С тех пор ни одна из предпринятых попыток не удовлетворяла это условие.
Почему GIL не был удалён в Python 3?
Python 3 на самом деле имел возможность переделки некоторых функций с нуля, хотя из-за этого многие расширения на С попросту сломались бы и их пришлось бы переделывать. Именно из-за этого первые версии Python 3 так слабо расходились по сообществу.
Но почему бы параллельно с обновлением Python 3 не удалить GIL?
Его удаление сделает однопоточность в Python 3 медленней по сравнению с Python 2 и просто представьте, во что это выльется. Нельзя не заметить преимущества однопоточности в GIL. Именно поэтому он всё ещё не удалён.
Но в Python 3 действительно появились улучшения для существующего GIL. До этого момента в статье рассказывалось о влиянии GIL на многопоточные программы, которые затрагивают только процессор или только I/O. А что насчёт тех программ, у которых часть потоков идут на процессор, а часть на I/O?
В таких программах I/O-потоки «страдают» из-за того, что у них нет доступа к GIL от процессорных потоков. Это связано со встроенным в Python механизмом, который принуждал потоки освобождать GIL после определённого интервала непрерывного использования. В случае, если никто другой не используют GIL, эти потоки могли продолжать работу.
>>> import sys >>> # По умолчанию интервал выставлен в 100 >>> sys.getcheckinterval() 100
Но тут есть одна проблема. Почти всегда GIL занимается процессорными потоками и остальные потоки не успевают занять место. Этот факт был изучен David Beazley, визуализацию этого можно увидеть здесь.
Проблема была решена в Python 3.2 в 2009 разработчиком Antoine Pitrou. Он добавил механизм подсчёта потоков, которые нуждаются в GIL. И если есть другие потоки, нуждающиеся в GIL, текущий поток не занимал бы их место.
Как справиться GIL?
Если GIL у вас вызывает проблемы, вот несколько решений, которые вы можете попробовать:
Многопроцессность против многопоточности. Довольно популярное решение, поскольку у каждого 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. У Python есть много разных реализаций интерпретаторов. CPython, Jyton, IronPython и PyPy, написанные на C, Java, C# и Python соответственно. GIL существует только на оригинальном интерпретаторе — на CPython.
Вы просто можете использовать преимущества однопоточности, в то время, пока одни из самых ярких умов прямо сейчас работают над устранением GIL из CPython. Вот одна из попыток.
Зачастую, GIL рассматривается как нечто-то сложное и непонятное. Но имейте ввиду, что как python-разработчик, вы столкнётесь с GIL только если будете писать расширения на C или многопоточные процессорные программы.
На этом этапе вы должны понимать все аспекты, необходимые при работе с GIL. Если же вам интересна низкоуровневая структура GIL — посмотрите Understanding the Python GIL от David Beazley.