Какие участки кода в python могут выполняться без gil
Перейти к содержимому

Какие участки кода в python могут выполняться без gil

  • автор:

Python 3.9 без GIL. Что дальше?

Если вы ещё не знакомы с ГБИ (GIL) — глобальной блокировкой интерпретатора — полезно будет посмотреть старое, но актуальное видео «Познавая ГБИ (GIL) Питона».

В дайджесте 03.10.2021 — 10.10.2021 внимательные читатели могли заметить упоминание темы «Python multithreading without the GIL» в разделе «Разработка языка». О чём это?

7 октября Сэм Гросс выступил с предложением обсудить вариант подхода к устранению GIL. Не только выступил, но и опубликовал детальное описание подхода. И не только опубликовал, но и поделился ссылкой на репозиторий, где он применил подход к Python 3.9.

На заметку

Идея избавиться от GIL далеко не нова. Многие об этом говорили и говорят, немногие пытаются, хоть что-то получается у единиц: читайте «Чемпион подустал».

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

На днях в ходе спринта разработчиков CPython прошла встреча с Сэмом, о которой пишет Лукаш Ланга в своей статье «Notes From the Meeting On Python GIL Removal Between Python Core and Sam Gross».

  • замена текущего варианта подсчёта ссылок «пристрастным», позволяющим нитям по-разному обходиться с объектом, в зависимости от того был он порождён в этой нити или в другой;
  • введение понятия «бессмертных» объектов (например, для None , булевых значений, мелких целых, интернированных строк), для которых не требуется подсчёт ссылок;
  • использование механизма отложенного подсчёта ссылок для глобальных, но «смертных» объектов, типа модулей, функций, объектов кода. Чтобы занесение их на стек не изменяло счётчик, а деаллоцировались они только при сборке мусора;
  • замена аллокатора pymalloc на mimalloc , обеспечивающий безопасную работу с нитями и лёгковесные «кучи»;
  • замена стековой виртуальной машины регистровой для ускорения вызовов функций.

На заметку

В готовящийся Python 3.11 уже внесены правки, благодаря которым он сейчас на 16% быстрее (при работе в одной нити), чем реализация Сэма.

  • Невозможно определить, сколько составит конкретно ваш выигрыш при использовании реализации без GIL без замеров на реальном коде.
  • Предлагаемые изменения хорошо показывают себя вместе. По-отдельности могут снижать быстродействие.
  • Авторам расширений на Си, если они захотят получить выигрыш, нужно будет адаптировать расширения. Предполагается, что потребуется продолжительный период адаптации, в котором блокировку можно будет отключать/включать по требованию.
  • Проект с подинтерпретаторами может потерять свою актуальность.
  • Не стоит ожидать, что 3.11 выйдет уже без ГБИ. Но, быть может, mimalloc использовать получится.

Глава 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

Python в три ручья (часть 2). Блокировки

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

В статье рассказывается:

  1. Простая блокировка в Python
  2. С блокировками и без. Пример–сравнение
  3. Как избежать взаимных блокировок?
  4. Другие инструменты синхронизации в Python
  5. Компактные блокировки с with

Пройди тест и узнай, какая сфера тебе подходит:
айти, дизайн или маркетинг.
Бесплатно от Geekbrains

Потоки стремятся к ресурсам, с которыми должны работать. И когда к одному и тому же ресурсу обращается несколько потоков, возникает конфликт. Как его предотвратить?

Потоки нельзя в любой момент напрямую остановить или завершить: на то они и потоки. Но можно на их пути поставить дамбу — блокировку. Она пропустит только один поток, а остальные временно удержит. Так вы исключите конфликт.

Узнай, какие ИТ — профессии
входят в ТОП-30 с доходом
от 210 000 ₽/мес
Павел Симонов
Исполнительный директор Geekbrains

Команда GeekBrains совместно с международными специалистами по развитию карьеры подготовили материалы, которые помогут вам начать путь к профессии мечты.

Подборка содержит только самые востребованные и высокооплачиваемые специальности и направления в IT-сфере. 86% наших учеников с помощью данных материалов определились с карьерной целью на ближайшее будущее!

Скачивайте и используйте уже сегодня:

Павел Симонов - исполнительный директор Geekbrains

Павел Симонов
Исполнительный директор Geekbrains

Топ-30 самых востребованных и высокооплачиваемых профессий 2023

Поможет разобраться в актуальной ситуации на рынке труда

Подборка 50+ бесплатных нейросетей для упрощения работы и увеличения заработка

Только проверенные нейросети с доступом из России и свободным использованием

ТОП-100 площадок для поиска работы от GeekBrains

Список проверенных ресурсов реальных вакансий с доходом от 210 000 ₽

Получить подборку бесплатно
Уже скачали 26493

Когда поток A выполняет операцию с общими ресурсами, а поток Б не может вмешаться в нее до завершения — говорят, что такая операция атомарна. Залогом потокобезопасности как раз выступает атомарность — непрерывность, неделимость операции.

Простая блокировка в Python

Взаимоисключение (mutual exception, кратко — mutex) — простейшая блокировка, которая на время работы потока с ресурсом закрывает последний от других обращений. Реализуют это с помощью класса Lock.

import threading mutex = threading.Lock()

Мы создали блокировку с именем mutex, но могли бы назвать её lock или иначе. Теперь её можно ставить и снимать методами .acquire() и .release():

resource = 0 def thread_safe_function(): global resource for i in range(1000000): mutex.acquire() # Делаем что-то с переменной resource mutex.release()

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

С блокировками и без. Пример–сравнение

Что происходит, когда два потока бьются за ресурсы, и как при этом сохранить целостность данных? Разберёмся на практике.

Для вас подарок! В свободном доступе до 25.02 —>
Скачайте ТОП-10
бесплатных нейросетей
для программирования
Помогут писать код быстрее на 25%
Чтобы получить подарок, заполните информацию в открывшемся окне

Возьмём простейшие операции инкремента и декремента (увеличения и уменьшения числа). В роли общих ресурсов выступят глобальные числовые переменные: назовём их protected_resource и unprotected_resource. К каждой обратятся по два потока: один будет в цикле увеличивать значение с 0 до 50 000, другой — уменьшать до 0. Первую переменную обработаем с блокировками, а вторую — без.

import threading protected_resource = 0 unprotected_resource = 0 NUM = 50000 mutex = threading.Lock() # Потокобезопасный инкремент def safe_plus(): global protected_resource for i in range(NUM): # Ставим блокировку mutex.acquire() protected_resource += 1 mutex.release() # Потокобезопасный декремент def safe_minus(): global protected_resource for i in range(NUM): mutex.acquire() protected_resource -= 1 mutex.release() # То же, но без блокировки def risky_plus(): global unprotected_resource for i in range(NUM): unprotected_resource += 1 def risky_minus(): global unprotected_resource for i in range(NUM): unprotected_resource -= 1 

В названия потокобезопасных функций мы поставили префикс safe_, а небезопасных — risky_.

Создадим 4 потока, которые будут выполнять функции с блокировками и без:

thread1 = threading.Thread(target = safe_plus) thread2 = threading.Thread(target = safe_minus) thread3 = threading.Thread(target = risky_plus) thread4 = threading.Thread(target = risky_minus) thread1.start() thread2.start() thread3.start() thread4.start() thread1.join() thread2.join() thread3.join() thread4.join() print ("Результат при работе с блокировкой %s" % protected_resource) print ("Результат без блокировки %s" % unprotected_resource)

Запускаем код несколько раз подряд и видим, что полученное без блокировки значение меняется случайным образом. При использовании блокировки всё работает последовательно: сначала значение растёт, затем — уменьшается, и в итоге получаем 0. А потоки thread3 и thread4 работают без блокировки и наперебой обращаются к глобальной переменной. Каждый выполняет столько операций своего цикла, сколько успевает за время активности. Поэтому при каждом запуске получаем случайные числа.

Как избежать взаимных блокировок?

Следите, чтобы у нескольких блокировок не было шанса сработать одновременно. Иначе одна заглушка перекроет один поток, другая — другой, и может случиться взаимная блокировка — тупик (deadlock). Это ситуация, когда ни один поток не имеет права действовать и программа зависает или рушится.

Дарим скидку от 60%
на курсы от GeekBrains до 25 февраля
Уже через 9 месяцев сможете устроиться на работу с доходом от 150 000 рублей

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

Пишите код так, чтобы блокировки снимались, даже если функция выбрасывает исключение и завершает работу нештатно. Подстраховаться можно с помощью конструкции try-except-finally:

try: mutex.acquire() # Ваш код. except SomethingGoesWrong: # Обрабатываем исключения finally: # Ещё код mutex.release()

Другие инструменты синхронизации в Python

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

Семафоры (Semaphore)

Семафор — это связка из блокировки и счётчика потоков. Если заданное число потоков уже работает с ресурсам, лишние будут блокироваться. Это удобно, чтобы ограничить число подключений к сети или одновременно авторизованных пользователей программы.

Значение счётчика уменьшается с каждым новым вызовом acquire(), то есть с подключением к ресурсу новых потоков. Когда ресурс высвобождается, значение возрастает. При нулевом значении счётчика работа потока останавливается, пока другой поток не вызовет метод release(). По умолчанию значение счётчика равно 1.

s = Semaphore(5) # В скобках при необходимости указывают стартовое значение счётчика 

Можно создать «ограниченный семафор» конструктором BoundedSemaphore().

События (Event)

Событие — сигнал от одного потока другим. Если событие возникло — ставят флаг методом .set(), а после обработки события — снимают с помощью .clear(). Пока флага нет, ресурс заблокирован. Ждать события могут один или несколько потоков. Важную роль играет wait(): если флаг установлен, этот метод спокойно отдаёт управление ресурсом; если нет — блокирует его на заданное время или до установки флага одним из потоков.

e = threading.Event() def event_manager(): # Ждём, когда кто-нибудь захватит флаг e.wait() . # Ставим флаг e.set() # Работаем с ресурсом . # Снимаем флаг и ждём нового e.clear()

Если нужно задать время ожидания, его пишут в секундах, в виде числа с плавающей запятой. Например: e.wait(3,0).

Только до 22.02
Скачай подборку материалов, чтобы гарантированно найти работу в IT за 14 дней
Список документов:

ТОП-100 площадок для поиска работы от GeekBrains

20 профессий 2023 года, с доходом от 150 000 рублей

Чек-лист «Как успешно пройти собеседование»

Чтобы зарегистрироваться на бесплатный интенсив и получить в подарок подборку файлов от GeekBrains, заполните информацию в открывшемся окне

Метод is_set() проверяет, активно ли событие. Важно следить, чтобы события попадали в поле зрения потоков-потребителей сразу после появления. Иначе работа зависящих от события потоков нарушится.

Рекурсивная блокировка (RLock)

Такая блокировка позволяет одному потоку захватывать ресурс несколько раз, но блокирует все остальные потоки. Это полезно, когда вы используете вложенные функции, каждая из которых тоже применяет блокировку. Число вложенных .acquire() и .release() не даст интерпретатору запутаться, сколько раз поток имеет право захватывать ресурс, а когда блокировку надо снять полностью. Механизм основан на классе RLock:

import threading, random counter = 0 re_mutex = threading.RLock() def step_one(): global counter re_mutex.acquire() counter = random.randint(1,100) print("Random number %s" % counter) re_mutex.release() def step_two(): global counter re_mutex.acquire() counter *= 2 print("Doubled = %s" % counter) re_mutex.release() def walkthrough(): re_mutex.acquire() try: step_one() step_two() finally: re_mutex.release() t = threading.Thread(target = walkthrough) t2 = threading.Thread(target = walkthrough) t.start() t2.start() t.join() t2.join()

Запустите это и проверьте результат: арифметика должна быть верна.

Теперь попробуйте убрать блокировку внутри walkthrough:

def walkthrough(): step_one() step_two()

Ещё раз запустите код — порядок действий нарушится. Программа умножит на 2 только второе случайное число, а затем удвоит полученное произведение.

Переменные состояния (Condition)

Переменная состояния — усложнённый вариант события (Event). Через Condition на ресурс ставят блокировку нужного типа, и она работает, пока не произойдёт ожидаемое потоками изменение. Как только это случается, один или несколько потоков разблокируются. Оповестить потоки о событии можно методами:

  • notify() — для одного потока;
  • notifyAll() — для всех ожидающих потоков.

Это выглядит так:

# Создаём рекурсивную блокировку mutex = threading.RLock() # Создаём переменную состояния и связываем с блокировкой cond = threading.Condition(mutex) # Поток-потребитель ждёт свободного ресурса и захватывает его def consumer(): while True: cond.acquire() while not resourse_free(): cond.wait() get_free_resource() cond.release() # Поток-производитель разблокирует ресурс и уведомляет об этом потребителя def producer(): while True: cond.acquire() unblock_resource() # Сигналим потоку: "Налетай на новые данные!" cond.notify() cond.release()

Чтобы выставить больше одного условия разблокировки, можно увязать доступ к ресурсу с несколькими переменными состояния.

cond = threading.Condition(mutex) another_cond = threading.Condition(mutex)

Компактные блокировки с with

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

Чтобы понять дальнейший материал, кратко разберем работу with, хотя это и не про блокировки. У класса, который мы собираемся использовать с with, должно быть два метода:

  • «Предисловие» — метод __enter__(). Здесь можно ставить блокировку и прописывать другие настройки;
  • «Послесловие» — метод __exit__(). Он срабатывает, когда все инструкции выполнены или работа блока прервана. Здесь можно снять блокировку и/или предусмотреть реакцию на исключения, которые могут быть выброшены.

Удача! У нашего целевого класса Lock эти два метода уже прописаны. Поэтому любой экземпляр объекта Lock можно использовать с with без дополнительных настроек.

Отредактируем функцию из примера с инкрементом. Поставим блокировку, которая сама снимется, как только управляющий поток выйдет за пределы with-блока:

def safe_plus(): global protected_resource for i in range(NUM): with mutex: protected_resource += 1 # И никаких acquire-release! 

Выполнение процессов и потоков и роль GIL

Хочу уточнить, правильно ли я понимаю работу процессов и потоков в ОС. Предположим, у меня есть одноядерная система, на которой я запустил 3 процесса, каждый из которых содержит по 3 потока. Верно ли, что и процессы, и потоки будут выполняться псевдо-параллельно, используя планировщик задач ОС? Если да, то вопрос такой: какую роль в такой системе будет играть глобальная блокировка интерпретатора (GIL) в Python? Она заключается в том, что интерпретатор будет всегда выполнять только одну инструкцию в один момент времени, но из-за ограничений ОС (у нас же одно ядро) получается, что интерпретатор в принципе и без GIL’а не способен нарушить это правило. Глобальная блокировка сильно влияет на выполнение потоков в Python’e, из-за чего они практически неспособны выполнять cpu-bound задач (вычисления, например). Но выходит, что из-за того, что ядро одно и процессор в принципе в любом ЯП будет выполнять по одной инструкции в один момент времени, тот же, например, C# или C++ также будет страдать и плохо выполнять параллельные вычисления в потоках? Хорошо, теперь предположим, что у меня система с четырёх-ядерным процессором и все те же 3 процесса и 3 потока на каждый. Правильно ли я понимаю, что в таком случае процессор сможет выполнять до 4-х потоков одновременно? Допустим, 2 потока из одного процесса и 2 из другого. Это если речь не про Python и GIL. В Python’e, соответственно, все также будет выполняться по одному потоку в единицу времени. Если есть ещё какие-то замечания по этому поводу, буду рад почитать. Спасибо.

Отслеживать

149k 12 12 золотых знаков 59 59 серебряных знаков 132 132 бронзовых знака

задан 23 мая 2021 в 6:34

352 2 2 серебряных знака 10 10 бронзовых знаков

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *