Как работает GIL в Ruby. Часть 1

http://www.jstorimer.com/blogs/workingwithcode/8085491-nobody-understands-the-gil
  • Перевод
Пять из четырех разработчиков признают, что многопоточное программирование понять непросто.

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

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

Я хотел знать, как работает GIL с технической точки зрения. На GIL нет ни спецификации, ни документации. По сути, это особенность MRI (Matz's Ruby Implementation). Команда разработчиков MRI ничего не говорит по поводу того, как GIL работает и что гарантирует.

Впрочем, я забегаю вперед.

Если вы совсем ничего не знаете о GIL, вот описание в двух словах:


В MRI есть нечто, называемое GIL (global interpreter lock, глобальная блокировка интерпретатора). Благодаря ей в многопоточном окружении в некоторый момент времени может выполняться Ruby-код только в одном потоке.

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

Из статьи «Parallelism is a Myth in Ruby» 2008 года за авторством Ильи Григорика я получил общее понимание о GIL. Вот только общее понимание не поможет разобраться с техническими вопросами. В частности, я хочу знать, гарантирует ли GIL потокобезопасность определенных операций в Ruby. Приведу пример.

Добавление элемента к массиву не потокобезопасно


В Ruby вообще мало что потокобезопасно. Возьмем, например, добавление элемента к массиву

array = []

5.times.map do
  Thread.new do
    1000.times do
      array << nil
    end
  end
end.each(&:join)

puts array.size

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

$ ruby pushing_nil.rb
5000

$ jruby pushing_nil.rb
4446

$ rbx pushing_nil.rb
3088

=(

Даже в таком простом примере мы сталкиваемся с непотокобезопасными операции. Разберемся в происходящем.

Обратим внимание на то, что запуск кода с использованием MRI дает верный (возможно, в данном контексте вам больше понравится слово «ожидаемый» — прим. пер.) результат, а JRuby и Rubinius — нет. Если запустить код еще раз, ситуация повторится, причем JRuby и Rubinius дадут другие (по-прежнему некорректные) результаты.

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

Почему параллельные потоки могут нарушить целостность данных


Как такое может быть? Думали, Ruby такого не допустит? Посмотрим, как это технически возможно.

Будь то MRI, JRuby или Rubinius, Ruby реализован на другом языке: MRI написан на C, JRuby на Java, а Rubinius — на Ruby и C++. Поэтому при выполнении одной операции в Ruby, например, array << nil, может оказаться, что ее реализация состоит из десятков, а то и сотен строк кода. Вот реализация Array#<< в MRI:

VALUE
rb_ary_push(VALUE ary, VALUE item)
{
    long idx = RARRAY_LEN(ary);

    ary_ensure_room_for_push(ary, 1);
    RARRAY_ASET(ary, idx, item);
    ARY_SET_LEN(ary, idx + 1);
    return ary;
}

Заметим, что здесь есть как минимум четыре разных операции:

  1. Получение текущей длины массива
  2. Проверка на наличие памяти для еще одного элемента
  3. Добавление элемента к массиву
  4. Присваивание длине массива старого значения + 1

Каждая из них обращается к другим функциям. Я обращаю внимание на эти детали для того, чтобы показать, как параллельные потоки могут нарушить целостность данных. Мы привыкли к линейному пошаговому выполнению кода — в однопоточном окружении можно взглянуть на короткую функцию на C и легко отследить порядок выполнения кода.

Но если мы имеем дело с несколькими потоками, так сделать нельзя. Если у нас есть два потока, они могут выполнять разные участки кода функции и приходится следить за двумя цепочками выполнения кода.

Кроме того, так как потоки используют общую память, они могут одновременно изменять данные. Один из потоков может прервать другой, изменить общие данные, после чего другой поток продолжит выполнение, будучи не в курсе о том, что данные изменились. Это и есть причина, по которой некоторые реализации Ruby выдают неожиданные результаты при простом добавлении nil к массиву. Происходящая ситуация подобна описанной ниже.

Изначально система находится в следующем состоянии:



У нас есть два потока, каждый из которых вот-вот приступит к выполнению функции. Пусть шаги 1-4 будут псевдокодом реализации Array#<< в MRI, приведенной выше. Ниже приведено возможное развитие событий (в начальный момент времени активен поток A):



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

Это всего лишь один из возможных вариантов развития событий:

Поток A начинает выполнять код функции, но когда очередь доходит до шага 3, происходит переключение контекста. Поток A приостанавливается и настает очередь потока B, который выполняет весь код функции, добавляя элемент и увеличивая длину массива.

После этого возобновляется поток A ровно с той точки, в которой был остановлен, а это случилось прямо перед тем, как увеличить длину массива. Поток A присваивает длине массива значение 1. Вот только поток B уже успел изменить данные.

Еще раз: поток B присваивает длине массива значение 1, после чего поток A тоже присваивает ей 1, несмотря на то, что оба потока добавили к массиву элементы. Целостность данных нарушена.

А я полагался на Ruby


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

Если запустить пример выше несколько раз, используя JRuby или Rubinius, вы увидите, что результат всегда разный. Переключение контекста непредсказуемо. Оно может случиться раньше или позже или вообще не произойти. Я коснусь этой темы в следующей секции.

Почему Ruby не защищает нас от этого безумия? По той же причине, по которой базовые структуры данных в других языках не потокобезопасны: это слишком накладно. Реализации Ruby могли бы иметь потокобезопасные структуры данных, но это потребует оверхед, который сделает код еще медленее. Поэтому бремя обеспечения потокобезопасности перенесено на программиста.

Я до сих пор не коснулся технических деталей реализации GIL, и главный вопрос все еще остается неотвеченным: почему запуск кода на MRI все равно дает верный результат?

Этот вопрос послужил причиной, по которой я написал эту статью. Общее понимание GIL не дает ответа на него: ясно, что только один поток может выполнять Ruby-код в некоторый момент времени. Но ведь переключение контекста все равно может произойти посередине функции?

Но сначала...

Виной всему планировщик


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

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

Это эффективно с точки зрения ОС, но вносит некоторую случайность и мотивирует пересмотреть взгляд на корректность программы. Например, при выполнении Array#<< следует иметь в виду, что поток может быть остановлен в любой момент и другой поток может выполнять тот же код параллельно, меняя общие данные.

Решение? Использовать атомарные операции


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

Простейший способ использовать атомарную операцию — прибегнуть к блокировке. Следующий код даст одинаковый предсказуемый результат с MRI, JRuby и Rubinius благодаря мьютексу.

array = []
mutex = Mutex.new

5.times.map do
  Thread.new do

    mutex.synchronize do
      1000.times do
        array << nil
      end
    end

  end
end.each(&:join)

puts array.size

Если какой-нибудь поток начинает выполнение блока mutex.synchronize, другие потоки вынуждены ждать его завершения перед тем, как начать выполнение этого же кода. Используя атомарные операции, вы получаете гарантию, что если переключение контекста случится внутри блока, то другие потоки все равно не смогут войти в него и изменить общие данные. Планировщик это заметит и опять переключит поток. Теперь код потокобезопасен.

GIL — тоже блокировка


Мы увидели, как можно использовать блокировку для создания атомарной операции и обеспечения потокобезопасности. GIL — тоже блокировка, но делает ли она код потокобезопасным? Превращает ли GIL array << nil в атомарную операцию?

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

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

Похожие публикации

Комментарии 38
    +3
    Несколько раз в статье забрасывается крючок. Ожидаешь: вот-вот доберусь до сути. И на тебе… На самом интересном. Жду не дождусь продолжения! Спасибо.
    0
    Спасибо! Интересно, как там в других языках всё устроено.
      +1
      Этот механизм во-первых реализован только в MRI, который хоть и основной интерпритатор в мире Руби, но далеко не единственный. Все остальные имплементации, например jRuby или рубиниус лишены недостатков GIL. Во-вторых, реализация GIL может изменится либо исчезнуть совсем в более поздних версиях MRI, поэтому лучше не полагаться на нее вообще, явно используя примитивы синхронизации, иммутабельные типы и т.п.
        0
        например jRuby или рубиниус лишены недостатков GIL

        И достоинств GIL они лишены в той же степени…
          +3
          Мне кажется, что единственное преимущество GIL — упрощение работы для разработчиков интерпритатора. Честно говоря, с точки зрения прикладного программиста на языке, наличие в нем GIL довольно сомнительное преимущество.

          Вот простой пример кода, который сводит на нет все мнимые бенефиты от GIL:

          @foo = true
          
          def one
            p 'Boo!' if @foo
            @foo = false
          end
          
          5.times.map do
            Thread.new do
              one
            end
          end.each(&:join)
          
            –5
            GIL — это упрощение работы для программиста. Каждой задаче свой инструмент. Нет достоинств без недостатков. Вопрос лишь как вы этим пользуетесь. Дождитесь продолжения или прочитайте оригинал статьи.

            Приведенный вами код не рабочий… Я что-то не припомню, чтобы Array#each принимал какие-нибудь параметры…
              +4
                –4
                Да, работает… А что с этим кодом по вашему не так?
                  +3
                  С кодом все замечательно, он демонстрирует то, что GIL не спасает от проблем синхронизации в коде приложения: строка вроде бы как должна напечататься один раз, а печатается несколько.
                    –5
                    По мне так этот код демонстрирует, что не стоит кому попало заниматься многопоточным программированием.
                    Вы, например, можете аргументировано обосновать почему этот код должен выводить одну строку?

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

                    Ребята, держитесь подальше от многопоточного программирования с такой подготовкой. Даже GIL не способен избавить от необходимости программисту иметь мозги…
                      +3
                      Не понимаю вашей аргументации, уж извините. На то, что код рабочий вам указали выше. Я привел вам классический пример рассинхронизации данных между потоками, которому плевать на GIL. Откройте любую книжку по C++ или, к примеру, по Java на разделе «многопоточность» и вы увидите такой же код перед объяснением концепций мьютексов, synchronized секций etc.

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

                      Вы показали некоторую неосведомленность в знании основ языка, при этом не явили публике ваших аргументов в поддержку GIL. Оставив в стороне ваши необоснованные обвинения, не могли бы вы рассказать, какие же преимущества, по вашему, дает этот механизм прикладному программисту?
                        –4
                        Давайте отделим мух от котлет. Код типа вашего приводится в качестве того, как не надо писать многопоточные приложения. Ипользование мютексов при работе с shared memory и семафоров в многопоточном программировании — это идиомы. Вне зависимости от языка.

                        Не понимаю почему вы решили, что GIL просто обязан ваш по всем канонам кривой код превратить в правильный код. Ни вы, ни ваш товарищ alno не можете сказать, как должен работать этот код. И это правильно, спецификация языка этого не определяет.
                        Считать корректными выводы, используя некорректные начальные данные (в данном случае некорректный код) даже с точки зрения формальной логики нонсенс.

                        Метод Symbol#to_proc — это не основы языка, а новая фича языка 1.9. Я избегаю подобные конструкции, поскольку иногда приходится поддерживать код написанный под 1.8.

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

                        Плюс у нее очень узкое применение (работает только с методами, которые принимают один-единственный параметр, если я правильно понимаю).

                        Как говаривает в таких случаях один товарищ в наших краях: грёбанная магия, долбанное волшебство…

                        Про преимущества будет в конце второй части этой статьи. Вам нравится когда вам пересказывают фильм, на который вы только что купили билет?
                          +3
                          Метод Symbol#to_proc — это не основы языка, а новая фича языка 1.9. Я избегаю подобные конструкции, поскольку иногда приходится поддерживать код написанный под 1.8.

                          ruby-doc.org/core-1.8.7/Symbol.html#method-i-to_proc
                            +3
                            Давайте отделим мух от котлет

                            Давайте! Для начала перечитайте внимательно мой первый комментарий в этом треде, с которым вы стали спорить.

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

                            Symbol#to_proc имеет более существенные недостатки, чем ухудшение читабельности, и я согласен с тем, что подобные конструкции не стоит писать в production-ready коде, но это не повод воинственно утверждать, что код, который вы даже не потрудились проверить — нерабочий. Отмечу, что я переработал кусок кода из статьи, который написан в таком же стиле, вы уверены, что внимательно читали ее?

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

                            При чем тут спойлеры, эти знания обязательные для любого человека, называющего себя разработчиком на языке Руби. К слову, я читал все три части еще в оригинале и не нашел ничего нового для себя, а вы, судя по всему не читали, иначе бы не пропустили выводы автора, с которыми я и не спорил.

                            Итак, повторю свой вопрос, какие такие замечательные возможности дает вам GIL, что перекрывает для вас его объективно значительные недостатки?
                              0
                              Это какие же недостатки он (#to_proc) имеет? Всегда использовал его и горя не знал. С точки зрения читабельности, на мой взгляд, &method намного удобнее, чем {|name| name.method} для человека, который знает, как это работает.

                              А если все время подстраиваться под новичков, как предлагает northbear, то может нам вообще стоит остаться в каменном веке? А 1.8 ветка, насколько я помню, уже не поддерживается, да и код там этот поддерживается.
                                0
                                Раньше были проблемы с выделением памяти, насколько я помню (еще в те времена, когда этот метод был рельсовый), и с увеличением времени на вызов методов (в 2-3 раза). Проверил на MRI 2.0.0p247, никаких ликов нет, время на вызов соизмеримое. По поводу вкусов спорить не буду, лично мне версия без &: нравится намного больше.
          +2
          Переводчик будет рад услышать замечания и конструктивную критику.


          Вы проделали большой объем работы, текст читается легко, спасибо! Когда будете переводить 2ю часть, пожалуйста переведите и комментарии, там много полезных исправлений.
            0
            GIL на то и GIL чтобы блокировать, без транзакционной памяти этот вопрос не обойти
              0
              Вообще-то, обойти — см. BEAM. Вкратце — своя куча и свой GC у каждого потока. У этого подхода есть свои минусы, однако, эффект потрясающий.
                0
                Чем это проще и лучше форка с SHM?
                  0
                  Полагаю, STM несколько сложнее в реализации. Черт его знает, на самом деле.
                  Алсо, не смог найти актуальной информации по теме. Этот форк вообще существует?
                    0
                    В PyPy реализация софтверная есть, хардкор пока от Интелов ждут.
                      0
                      Про форк с SHM я имел ввиду process shared memory
                    0
                    Кто такой «BEAM» и где про него надо смотреть?
                      0
                      Erlang VM. Вкратце ознакомиться можно в habrahabr.ru/post/117538/
                        0
                        Извините, но Эрланг изначально спроектирован так, что в нем все операции thread-safe.

                        Возьмём пример из статьи. В Эрланге нет аналога операции array << nil. В Эрланге вы можете создать переменную и присвоить ей значение, но изменить её после этого вы не можете.

                        Без должной подготовки и навыков писать на эрланге очень тяжело. Но параллельность — это да…
                        Руби в плане программирования полная противоположность Эрлангу. Но GIL…

                        Так устроен мир. За всё нужно платить. Как это вообще можно сравнивать, я не понимаю…
                          0
                          Вообще, изначально было постулировано, что только STM спасет мир. BEAM был контрпримером. Про язык речи вообще не шло.

                          Кстати, хоть VM и накладывает свой отпечаток, не все так страшно. В эрланге можно иметь shared mutable state, хоть это и будет выглядеть весьма отлично от других языков. Кстати, где это будет дороже по производительности — тоже под вопросом. В конце концов, никто не запрещает разработать другой язык под BEAM, где будут изменяемые переменные и array
                            0
                            Кем и что «изначально было постулировано»? Мы вроде тут обсуждаем статью про GIL в MRI, конкретной реализации ruby.

                            Архитектура виртуальной машины Elrang'а очень хорошо адаптирована под структуру и концепцию языка и поэтому очень эффективна. Но утверждать, что раз она эффективна для Erlang'а, то значит она будет столь же эффективна для ruby, как минимум странно…

                            В конце концов, никто не запрещает разработать другой язык под BEAM, где будут изменяемые переменные и array

                            Как у вас всё легко. Вас послушать, так и вы и корову запросто летать научите…
                              0
                              GIL на то и GIL чтобы блокировать, без транзакционной памяти этот вопрос не обойти

                              Это изначальный постулат.
                              В конце концов, никто не запрещает разработать другой язык под BEAM, где будут изменяемые переменные и array

                              Такой язык уже есть, elixir.
                                0
                                GIL на то и GIL чтобы блокировать, без транзакционной памяти этот вопрос не обойти

                                Это изначальный постулат.

                                Вы знаете, что такое atoms и зачем нужна atoms tables в BEAM? Что происходит когда два потока пытаются обратиться к одному атому в BEAM? Вы будете сильно удивлены, но один из потоков будет блокирован…

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

                                Такой язык уже есть, elixir.

                                Попробуйте написать на elexir'е код, разбираемый в статье, и сравните результаты. BEAM — это принципиально другая концепция, которая имеет куда более серьезные ограничения чем GIL в MRI… Но именно благодаря этому BEAM в определенных применениях очень силён. Нет достоинств без недостатков…
                                  0
                                  Да тут вроде разговор, что не GIL'ом единым и транзакционной памятью можно решить проблему многопоточности в приложениях. Как пример – BEAM.
                                  А elixir тут как пример альтернативного языка для BEAM.
                                    0
                                    К слову, на Руби есть попытки реализации модели акторов и некоторых других эрланговских подходов: github.com/celluloid/celluloid. Не хочу давать какую-то оценку этому фреймворку, просто отмечаю сам факт его наличия.
                                0
                                >> Как у вас всё легко.
                                Ну так. Люди под х86 компиляторы пишут, и ничего. А тут все же VM.
                    +14
                    A programmer had a problem. He thought to himself, «I know, I'll solve it with threads!». has Now problems. two he
                      –1
                      Была у программиста проблема: найти по шаблону подстроку в строке. Решил использовать для этого регэкс. Теперь у него две проблемы…
                      Так всегда: хочешь побыстрее долететь — потрать чуток лишнего времени на изготовку.
                      +2

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

                      Самое читаемое