Ракторы (Ractors)
Перевод статьи “Ractors”
@brandur от 15 января 2021 года.
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
Входящая очередь имеет неограниченный размер поэтому отправка сообщения никогда не блокируется. Получение блокируется до тех пор, пока не будет доступно сообщение в очереди.
“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
(Как обычно я опустил множество деталей. Смотри взаимодействие между Ракторами для более глубокого понимания.)
Каналы
Реализация модели акторов в 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
Канал раздает данные потребителям через свой исходящий порт и блокируется при этом, а получает сообщения со своего входящего порта: который имеет неограниченный размер. Поставщики данных могут отправлять данных столько, сколько хотят в этот псевдо-канал без блокировок.
Применимость сегодня
Стремясь заставить Ракторы работать, я попытался взять небольшую часть статического генератора, который генерирует тот самый выпуск, который вы читаете, и реализовать его в 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 долго ждал, чтобы представить такие необходимые функции, как типы и параллелизм, что, возможно, уже поздновато. Возможно язык потерял большую часть импульса, который когда-то имел, и вполне вероятно, осталось не так много библиотек, авторы которых готовы потратить силы на совместимость с Ракторами. Время покажет.
Артефакты
Сегодняшние фотографии взяты из похода на Серную гору недалеко от Банфа в Альберте. Поднимаясь мы натолкнулись на, возможно, самую маленькую загадку в мире, нашли древнюю мемориальную доску, встроенную в одно из старых деревьев в этом районе. Совершенно неразборчивую.
Это было в нескольких футах от тропы по другую сторону снежного заноса, так что мы не могли подойти близко. Мы сделали несколько фотографий и разгадывали их в увеличенном масштабе на экране компьютера. Мы начали уже сомневаться в возможности разгадать что-либо, но потом произошел прорыв. Слово ель
обнаружилось в правом верхнем углу. Совместив это с видимыми буквами Eng
в нескольких местах, а также с известным списком елей мы получили «Engelmann Spruce». Мемориальная доска называет дерево, к которому она прикреплена, высокогорная ель, которая в основном встречается в Британской Колумбии, Альберте, Монтане и Айдахо, а также немного растет в других местах США, включая северную Калифорнию. Строка ниже - это его ботаническое название и ботаника, который его опубликовал, «Picea Engelmannii Engelm» (подробнее об этом).
Мемориальная доска выглядит настолько древней, что, возможно, ее установил Норман Сансон, известный тем, что в течение 30 лет неустанно ходил по этой тропе, чтобы измерить погоду на вершине Серы в начале 1900-х годов. Мы придумали эту историю, но надеемся, что это правда.
Я узнал, как распознать ель Энгельмана, и что детективные истории полезны для души, даже самых маленьких среди них.
До скорого.