Введение в ракторы в Ruby

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

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

Что такое акторная модель?

В компьютерных науках объектно-ориентированная модель очень популярна, а в сообществе Ruby многие привыкли к термину «все есть объект».

Аналогично, позвольте мне познакомить вас с акторной моделью, в рамках которой «все есть актор». Модель акторов — это математическая модель параллельных вычислений, в которой универсальным примитивным/фундаментальным агентом вычислений является актор. Актор способен на следующее:

  • получать сообщения и отвечать отправителю
  • отправлять сообщения другим агентам
  • определять, как ответить на следующее полученное сообщение
  • Создавать несколько других агентов
  • принимать локальные решения
  • Выполнение действий (например, изменение данных в базе данных).

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

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

Несколько примеров реализации модели акторов: akka, elixir, pulsar, celluloid и ractors. Несколько примеров моделей параллелизма включают потоки, процессы и фьючерсы.

Что такое Ractor в Ruby?

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

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

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

В 2020 году, когда был выпущен Ruby 3.0.0, это были слова Матца:

Сегодня наступила эра многоядерности. Параллелизм очень важен. С Ractor, наряду с Async Fiber, Ruby станет настоящим параллельным языком.

Ractor не претендует на решение всех проблем потокобезопасности. В документации по Ractor ясно сказано следующее:

Существует несколько блокирующих операций (waiting send, waiting yield и waiting take), поэтому вы можете создать программу, в которой есть проблемы dead-lock и live-lock.

Некоторые виды разделяемых объектов могут вводить транзакции (например, STM). Однако неправильное использование транзакций приведет к возникновению непоследовательного состояния.

Без тракторов вам придется отслеживать все мутации состояния, чтобы отладить проблемы безопасности потоков. Однако прелесть тракторов в том, что мы можем сконцентрировать наши усилия на подозрительном общем коде.

Когда и почему я должен использовать ракторы в Ruby?

Когда вы создаете рактор в первый раз, вы получите предупреждение, подобное этому:

<internal:ractor>:267: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
Войдите в полноэкранный режим Выйти из полноэкранного режима

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

В примечаниях к выпуску Ruby 3.0.0 вы найдете этот эталонный пример функции Tak, где она выполняется последовательно четыре раза и четыре раза параллельно с помощью ractors:

def tarai(x, y, z) =
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))
require 'benchmark'
Benchmark.bm do |x|
  # sequential version
  x.report('seq'){ 4.times{ tarai(14, 7, 0) } }

  # parallel version with ractors
  x.report('par'){
    4.times.map do
      Ractor.new { tarai(14, 7, 0) }
    end.each(&:take)
  }
end
Вход в полноэкранный режим Выход из полноэкранного режима

Результаты следующие:

Benchmark result:
          user     system      total        real
seq  64.560736   0.001101  64.561837 ( 64.562194)
par  66.422010   0.015999  66.438009 ( 16.685797)
Вход в полноэкранный режим Выход из полноэкранного режима

В примечаниях к выпуску Ruby 3.0.0 говорится:

Результат был измерен на Ubuntu 20.04, Intel(R) Core(TM) i7-6700 (4 ядра, 8 аппаратных потоков). Он показывает, что параллельная версия в 3,87 раза быстрее, чем последовательная.

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

Модификация объектов классов/модулей в мультиракторных программах может привести к возникновению условий гонки, и ее следует избегать, насколько это возможно. Однако большинство объектов не подлежат совместному использованию, поэтому необходимость в реализации блокировок для предотвращения условий гонки отпадает. Если объекты доступны для совместного использования, они защищаются интерпретатором или механизмом блокировки.

Создание вашего первого рактора в Ruby

Создать ректор так же просто, как и любой другой экземпляр класса. Вызовите Ractor.new с помощью блока — Ractor.new { block }. Этот блок выполняется параллельно с каждым другим ректором.

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

r = Ractor.new { puts "This is my first ractor" }
# This is my first ractor

# create a ractor with a name
r = Ractor.new name: 'second_ractor' do
  puts "This is my second ractor"
end
# This is my second ractor

r.name
# => "second_ractor"
Вход в полноэкранный режим Выход из полноэкранного режима

Аргументы также могут быть переданы в Ractor.new, и эти аргументы становятся параметрами для блока ractor.

my_array = [4,5,6]
Ractor.new my_array do |arr|
  puts arr.each(&:to_s)
end
# 4
# 5
# 6
Вход в полноэкранный режим Выход из полноэкранного режима

Помните, мы говорили о том, что ракторы не могут получить доступ к объектам, определенным вне их области видимости? Давайте посмотрим на пример этого:

outer_scope_object = "I am an outer scope object"
Ractor.new do
  puts outer_scope_object
end
# <internal:ractor>:267:in `new': can not isolate a Proc because it accesses outer variables (outer_scope_object). (ArgumentError)
Вход в полноэкранный режим Выход из полноэкранного режима

Мы получаем ошибку при вызове .new, связанную с тем, что Proc не изолирован. Это происходит потому, что Proc#isolate вызывается при создании трактора, чтобы предотвратить совместное использование не подлежащих совместному использованию объектов. Однако объекты можно передавать тракторам и получать от них сообщения.

Отправка и получение сообщений в Ractors

Ракторы отправляют сообщения через исходящий порт и принимают сообщения через входящий порт. Входящий порт может хранить бесконечное количество сообщений и работает по принципу FIFO.

Метод .send работает так же, как почтальон доставляет сообщение по почте. Почтальон забирает сообщение и бросает его в дверь (входящий порт) трактора.

Однако, чтобы заставить человека открыть дверь, недостаточно просто бросить сообщение в дверь. .receive тогда доступен для трактора, чтобы открыть дверь и получить сообщение, которое было сброшено.

Возможно, трактор захочет выполнить некоторые вычисления с этим сообщением и вернуть ответ, так как же нам его получить? Мы попросим почтальона .take ответ.

tripple_number_ractor = Ractor.new do
  puts "I will receive a message soon"
  msg = Ractor.receive
  puts "I will return a tripple of what I receive"
  msg * 3
end
# I will receive a message soon
tripple_number_ractor.send(15) # mailman takes message to the door
# I will return a tripple of what I receive
tripple_number_ractor.take # mailman takes the response
# => 45
Вход в полноэкранный режим Выйти из полноэкранного режима

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

Вот простой пример:

r = Ractor.new do
  5**2
end
r.take # => 25
Войти в полноэкранный режим Выйти из полноэкранного режима

Помимо возврата сообщения, ractor может также отправить сообщение на свой исходящий порт через .yield.

r = Ractor.new do
  squared = 5**2
  Ractor.yield squared*2
  puts "I just sent a message out"
  squared*3
end
r.take
# => 50
r.take
# => 75
Вход в полноэкранный режим Выход из полноэкранного режима

Первое сообщение, отправленное в исходящий порт, будет squared*2, а следующее — squared*3. Поэтому, когда мы вызываем .take, мы сначала получаем 50. Чтобы получить 75, нам придется вызвать .take во второй раз, так как на исходящий порт отправляется два сообщения.

Давайте рассмотрим все это на примере клиентов, отправляющих свои заказы в супермаркет и получающих выполненные заказы:

supermarket = Ractor.new do
  loop do
    order = Ractor.receive
    puts "The supermarket is preparing #{order}"
    Ractor.yield "This is #{order}"
  end
end

customers = 5.times.map{ |i|
  Ractor.new supermarket, i do |supermarket, i|
    supermarket.send("a pack of sugar for customer #{i}")
    fulfilled_order = supermarket.take
    puts "#{fulfilled_order} received by customer #{i}"
  end
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вывод выглядит следующим образом:

The supermarket is preparing a pack of sugar for customer 3
The supermarket is preparing a pack of sugar for customer 2
This is a pack of sugar for customer 3 received by customer 3
The supermarket is preparing a pack of sugar for customer 1
This is a pack of sugar for customer 2 received by customer 2
The supermarket is preparing a pack of sugar for customer 0
This is a pack of sugar for customer 1 received by customer 1
This is a pack of sugar for customer 0 received by customer 0
The supermarket is preparing a pack of sugar for customer 4
This is a pack of sugar for customer 4 received by customer 4
Вход в полноэкранный режим Выйти из полноэкранного режима

Запуск во второй раз дает результат:

The supermarket is preparing a pack of sugar for customer 0
This is a pack of sugar for customer 0 received by customer 0
The supermarket is preparing a pack of sugar for customer 4
This is a pack of sugar for customer 4 received by customer 4
The supermarket is preparing a pack of sugar for customer 1
This is a pack of sugar for customer 1 received by customer 1
The supermarket is preparing a pack of sugar for customer 3
The supermarket is preparing a pack of sugar for customer 2
This is a pack of sugar for customer 3 received by customer 3
This is a pack of sugar for customer 2 received by customer 2
Войти в полноэкранный режим Выход из полноэкранного режима

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

Несколько замечаний по поводу отправки и получения сообщений:

  • Сообщения также можно отправлять, используя << msg, вместо .send(msg).
  • Вы можете добавить условие к .receive, используя receive_if.
  • Когда .send вызывается на тракторе, который уже завершен (не работает), вы получаете Ractor::ClosedError.
  • Исходящий порт трактора закрывается после вызова .take, если он запущен только один раз (не в цикле).
r = Ractor.new do
  Ractor.receive
end
# => #<Ractor:#61 (irb):120 running>
r << 5
# => #<Ractor:#61 (irb):120 terminated>
r.take
# => 5
r << 9
# <internal:ractor>:583:in `send': The incoming-port is already closed (Ractor::ClosedError)
r.take
# <internal:ractor>:694:in `take': The outgoing-port is already closed (Ractor::ClosedError)
Вход в полноэкранный режим Выход из полноэкранного режима
  • Объекты могут быть перемещены в конечный рактор с помощью .send(obj, move: true) или .yield(obj, move: true). Эти объекты становятся недоступными в предыдущем месте назначения, вызывая ошибку Ractor::MovedError при попытке вызвать любые другие методы для перемещенных объектов.
r = Ractor.new do
  Ractor.receive
end
outer_object = "outer"
r.send(outer_object, move: true)
# => #<Ractor:#3 (irb):7 terminated>
outer_object + "moved"
# `method_missing': can not send any methods to a moved object (Ractor::MovedError)
Вход в полноэкранный режим Выход из полноэкранного режима
  • Нити не могут быть отправлены в виде сообщений с помощью .send и .yield. Это приводит к ошибке TypeError.
r = Ractor.new do
  Ractor.yield(Thread.new{})
end
# <internal:ractor>:627:in `yield': allocator undefined for Thread (TypeError)
Вход в полноэкранный режим Выход из полноэкранного режима

Объекты с возможностью совместного использования и объекты без возможности совместного использования

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

Вы можете проверить разделяемость объекта через Ractor.shareable? и сделать объект разделяемым через Ractor.make_shareable.

Ractor.shareable?(5)
# => true
Ractor.shareable?(true)
# => true
Ractor.shareable?([4])
# => false
Ractor.shareable?('string')
# => false
Вход в полноэкранный режим Выход из полноэкранного режима

Как было показано выше, неизменяемые объекты являются разделяемыми, а изменяемые — нет. В Ruby мы обычно вызываем метод .freeze для строки, чтобы сделать ее неизменяемой. Это тот же метод, который используют рерайтеры, чтобы сделать объект доступным для совместного использования.

str = 'string'
Ractor.shareable?(str)
# => false
Ractor.shareable?(str.freeze)
# => true
arr = [4]
arr.frozen?
# => false
Ractor.make_shareable(arr)
# => [4]
arr.frozen?
# => true
Вход в полноэкранный режим Выход из полноэкранного режима

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

SHAREABLE = 'share'.freeze
# => "share"
SHAREABLE.object_id
# => 350840
r = Ractor.new do
  loop do
    msg = Ractor.receive
    puts msg.object_id
  end
end
r.send(SHAREABLE)
# 350840
NON_SHAREABLE = 'can not share me'
NON_SHAREABLE.object_id
# => 572460
r.send(NON_SHAREABLE)
# 610420
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

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

  • Объекты ректоров также являются объектами, доступными для совместного использования.
  • Константы, которые могут быть использованы совместно, но определены вне области видимости рактора, могут быть доступны в ракторе. Помните наш пример outer_scope_object? Попробуйте еще раз, определив его как OUTER_SCOPE_OBJECT = "I am an outer scope object".freeze.
  • Объекты классов и модулей являются разделяемыми, но переменные экземпляра или константы, определенные в них, не являются таковыми, если им присваиваются не разделяемые значения.
class C
  CONST = 5
  @share_me = 'share me'.freeze
  @keep_me = 'unaccessible'
  def bark
   'barked'
  end
end

Ractor.new C do |c|
  puts c::CONST
  puts c.new.bark
  puts c.instance_variable_get(:@share_me)
  puts c.instance_variable_get(:@keep_me)
end
# 5
# barked
# share me
# (irb):161:in `instance_variable_get': can not get unshareable values from instance variables of classes/modules from non-main Ractors (Ractor::IsolationError)
Вход в полноэкранный режим Выход из полноэкранного режима
  • Входящий порт или исходящий порт можно закрыть с помощью Ractor#close_incoming и Ractor#close_outgoing, соответственно.

Подведение итогов и дальнейшее чтение по Ractors

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

Реакторы идут глубже. На ракторах можно вызывать множество других публичных методов, например select для ожидания успеха take, yield и receive, count, current и т.д.

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

Ractors действительно являются экспериментальными, но они определенно выглядят так, как будто у них большое будущее в эволюции Ruby.

Счастливого кодинга!

P.S. Если вы хотите читать посты Ruby Magic, как только они выходят из печати, подпишитесь на нашу рассылку Ruby Magic и не пропустите ни одного поста!

Оцените статью
devanswers.ru
Добавить комментарий