Pull to refresh

Как работает GIL в Ruby. Часть 3. Делает ли GIL ваш код потоко-безопасным?

Reading time9 min
Views5.1K
Original author: Jesse Storimer


Переводы предыдущих двух частей:
Первая часть
Вторая часть

Это статья Jesse Storimer. Он выступает на семинаре Unix fu, онлайн классе для Ruby-разработчиков, которые хотят научиться удивительным хакам в Ruby и повысить свой уровень в разработке серверного стека. Количество участников ограничено, так что поторопитесь, пока есть свободные места. Так же, он является автором книг «Работа с Unix процессами», «Работа с TCP сокетами» и «Работа с потоками в Ruby».

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

Но вы не должны принимать мои слова на веру.

Эта серия статей началась с попытки понять, что представляет из себя GIL на техническом уровне. Первая часть объясняет, откуда появляются условия для возникновения состояния гонки в коде на C, который используется в реализации MRI. Тем не менее, кажется, что GIL позволил избежать этого, по крайней мере, для метода Array#<< мы это увидели.

Вторая часть подтверждает, что GIL, по сути, делает атомарной реализацию встроенных методов в MRI. Другими словами, это исключает возникновение состояния гонки. Однако, это распространяется только на встроенные функции самого MRI, но не на ваш Ruby-код. Таким образом, мы все-равно остались с вопросом: «Предоставляет ли GIL какие-либо гарантии, что ваш код на Ruby будет потоко-безопасным?».

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

Еще раз о состоянии гонки


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

Давайте сделаем шаг назад и вспомним, как может возникнуть состояние гонки. Мы воспользуемся следующим примером кода на Ruby для этой части статьи:

class Sheep
  def initialize
    @shorn = false
  end

  def shorn?
    @shorn
  end

  def shear!
    puts "shearing..."
    @shorn = true
  end
end


В этом классе нет ничего нового. Овца не острижена при рождении. Метод «shear!» выполняет стрижку и помечает овцу как уже остриженную.



sheep = Sheep.new

5.times.map do
  Thread.new do
    unless sheep.shorn?
      sheep.shear!
    end
  end
end.each(&:join)


Данный код создает новый объект овцы и порождает 5 потоков. Каждый из них проверяет острижена ли овца, и если нет, то вызывает метод «shear!».

Вот такой результат я получаю, запустив этот код несколько раз на MRI 2.0:

$ ruby check_then_set.rb
shearing...
$ ruby check_then_set.rb
shearing...
shearing...
$ ruby check_then_set.rb
shearing...
shearing...


Иногда одну овцу стригут дважды!

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

Эти неожиданные результаты получились в результате возникновения состояния гонки в вашем Ruby-коде. На самом деле, это достаточно распространенный шаблон ошибки проектирования, у которого есть даже собственное название: «check-then-set race condition». В этом случае два или более потоков проверяют некоторое значение, а затем устанавливают другие значения, основываясь на первом. Не имея ничего, чтобы обеспечить атомарность, вполне возможно, что два потока проходят фазу «проверки значения», а затем оба выполняют фазу «установки новых значения».

Распознавание состояния гонки


Прежде чем мы рассмотрим, как это исправить, я хочу, чтобы вы поняли, как распознать это. Я обязан @brixen за объяснение терминологии чередования в контексте параллелизма. Это действительно полезно.

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

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

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


Просто чтобы схемы были проще, я заменил вызов метода «shear!» ее кодом.

Рассмотрим эту схему. Красным выделены чередующиеся блоки потока A, синим — блока B.

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



Теперь я организовал схему так, чтобы вы видели последовательный порядок событий. Помните, что GIL останавливает все вокруг исполняемого кода, так что два потока не могут по-настоящему работать параллельно. События в этом случае идут последовательно, сверху-вниз.

В этом чередовании поток A сделал всю свою работу, а затем планировщик переключает контекст в поток B. Так как поток A уже успешно остриг овцу и обновил переменную состояния, поток B ничего с ней не делает.

Но не всегда все так просто. Помните, что планировщик может переключить контекст в любой момент. В этот раз нам повезло.

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



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

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

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

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



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

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

Это ужасно!


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

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

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

require 'thread'

class Sheep
  # ...
end

sheep = Sheep.new
sheep_queue = Queue.new
sheep_queue << sheep

5.times.map do
  Thread.new do
    begin
      sheep = sheep_queue.pop(true)

      sheep.shear!
    rescue ThreadError
      # raised by Queue#pop in the threads
      # that don't pop the sheep
    end
  end
end.each(&:join)


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

Если вы запустите этот код на MRI, или на любой другой действительно параллельной реализации Ruby, он каждый раз будет выдавать ожидаемый результат. Мы ликвидировали состояние гонки в этом коде. Даже с учетом того, что все потоки будут вызывать Queue#pop в более или менее одно время, в этом коде используется внутренний мьютекс для того, чтобы только один поток одномоментно смог получить овцу.

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

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

Отложенная инициализация


Я просто быстро отмечу, что ленивая инициализация является другой формой «check-then-set race condition». Оператор ||= разворачивается в:

@logger ||= Logger.new

# Разворачивается в

if @logger == nil
  @logger = Logger.new
end

@logger


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

Размышления


В конце, я хочу, чтобы вы извлекли для себя какой-то урок.

4 из 5 программистов согласятся, что в многопоточном программировании довольно сложно сделать все правильно.

В конце концов, все что гарантирует вам GIL, это только то, что реализации методов, встроенные в MRI, будут выполняться атомарно (но и тут есть свои подводные камни). Такое поведение иногда может помочь нам, но GIL в действительности создан для внутренней защиты самого MRI, а не как надежное API для Ruby-разработчиков.

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

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

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

«Если вы добавили новый поток в вашу программу, то вы, вероятно, добавили 5 новых ошибок». Mike Perham


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

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

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

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

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

«Не работайте совместно, деля состояние, делите состояние совместно работая»


Использование структуры данных для синхронизации поддерживает это. Модель акторов поддерживает эту идею. Она лежит в основе параллелизма в таких языках как Go, Erlang и других.

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

Благодарю Brian Shirai за анализ черновика этой статьи.
Tags:
Hubs:
+11
Comments2

Articles

Change theme settings