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.

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 на своих проектах.

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

Комментарии (34)


  1. remixoff
    20.03.2024 11:39

    Как классно жить в современном мире!

    То есть, лет через 6-9 (когда GIL удалят и из флагов, а его удалят!) приличный кусок маленьких проектов, которые авторы писали для себя, и выложили в сеть по доброте душевной, превратятся в тыкву, если тысячи людей за эти 6 лет не потратят дополнительный кусок своей жизни на переписывание проектов, которые им давно не интересны.

    В то же время, мы понимаем, что если бы 15 лет назад (или когда там змея родилась?) послушали инженеров, а не маркетологов, усиленно напирающих на популярные, а не полезные фичи, то через 8 лет мы бы оказались в точке не хуже, чем та, где GIL сначала разработали, потратив кучу ресурсов, а потом удалили, потратив еще одну кучу.

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

    ....
    — Я ж тебя из под земли достану, и под землю закопаю!
    — Но КПД — ноль? НОЛЬ??!!
    ....
    © Кто‑то из КВН


    1. kozlov_de
      20.03.2024 11:39
      +2

      Не удалят. Зачем?

      Только, боюсь, noGIL решение будет некрасивым костылём. Как вижу "stop the world" так думаю "приплыли"


    1. Jury_78
      20.03.2024 11:39
      +9

      Есть же Питон версии 2, и 3, добавят версии 4. ;)


    1. NickNal
      20.03.2024 11:39
      +12

      Куча кода регулярно ломается при обновлении мажорных (а иногда и минорных) версий популярных библиотек, для этого и придумали всякие requirements.txt, virtualenv, контейнеры и т.п.

      А тут переписывают значимую часть ядра языка на горизонте нескольких мажорных версий (5+ лет)

      Не вижу проблемы и повода для ворчания


    1. loltrol
      20.03.2024 11:39
      +1

      Не думаю. Много таких свистелок прямо таки тяжело используют потоки?


    1. xenon
      20.03.2024 11:39
      +3

      GIL сначала разработали, потратив кучу ресурсов, а потом удалили, потратив еще одну кучу

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

      Конечно, все сложное и опасное лучше разруливать как-то безопасно, но это сложно. Поэтому GIL выглядит как простое и надежное, кондовое такое временное решение, которое рано или поздно должно быть заменено. Но на первое время - "сойдет для сельской местности".

      Аналогия: вы строите большой и сложный дом и вы не хотите, чтобы он сгорел из-за пожара в проводке. Сделать всю электрику по уму - долго, дорого и сложно. Поэтому для начала вы просто делаете один рубильник, который отрубает дом от электричества. Просто и надежно. И пока рубильник отключен - у вас дома уж точно проводка не загорится.

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


    1. stepalxser
      20.03.2024 11:39
      +1

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


    1. morijndael
      20.03.2024 11:39
      +1

      Эти проекты куда быстрее превратятся в тыкву из-за изменений/удалений в стандартной библиотеке

      Просто их нужно будет запускать старым интерпретатором, и всё


  1. atues
    20.03.2024 11:39
    +2

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


    1. indrej
      20.03.2024 11:39

      "use strict", или подобное в коде, и компилятор работает по новым стандартам. Хотя тоже костыль, конечно.


  1. evgenyk
    20.03.2024 11:39

    Вот объясните мне, зачем вообще использовать треды? Ладно, если делать что-то простенькое. Но для серьезных вещей всегда можно взять мультипроцессинг. И никаких проблем с GIL/


    1. yarston
      20.03.2024 11:39
      +2

      Вместо проблем с gil будут проблемы с тем, что в каждом процессе свой экземпляр интерпретатора, и для сообщения между ними придётся сериализовывать объекты и передавать через сокеты.


      1. c0r3dump
        20.03.2024 11:39

        Зачем так жёстко с сериализацией-то? Язык один, структуры данных одни, разве нельзя заюзать shm какой-нибудь?


        1. yarston
          20.03.2024 11:39

          Это shared memory? Можно, но если объект не массив байтов - то после сериализации. Да в целом дополнительный сложности и оверхэд по сравнению с остальными языками, о том и статья.


    1. Biga
      20.03.2024 11:39

      Процесс может помереть от внешних причин, и это добавляет различного гемора.


      1. c0r3dump
        20.03.2024 11:39

        Я не против тредов, но все же если процесс упал от внешних причин, то упала часть приложения, а если будут треды, упадёт от этой же причины все приложение, разве нет? Так-то оба подхода норм, но круто же, если не было нормальных тредов, а теперь - будут.


    1. funny_falcon
      20.03.2024 11:39

      Почитайте PEP703, благо на него есть ссылка в статье.

      Там в начале как раз приведены примеры, кому и когда мультипроцессинг не удобен.


    1. alex88django_novice
      20.03.2024 11:39

      Multiprocessing, в целом, нужен, дабы добиться параллелизма (которого нельзя добиться, используя Потоки из-за наличия GIL), используя более "дорогую" абстракцию ОС (нежели Поток) - Процесс.
      - Порождение нового Процесса дороже, чем порождение нового Потока (особенно в Windows / MacOS, где Процессы spawn'ятся, а не fork'аются как в Linux);
      - Переключение контекста между Процессами дороже, чем между Потоками;
      - Так как у Процессов нет общей памяти (у каждого свой heap), то, для синхронизации оных нужно использовать всякие multiprocessing.Queue / multiprocessing.SharedMemory, которые автоматически сериализуют/десериализуют передаваемые между процессами объекты - дополнительный оверхэд.


  1. Ingeniosus
    20.03.2024 11:39
    +3

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


    1. xenon
      20.03.2024 11:39
      +4

      Мне кажется, это костыли.

      Когда новая версия библиотеки не совместима со старой - это плохо. И как костыль, мы используем virtualenv, чтобы для одной программы у нас был пакет python-package-x==1.0.2, а для другой python-package-x==2.0.8. И иногда 2.0.8 вполне может заменить 1.0.2, а вот иногда - нет. И на всякий случай мы для каждой утилитки все дерево зависимостей копируем.

      В результате простая утилитка (текстовый файл же по сути!) может за собой тянуть десятки мегабайт всяких dependencies.

      virtualenv - не right way, а костыль, потому что у нас (даже у майнтейнеров самых популярных пакетов) как-то не выходит соблюдать обратную совместимость. Тут не надо путать: использовать костыли, когда у нас нога сломана - да, это right way, но считать, что костыль - это и есть right way, это уже ошибка.

      Сейчас еще вроде нет проблемы с интерпретатором. Везде, где есть, скажем, python 3.6, можно без страха поставить 3.11 и все будет норм. Несовместимость есть только с переходом python2 -> python3, но в целом мне нравится, как ее реализовали. Сейчас про python2 просто можно забыть и все.

      И в golang такая же херота. Еще на заре развития человечества, когда этот обелиск стоят и обезьяны вокруг него с палками прыгали - изобрели shared libraries, чтобы програмки были маленькими, а все прочее было в .so файлах. Причем если у вас две программы используют одну либу - вам достаточно одного .so на них. Но не смогли справиться со сложностями, и в golang перешли к "новаторскому" решению - а давайте даунгрейднемся до уровня обезьян без палок и обелиска, тупо будем статики компилить!

      И вы предалагаете не решать проблему с костылями, а плотнее на них опираться? Зачем нам ноги в неудобных ботинках, ноги - атавизм! костыли же решают!

      Мы так доиграемся, что каждая утилитка типа ls, less или vim будет со своим докер-контейнером идти.

      alias ls="docker run ls"


      1. falconandy
        20.03.2024 11:39
        +2

        тупо будем статики компилить

        Это одна из фишек golang, за которую в частности его и выбирают.

        Везде, где есть, скажем, python 3.6, можно без страха поставить 3.11 и все будет норм.

        Я редко использую python, но сталкивался с тем, что типы переименовывались или переезжали в другой пакет. Точно не помню, но вроде бы это касалось базовых типов, используемых в type hints.


        1. xenon
          20.03.2024 11:39

          Да, выбирают за простоту - проще упихать тупо все в один бинарь, чем, например, сделать несколько .deb пакетов для разных дебианов-убунт, потом .rpm'ы, потом еще что-то...

          Я о том и говорю - где-то у нас (человечества) не получилось просто и легко решить задачу с зависимостями, поэтому вот и требуется этот костыль со статиками. Флаг --static для gcc существует с каменного века же. Golang не изобрел его. Просто отказался решать сложную задачу управления зависимостями (для которой нет простого-легкого-удобного решения).

          Кстати, это мне тоже не очень понятно. Задача о зависимостях ведь достаточно общая, унифицированная. Для пакета X нужны пакеты A, B, C. Для каждого из них - свои зависимости тоже. И если у нас есть пакеты X и Y и оба зависят от А - было бы неплохо иметь только одну копию A.

          Но почему у нас на одних системах deb, на других rpm, почему в пайтоне приходится использовать pip/pipx а в JS composer/npm ? Мне кажется, достаточно логичным было бы иметь одну простую систему управления зависимостями, которую можно было бы использовать везде. И даже если для нашего нового языка программирования мы хотим свою такую систему (python-pip) - то чтобы могли бы сделать ее на общем фундаменте.


      1. Ingeniosus
        20.03.2024 11:39

        Это крайний идеализм:

        "утилиты станут контейнерами", "виртуальное оружение это костыль".

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


        1. xenon
          20.03.2024 11:39

          Мне кажется, у вас "за спасибо" звучит как аргумент вроде "должно быть вторым сортом". Современные заспасибные опенсорсные технологии вполне себе хорошие (потому и популярные даже там, где можно спиратить что-то платное). А про планирование на 10 лет вперед - согласен конечно.


    1. saege5b
      20.03.2024 11:39
      +1

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

      Например "А" хочет pandas==1.x и protobuf<=4.25,

      "Б" хочет pandas>=2.2,

      "В" которая падает от protobuf<5.


  1. oratorslova
    20.03.2024 11:39

    "2028-2030 годы". Вы оптимист. Мир такой сложный и зыбкий. Столько новых точек напряжённости. Стоит ли загадывать дальше 2025 года?


  1. ef_end_y
    20.03.2024 11:39

    Проблема в искажении концепции тредов. По умолчанию ничего не должно быть общим в тредах, что в 99% случаях и есть в реальных программах. Если что-то нужно общее, то необходимо шарить этот объект и для него уже должны применяться мютексы при изменении, и это будет очень нечасто


    1. vda19999
      20.03.2024 11:39

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

      Но проблема в том, что питону не разрешается падать с segmentation fault из-за ошибки программиста.


      1. ef_end_y
        20.03.2024 11:39

        Есть средства, которые позволяют не заморачиваться об этом программисту. Например, перл. Пометил переменную как общую между потоками - теперь она особо обрабатывается


        1. vda19999
          20.03.2024 11:39

          А что будет если не пометить и использовать в нескольких потоках?

          И второе - помечаться видимо должен объект, на который переменная ссылается?


  1. vda19999
    20.03.2024 11:39
    +1

    В статье не написано, но в питоне 3.13 собранном с выключенным GIL, все объекты занимают больше места, чем в сборке с включённым GIL.

    Если включение GIL будет можно сделать в рантайме - видимо, размер уравняют. И в таком случае, он для всех станет больше? Вот это будет провал.


    1. KazakovDenis Автор
      20.03.2024 11:39

      Подскажите, откуда такая инфа? Из proposal понятно, что размер должен несущественно вырасти, т.к. добавляются новые поля под флаги и мьютексы, но это не должен быть критический прирост. Я ещё не смотрел pull request'ы, сколько он составляет?


      1. vda19999
        20.03.2024 11:39
        +1

        Я собрал CPython без gil (первый скрин) и с ним (второй скрин), и увидел вот что:


  1. AccountForHabr
    20.03.2024 11:39

    Поясните пожалуйста, почему отказ от поколений gc уменьшит количество stop world? Интуитивно кажется что это не должно никак повлиять, но и даже наоборот, отказ спровоцирует увеличение продолжительности stop world...