Главная
Блог разработчиков phpBB
 
+ 17 предустановленных модов
+ SEO-оптимизация форума
+ авторизация через соц. сети
+ защита от спама

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

Anna | 20.06.2014 | нет комментариев
Пять из четырех разработчиков признают, что многопоточное программирование осознать сложно.

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

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

Однако, я забегаю вперед.

Если вы вовсе ничего не знаете о GIL, вот изложение в 2-х словах:

В 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

В этом примере всякий из пяти потоков 1000 раз добавляет 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 все равно дает правильный итог?

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

Источник: programmingmaster.ru

Оставить комментарий
Форум phpBB, русская поддержка форума phpBB
Рейтинг@Mail.ru 2008 - 2017 © BB3x.ru - русская поддержка форума phpBB