Ракторы (Ractors)

Перевод статьи “Ractors”
@brandur от 15 января 2021 года.

взято из оригинальной статьи https://brandur.org/nanoglyphs/018-ractors

25 декабря 2020 год, в Рождество вышел релиз Ruby 3. Дата может показаться необычной, но это традиция ежегодных релизов Ruby в виде высокотехнологичного рождественского подарка миру. Команда выпускает релиз ежегодно 25 декабря с момента выхода Ruby 2 в 2013 году и это делает текущий релиз уже восьмым подряд.

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

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


Краткий антракт: добро пожаловать в Nanoglyph, еженедельник о параллельных вычислениях и хвойных деревьях. Если вы читаете это через браузер - можете подписаться здесь.


GVL бывший GIL

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

Давайте посмотрим на простой код Ruby для вычисления Фибоначчи:

def fib(n)
  new, old = 1, 0
  n.times { new, old = new + old, new }
  old
end

3000.times.each do |i|
  fib(i)
end

И теперь тоже самое, но с разделением нагрузки на два потока:

t1 = Thread.new do
  3000.times.each do |i|
    fib(i) if i % 2 == 0
  end
end

t2 = Thread.new do
  3000.times.each do |i|
    fib(i) if i % 2 == 1
  end
end

t1.join
t2.join

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

Однопоточная реализация (программа #1):

$ time ruby main.rb
real    1.00s
user    0.94s
sys     0.04s

Многопоточная (программа #2):

$ time ruby main.rb
real    1.06s
user    1.00s
sys     0.04s

Многопоточность не только не быстрее, но даже медленнее, чем однопоточная версия в этом примере. Мы могли бы даже использовать 4, 8 или 16 потоков, и ни один вариант не справился бы быстрее. Что происходит?

В этом разделе будет использоваться два понятия параллельности, свойственные англоязычной литературе, поскольку речь пойдёт о сравнении их друг с другом. Термин Concurrency будет переводиться «конкурентность», а термин Parallelism будет переводиться «параллелизм».

Ответ: несмотря на наличие обычных конструкций, таких как потоки, Ruby может быть конкурентным языком, но не параллельным. Глобальная блокировка интерпретатора (GIL) гарантирует, что в любой момент времени Ruby работает только в одном месте. Приведенный выше случай работает плохо, потому что, несмотря на потоки, каждая операция выполняется последовательно.

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

Я использую архаичную терминологию. Сейчас GIL называется GVL (Global VM Lock), потому что он больше не окружает весь интерпретатор, а только выполнение байт-кода в виртуальной машине Ruby. GVL был улучшением, но это было немного похоже на ребрендинг StatOil на Equinor - немного другое, даже если в основном то же самое. И это хорошая возможность отбросить старый багаж, выбрав более современное имя. Но в Ruby по-прежнему нет параллелизма на уровне языка.


Наконец параллельность

И вот тут на сцену выходят Ракторы. Впервые они позволяют выполнять код Ruby (MRI) действительно параллельно.

Рактор это не столько поток, сколько параллельная среда. Каждый Рактор получает свой собственный GVL, а это означает, что узкое место теперь находится на уровне Рактора, а не во всей исполняемой среде Ruby. Каждый Рактор имеет по крайней мере один поток, но, как и любой обычный процесс Ruby, может запускать новые с помощью Thread.new. Каждый поток внутри Рактора связан с традиционными ограничениями GVL(RVL?), но является более точечным в сравнении с глобальными блокировками потоков.

Для облегчения изоляции, Ракторам разрешено наследовать только состояние, которое глобально безопасно. Эта безопасность определяется неизменяемостью, поэтому можно использовать целые числа, замороженные строки или замороженный массив с каждым замороженным элементом. Незамороженная строка или объект с изменяемыми полями в Ракторе будут недоступны. API Ракторов предоставляет метод .shareable?, помогающий определить разницу:

Ractor.shareable?(1)            #=> true
Ractor.shareable?('foo')        #=> false (unless `freeze_string_literals: true` is on)
Ractor.shareable?('foo'.freeze) #=> true

Обмен сообщениями

Подобно Erlang, Ракторы - точная реализация модели акторов. У них есть входящий и исходящий порты, каждый из которых использует отдельный способ передачи сообщений.

Push” - передача с помощью receive/send отправляет сообщение неблокирующим способом во входной порт Рактора:

receiver = Ractor.new do
  while message = Ractor.receive
    puts message
  end
end

loop do
  receiver.send 'ping'
  sleep(1)
end
взято из оригинальной статьи https://brandur.org/nanoglyphs/018-ractors

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


Pull” - передача с использованием take/yield. Рактор посылает (yield) сообщение в свой исходящий порт, а получающий процесс забирает его по готовности. В отличие от push-передачи оба конца блокируются при данном подходе.

Этот пример аналогичен приведенному выше, но с изменёнными ролями - Рактор шлёт сообщения основному приложению:

sender = Ractor.new do
  loop do
    Ractor.yield 'ping'
    sleep(1)
  end
end

while message = sender.take
  puts message
end
взято из оригинальной статьи https://brandur.org/nanoglyphs/018-ractors

(Как обычно я опустил множество деталей. Смотри взаимодействие между Ракторами для более глубокого понимания.)

Каналы

Реализация модели акторов в Ractor даже более чистая, чем в языке вроде Go, с явным намерением осуществить всё взаимодействие через передачу сообщений. Также есть несколько других примитивов одновременности.

Например в Ruby нет встроенной реализации каналов, но их можно создать с помощью Рактора, совмещающего pull и push взаимодействие:

 channel = Ractor.new do
  loop do
    Ractor.yield Ractor.receive
  end
end

# разделение канала между несколькими работающими Ракторами
5.times do |i|
  Ractor.new(channel, name: "ractor-#{i}") do |channel|
    while message = channel.take
      puts "#{name}: message"
    end
  end
end

loop do
  channel.send 'ping'
  sleep(1)
end
взято из оригинальной статьи https://brandur.org/nanoglyphs/018-ractors

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

Применимость сегодня

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

Но далеко я не ушел. Пул был реализован без проблем, но я на собственном опыте обнаружил, что внутри него практически ничего не работает. Например:

class RenderFragmentJob
  def initialize(source)
    @source = source
  end
  
  def name
    "fragment: #{File.basename(@source)}"
  end
  
  def work
    data = File.read(@source)
    _, frontmatter_data, markdown_data = data.split("+++")
    meta = TOML::Parser.new(frontmatter_data).parsed
    content = Kramdown::Document.new(markdown_data).to_html
    ...
  end
end

Предполагается, что он прочитает исходник блога, проанализирует его TOML-заголовок (frontmatter), а затем отрендерит. Но ничего из этого не работает.

И TOML парсер и Markdown рендерер зависят от библиотеки, называемой parslet, для синтаксического анализа. Parslet, в свою очередь, поддерживает внутренний кеш в процессе обработки файла. Этот кеш находится в “разделяемом” состоянии и запрещён в Ракторах - и задача генерирует исключение в момент обращения к кешу. С остальными библиотеками аналогично. Redcarpet - это средство рендеринга Markdown на основе C, которое я пробовал в качестве альтернативы. Он тоже потерпел неудачу - его вызовы на C тоже не разрешены внутри Рактора.

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

Корень проблемы

Кир Шатров рассказывает что Ракторы вошли в экосистему, «снизу», а не «сверху»:

  • «Верх» программы охватывает львиную долю ее кода и функциональности. Например Puma или Unicorn, где поток или процесс обертывают весь стек исполнения, включая HTTP эндпоинты, операции с БД, внутренние команды и утилиты. В идеальном будущем потоки и процессы, которые мы используем сегодня, могли бы стать вместо этого Ракторами, разделяя больше ресурсов и получая при этом выгоду от реального параллелизма.
  • «Низ» программы находится на ее окраинах, где требуется обернуть относительно небольшую часть ее стека. Разработчики могут распараллелить нагрузки, но только те, которые еще не зависят от большого количества существующего кода или библиотек, иначе это не сработает в Ракторах.

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

Насколько это будет успешным - вопрос открытый. Даже более популярным языкам, таким как Python, потребовалось много лет, чтобы перестроить свои экосистемы с учетом серьезных изменений, таких как переход от Python 2 к 3 или от синхронного кода к asyncio. Ruby долго ждал, чтобы представить такие необходимые функции, как типы и параллелизм, что, возможно, уже поздновато. Возможно язык потерял большую часть импульса, который когда-то имел, и вполне вероятно, осталось не так много библиотек, авторы которых готовы потратить силы на совместимость с Ракторами. Время покажет.

взято из оригинальной статьи https://brandur.org/nanoglyphs/018-ractors

Артефакты

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

Это было в нескольких футах от тропы по другую сторону снежного заноса, так что мы не могли подойти близко. Мы сделали несколько фотографий и разгадывали их в увеличенном масштабе на экране компьютера. Мы начали уже сомневаться в возможности разгадать что-либо, но потом произошел прорыв. Слово ель обнаружилось в правом верхнем углу. Совместив это с видимыми буквами Eng в нескольких местах, а также с известным списком елей мы получили «Engelmann Spruce». Мемориальная доска называет дерево, к которому она прикреплена, высокогорная ель, которая в основном встречается в Британской Колумбии, Альберте, Монтане и Айдахо, а также немного растет в других местах США, включая северную Калифорнию. Строка ниже - это его ботаническое название и ботаника, который его опубликовал, «Picea Engelmannii Engelm» (подробнее об этом).

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

До скорого.

взято из оригинальной статьи https://brandur.org/nanoglyphs/018-ractors

Оригинал: Ractors от 15 января 2021 года.
Автор: @brandur