Статьи

Темы в рубине

Потоки

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

Вступление

Прежде всего давайте определим «нить». Согласно Википедии

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

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

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

Основной пример

Рассмотрим следующий код

def calculate_sum(arr) sum = 0 arr.each do |item| sum += item end sum end @items1 = [12, 34, 55] @items2 = [45, 90, 2] @items3 = [99, 22, 31] puts "items1 = #{calculate_sum(@items1)}" puts "items2 = #{calculate_sum(@items2)}" puts "items3 = #{calculate_sum(@items3)}" 

Результатом вышеупомянутой программы будет

 items1 = 101 items2 = 137 items3 = 152 

Это очень простая программа, которая поможет понять, почему мы должны использовать потоки. В приведенном выше листинге кода мы имеем 3 массива и вычисляем их сумму. Все это довольно просто. Однако есть проблема. Мы не можем получить сумму массива items2 пока не items1 результат items1 . Это то же самое, что и для items3 . Давайте немного изменим код, чтобы показать, что я имею в виду.

 def calculate_sum(arr) sleep(2) sum = 0 arr.each do |item| sum += item end sum end 

В приведенном выше листинге кода мы добавили инструкцию sleep(2) которая приостановит выполнение на 2 секунды, а затем продолжит. Это означает, что items1 получит сумму через 2 секунды, items2 через 4 секунды (2 для items1 + 2 секунды для items2 ) и items3 получит сумму через 6 секунд. Это не то, что мы хотим.

Наши массивы предметов не зависят друг от друга, поэтому было бы идеально рассчитать их суммы независимо. Это где темы пригодятся.

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

 def calculate_sum(arr) sleep(2) sum = 0 arr.each do |item| sum += item end sum end @items1 = [12, 34, 55] @items2 = [45, 90, 2] @items3 = [99, 22, 31] threads = (1..3).map do |i| Thread.new(i) do |i| items = instance_variable_get("@items#{i}") puts "items#{i} = #{calculate_sum(items)}" end end threads.each {|t| t.join} 

Метод calculate_sum такой же, как в нашем предыдущем примере кода, где мы добавили sleep(2) . Наши массивы предметов такие же. Наиболее важным изменением является способ, которым мы вызывали calculate_sum для каждого массива. Мы обернули вызов Thread.new соответствующий каждому массиву в блоке Thread.new . Это как создавать темы в Ruby.

Мы сделали немного метапрограммирования, чтобы получить каждый массив элементов в соответствии с индексом i в цикле. В конце программы мы просим потоки обработать блоки, которые мы им дали.

Если вы запустите приведенный выше пример кода, вы можете увидеть следующий вывод (я использую его, потому что ваш вывод может отличаться с точки зрения последовательности элементов массивов)

 items2 = 137 items3 = 152 items1 = 101 

Вместо получения ответа для массива items2 через 4 секунды и массива items3 через 6 секунд мы получили сумму всех массивов через 2 секунды. Это здорово и показывает нам силу потоков. Вместо того, чтобы вычислять сумму одного массива за раз, мы вычисляем сумму всех массивов одновременно или одновременно. Это здорово, потому что мы сэкономили 4 секунды, что, безусловно, свидетельствует о лучшей производительности и эффективности.

Условия гонки

Каждая функция поставляется с ценой. Потоки хороши, но если вы пишете многопоточный код приложения, вы должны знать об обработке условий гонки. Что такое состояние гонки? Согласно Википедии

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

Проще говоря, если у нас есть некоторые общие данные, к которым могут обращаться несколько потоков, тогда наши данные должны быть в порядке (то есть не повреждены) после того, как все потоки завершат выполнение.

пример

 class Item class << self; attr_accessor :price end @price = 0 end (1..10).each { Item.price += 10 } puts "Item.price = #{Item.price}" 

Мы создали простой класс Item с переменной класса price . Item.price увеличивается в цикле. Запустите эту программу, и вы увидите следующий вывод

 Item.price = 100 

Теперь давайте посмотрим многопоточную версию этого кода

 class Item class << self; attr_accessor :price end @price = 0 end threads = (1..10).map do |i| Thread.new(i) do |i| item_price = Item.price # Reading value sleep(rand(0..2)) item_price += 10 # Updating value sleep(rand(0..2)) Item.price = item_price # Writing value end end threads.each {|t| t.join} puts "Item.price = #{Item.price}" 

Наш класс Item такой же. Тем не менее, мы изменили способ увеличения price . Мы сознательно использовали sleep в приведенном выше коде, чтобы показать вам возможные проблемы, которые могут возникнуть из-за параллелизма. Запустите эту программу несколько раз, и вы увидите две вещи.

 Item.price = 40 

Сначала вывод неверный и противоречивый. Выход больше не равен 100, и иногда вы можете увидеть 30, 40 или 70 и т. Д. Это то, что делает условие гонки . Наши данные больше не верны и повреждаются при каждом запуске нашей программы.

Взаимное исключение

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

Ruby обеспечивает очень аккуратный и элегантный способ взаимного исключения. Заметим:

 class Item class << self; attr_accessor :price end @price = 0 end mutex = Mutex.new threads = (1..10).map do |i| Thread.new(i) do |i| mutex.synchronize do item_price = Item.price # Reading value sleep(rand(0..2)) item_price += 10 # Updating value sleep(rand(0..2)) Item.price = item_price # Writing value end end end threads.each {|t| t.join} puts "Item.price = #{Item.price}" 

Теперь запустите эту программу, и вы получите следующий вывод

 Item.price = 100 

Это из-за mutex.synchronize . Один и только один поток может в любой момент получить доступ к блоку, заключенному в mutex.synchronize . Другие потоки должны ждать до завершения текущего потока, который обрабатывает.

Мы сделали наш код безопасным.

Rails является потокобезопасным и использует экземпляр класса Mutex, чтобы избежать условий гонки, когда несколько потоков пытаются получить доступ к одному и тому же коду. Посмотрите на следующий код из промежуточного программного обеспечения Rack::Lock . Вы увидите, что @mutex.lock используется для блокировки других потоков, которые пытаются получить доступ к тому же коду. Для более подробной информации о многопоточности в Rails прочитайте мою статью . Кроме того, вы можете посетить страницу класса Ruby Mutex для ознакомления с классом Mutex .

Типы потоков в разных версиях Ruby

В Ruby 1.8 были «зеленые» темы. Зеленые нити были реализованы и контролируются переводчиком. Вот некоторые плюсы и минусы зеленых нитей:

Pros

  • Кроссплатформенность (под управлением ВМ)
  • Унифицированное поведение / контроль
  • Легкий -> быстрее, меньший объем памяти

Cons

  • Не оптимизирован
  • Ограничено 1 ЦП
  • Блокирующий ввод / вывод блокирует все потоки

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

Pros

  • Работать на нескольких процессорах
  • Запланировано ОС
  • Блокирующие операции ввода / вывода не блокируют другие потоки.

Несмотря на то, что в Ruby 1.9 имеются собственные потоки, в любой момент времени будет выполняться только один поток, даже если у нас в процессоре несколько ядер. Это происходит из-за GIL (Global Interpreter Lock) или GVL (Global VM Lock), который MRI Ruby (JRuby и Rubinius не имеет GIL, и, как таковой, имеет «настоящие» потоки). Это предотвращает выполнение других потоков, если Ruby уже выполняет один поток. Но Ruby достаточно умен, чтобы переключать управление на другие ожидающие потоки, если один поток ожидает завершения операции ввода-вывода.

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