GIL в Python: как его будут отключать

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

Нельзя так просто взять и удалить GIL

Нельзя так просто взять и удалить GIL

Меня зовут Денис, и я Python-разработчик. Сегодня я хотел бы пересказать вам содержание PEP 703 «Making the Global Interpreter Lock Optional in CPython», одного из наиболее интересных проектов в мире CPython, работа над которым началась в январе 2023 года.

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

О чём PEP?

Вам, наверняка, известно, что сейчас ведётся активная работа по ускорению CPython — это проекты Faster CPython, Subinterpreters, Per-Interpreter GIL, No-GIL. Мы будем рассматривать часть, касающуюся последнего проекта.

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

24f05ab6c2f67f022be5c56bf1cfcbc8.png

PEP 703 представил подробный план того, что будет сделано для внедрения флага компиляции --disable-gil. Таким образом, любой желающий сможет собрать из исходников Python, у которого GIL будет отключен. Руководящий совет (Steering council) утвердил изменения к внедрению с версии 3.13, но с оговоркой, что они могут быть как частично, так и полностью обращены. Документ содержит большую мотивационную часть, мы рассмотрим лишь предлагаемые техники.

Основные идеи

Удаление GIL затрагивает многие части языка, поэтому все изменения были разделены на четыре категории:

  • подсчёт ссылок,

  • управление памятью,

  • потокобезопасность контейнеров (имеются ввиду такие структуры данных, как list и dict),

  • блокировки и атомарные API.

Автор PEP не совсем следует этой структуре, поэтому я буду рассматривать интересующие моменты просто по порядку.

Подсчёт ссылок

Каждый объект Python имеет счётчик ссылок (ob_refcnt). До версии 3.12 включительно счётчики ссылок изменялись только при захвате GIL исполняющим потоком, что и мешало обеспечить настоящую параллельность.

Счётчик ссылок изменяется только при захвате GIL

Счётчик ссылок изменяется только при захвате GIL

Как это работало:

  • один из потоков захватывает GIL на очередной итерации вычислительного цикла,

  • выполняет следующую инструкцию байт-кода,

  • во время исполнения инструкции изменяет значение счётчика,

  • проверяет, не ожидают ли GIL другие потоки, интервал переключения и принимает решение отпускать его или нет.

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

Как PEP 703 предлагает исправить ситуацию? Для этого будут использованы следующие подходы:

  • раздельный подсчёт ссылок (Biased reference counting, далее — BRC),

  • увековечивание (Immortalization),

  • отложенный подсчёт ссылок (Deferred reference counting, далее — DRC).

Разберём каждую из техник.

Раздельный подсчёт ссылок основан на наблюдении, что даже в многопоточных программах большинство объектов бывают востребованы только в том потоке, в котором они были созданы. Зная данный факт, мы можем упростить подсчёт ссылок для потока, владеющего объектом (например, не захватывать для этого GIL). Таким образом, для каждого PyObject будет введено 2 счётчика ссылок:

  • локальный (ob_ref_local) — для потока, владеющего объектом,

  • общий (ob_ref_shared) — для остальных потоков, его потребуется изменять атомарно (т.е. захватывая некий мьютекс).

Короткий и длинный пути изменения счётчиков потоками

Короткий и длинный пути изменения счётчиков потоками

Увековечивание — это техника, при которой статически аллоцированные объекты, такие как True, False, None, числа от -5 до 255, интернированные строки и т.п., помечаются бессмертными, и операции изменения счётчика ссылок для них будут холостыми (no-op).

Для бессмертных объектов изменение счетчиков не происходит

Для бессмертных объектов изменение счетчиков не происходит

Некоторые объекты в Python, такие как глобальные функции или импортированные модули, могут не быть доступны на протяжении всей жизни программы (например, вы можете удалить их из области видимости через del, уменьшив тем самым счётчик ссылок), при этом обращения к ним зачастую происходят из разных потоков.

Увековечивание в данном случае применить не удастся. Тогда на сцену выходит Отложенный подсчёт ссылок.

Отложенный подсчёт ссылок происходит при сборке мусора

Отложенный подсчёт ссылок происходит при сборке мусора

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

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

Управление памятью

На текущий момент CPython использует свой аллокатор pymalloc (на эту тему рекомендую вам документацию потрясающего профилировщика памяти Memray), который хорошо оптимизирован под выделение памяти для малых объектов, но не безопасен в многопоточной среде без GIL. В PEP предлагается заменить его потокобезопасным аллокатором Mimalloc.

Самое важное в этом пункте: Python-объекты должны аллоцироваться только соответствующим API, и наоборот это API должно использоваться только для Python-объектов.

Сборка мусора

Сборщик мусора (Garbage collector, далее — GC) потребует следующих изменений:

  • использования «stop-the-world» для обеспечения гарантий, которые ранее предоставлялись GIL,

  • переход от GC с поколениями к GC без поколений, чтобы сократить количество «stop-the-world»,

  • интеграцию с DRC и BRC.

Так как без GIL мы не можем гарантировать, что значения счётчиков ссылок не изменятся во время сборки мусора и ссылочные циклы будут определены, то появляется необходимость приостановить работу всех потоков, выполняющих байт-код. В текущей реализации GC требуется двойной обход для детектирования циклов, поэтому в новой реализации будет применено два »stop-the-world».

Stop this world.

Stop this world.

Для обеспечения приостановки потоков в структуру PyThreadState будет добавлено новое поле status, которое сможет принимать следующие значения: ATTACHED, DETACHED, GC.

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

Во время «stop-the-world» поток, в котором выполняется сборка мусора, должен убедиться, что никакие другие потоки не получили доступа к объектам, не изменяют их и перешли в статус GC (из состоянияDETACHED). Потоки в состоянии ATTACHED получают запрос на приостановку и самостоятельно переходят в статус GC. После сборки мусора потоки возвращаются в свои состояния.

Потокобезопасность контейнеров

Благодаря GIL операции со встроенными типами, такими как list, set, dict, потокобезопасны. Без него мы можем получить ситуацию, когда такие вызовы как list.extend(iterable) будут неатомарным, поскольку iterable может реализовывать протокол итератора на самом Python. Для сохранения ожидаемого поведения предлагается ввести мьютекс на уровне каждого такого контейнера. Однако, такой подход не может обеспечить на 100% те же гарантии, что и GIL. Например, та же операция list.extend(iterable) потребует одновременную блокировку обоих контейнеров.

В новых реалиях следущая конструкция даже на уровне Си-кода будет небезопасна, поскольку другой поток может изменить item между указанными вызовами:

PyObject *item = PyList_GetItem(list, idx);
Py_INCREF(item);

Решить подобную проблему предлагается введением новых функций, которые будут возвращать объекты с уже изменёнными счётчиками. Концепция названа Borrowed references. Например, вместо PyDict_GetItem будут использовать PyDict_FetchItem.

Однако, как многие уже могли догадаться, вынос мьютексов на уровень объектов можеть стать причиной взаимных блокировок (Deadlock), поскольку потоки, как правило, оперируют более чем одним объектом одновременно (тот же list.extend(iterable)).

Данный PEP вводит понятие «Критических секций Python», в которых тот или иной мьютекс неявно будет освобождаться и перезахватываться обратно при определённых условиях. Главная идея — один поток в один момент времени должен владеть только одним мьютексом. Подробно останавливаться на этом моменте не будем.

Блокировки и атомарные API

Для методов FetchItem и GetItem у dict и list будет представлен способ не захватывать объектный мьютекс (то есть без использования критических секций), если эти объекты в тот же момент не модифицируются другими потоками, и он назван Оптимистичным обходом блокировок (Optimistically Avoiding Locking).

Так решено сделать из следующих соображений:

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

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

Как мы упомянали ранее, доступ к объекту контейнера и изменение его счётчика ссылок — операция неатомарная и требует, как минимум, 2 действия, поэтому их закрывают мьютексом. В указанных выше 2 случаях предлагают использовать условный инкремент, который выполняется только, если счётчик ссылок не достиг нуля, и механизм похожий на Read-copy update (RCU).

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

Что дальше?

Следующим шагом спустя 2–3 года после релиза 3.13, то есть в 2026–2027 году core-разработчики предлагают перенести отключение GIL из флага компиляции в рантайм, например, путём добавления переменной окружения, но GIL иметь при этом по умолчанию включенным.

Затем, спустя ещё 2–3 года (2028–2030), GIL будет отключен на постоянной основе, но включить его можно будет также флагом или переменной. Это позволит мягко мигрировать всем python-проектам на новую парадигму. Но как видите, ещё довольно не скоро.

Заключение

PEP 703 содержит ещё много различной информации. Лично мне было интересно даже просто ознакомиться с перечисленными концепциями. Посмотрим, что из этого принесёт нам CPython 3.13, и будет ли от изменений положительный эффект. Я с удовольствием протестирую --disable-gil на своих проектах.

Что вы думаете думаете по поводу всего озвученного?

© Habrahabr.ru