Ractor
Ractor — это модель конкурентного программирования и соответствующий механизм, реализованный в языке Ruby (начиная с версии 3.0), предназначенный для параллельного выполнения кода без риска состояния гонки (race condition) за счёт изоляции объектов и обмена данными через каналы. Ractor является альтернативой традиционным потокам (Thread) в Ruby, которые, из-за глобальной блокировки интерпретатора (GIL), не позволяли достичь истинного параллелизма на уровне процессора (CPU-bound задач). В отличие от потоков, Ractor обеспечивает параллелизм, изолируя свои объекты и позволяя запускать несколько экземпляров на разных ядрах процессора одновременно.
История и предпосылки
До появления Ractor в Ruby (до версии 3.0) параллельное выполнение кода было ограничено глобальной блокировкой интерпретатора (GIL). GIL гарантирует, что в любой момент времени только один поток может выполнять байт-код Ruby. Это делало многопоточность в Ruby безопасной для работы с общими данными, но неэффективной для задач, требующих интенсивных вычислений на нескольких ядрах. Для ввода-вывода (I/O-bound) задач потоки работали хорошо, так как GIL отпускался при блокировках ввода-вывода, но для CPU-bound задач они не давали прироста производительности.
Разработчики Ruby (в первую очередь Koichi Sasada) начали работу над моделью, которая позволила бы обойти GIL, сохранив при этом простоту и безопасность языка. Результатом стал Ractor — акторная модель, вдохновлённая языками Erlang и Elixir, а также концепцией изолированных процессов. Ractor был представлен в Ruby 3.0 (2020 год) как экспериментальная функция, а в Ruby 3.1 и 3.2 получил значительные улучшения и стабилизацию.
Принцип работы
Ractor основан на модели акторов: каждый Ractor — это независимый исполнитель, который имеет собственный стек вызовов, кучу объектов и собственный поток выполнения. В отличие от потоков, где объекты могут быть доступны из любого потока (при условии синхронизации), в Ractor объекты изолированы: один Ractor не может напрямую получить доступ к объектам другого Ractor. Это исключает состояние гонки на уровне данных.
Изоляция объектов
Каждый Ractor имеет собственное пространство имён и кучу. Объекты, созданные внутри одного Ractor, не видны другим Ractor. Если Ractor необходимо передать данные другому, он должен отправить их через канал. При передаче данные либо копируются (deep copy), либо, если они являются неизменяемыми (immutable), передаются по ссылке. К неизменяемым объектам в Ruby относятся: числа (Integer, Float), символы (Symbol), true, false, nil, строки (String) с замороженным состоянием (frozen), а также массивы и хэши, состоящие только из неизменяемых объектов.
Каналы и обмен сообщениями
Ractor общаются друг с другом с помощью очередей сообщений. Каждый Ractor имеет входящую очередь (почтовый ящик). Для отправки сообщения используется метод Ractor#send или оператор <<, для приёма — Ractor.receive или Ractor#take. Существует два основных типа обмена:
- Однонаправленная отправка (fire-and-forget): Ractor отправляет сообщение, не ожидая ответа.
- Запрос-ответ: Ractor отправляет сообщение и ждёт ответа, который может быть получен через
Ractor#take.
Параллелизм
Каждый Ractor выполняется в собственном потоке операционной системы. Поскольку GIL не распространяется на Ractor (точнее, GIL отпускается при выполнении Ractor, так как каждый Ractor имеет свой собственный контекст), несколько Ractor могут выполняться на разных ядрах процессора одновременно. Это позволяет достичь истинного параллелизма для CPU-bound задач.
Создание и управление Ractor
Создание Ractor происходит с помощью метода Ractor.new, который принимает блок кода и, опционально, аргументы. Аргументы передаются в блок при создании:
```ruby ractor = Ractor.new do
код, выполняемый в новом Ractor
Ractor.receive end ```
Для отправки сообщения:
``ruby ractor.send(:hello) ``
Для получения ответа:
``ruby result = ractor.take ``
Метод Ractor#take блокирует вызывающий Ractor до тех пор, пока целевой Ractor не завершится и не вернёт значение (или не вызовет Ractor.yield). Если Ractor завершается без явного возврата, take вернёт значение последнего выражения в блоке.
Примеры использования
Параллельные вычисления
```ruby
Вычисление факториала в нескольких Ractor
ractors = (1..4).map do |n| Ractor.new(n) do |arg| (1..arg).reduce(1, :*) end end
results = ractors.map(&:take) puts results.inspect # [1, 2, 6, 24] ```
Обмен сообщениями
```ruby
Простой чат между двумя Ractor
r1 = Ractor.new do msg = Ractor.receive puts "Ractor 1 received: #{msg}" Ractor.yield(:reply_from_1) end
r2 = Ractor.new do msg = Ractor.receive puts "Ractor 2 received: #{msg}" Ractor.yield(:reply_from_2) end
r1.send(:hello_from_main) r2.send(:hello_from_main)
puts r1.take # :reply_from_1 puts r2.take # :reply_from_2 ```
Ограничения и особенности
Неизменяемость и копирование
Передача изменяемых объектов (например, массивов или хэшей) между Ractor требует копирования. Это может быть затратным по памяти и времени для больших структур данных. Для оптимизации рекомендуется использовать неизменяемые объекты (например, замороженные строки или числа).
Исключения и ошибки
Если в Ractor возникает необработанное исключение, оно распространяется на родительский Ractor при вызове Ractor#take. Если исключение не обработано, родительский Ractor также может завершиться с ошибкой. Для обработки ошибок можно использовать конструкцию begin...rescue внутри блока Ractor.
Совместимость с потоками
Ractor не заменяет полностью потоки. Для I/O-bound задач (например, работа с сетью, файлами) потоки могут быть более эффективны, так как они не требуют копирования данных и имеют меньшие накладные расходы. Ractor предназначен в первую очередь для CPU-bound задач.
Применение
Ractor используется в приложениях, где требуется параллельная обработка больших объёмов данных, например:
- Обработка изображений и видео: фильтрация, сжатие, распознавание.
- Научные вычисления: симуляции, статистические расчёты.
- Веб-серверы: обработка запросов в параллельном режиме (например, в связке с Rack).
- Парсинг и обработка данных: параллельный разбор больших файлов или веб-страниц.
Критика и альтернативы
Некоторые разработчики отмечают, что Ractor имеет высокий порог входа из-за необходимости переосмыслить архитектуру приложения. Копирование данных при передаче может быть неэффективным для больших структур. Альтернативами являются:
- Thread + Queue: классическая модель с потоками и очередями, но с ограничением GIL.
- Fork: создание дочерних процессов (например, через
Process.fork), что даёт полную изоляцию, но требует больше ресурсов. - Внешние библиотеки: например,
concurrent-rubyпредоставляет абстракции для параллелизма, работающие поверх потоков.
Интересные факты
- Название «Ractor» происходит от «Ruby Actor» — отсылка к модели акторов.
- Ractor был вдохновлён языком Erlang, где процессы (actors) также изолированы и общаются через сообщения.
- В Ruby 3.0 Ractor был помечен как экспериментальный, а в Ruby 3.2 статус был повышен до «стабильного» (хотя некоторые функции остаются экспериментальными).
- GIL в Ruby не полностью устранён: он всё ещё действует для потоков, но Ractor позволяет обойти его для параллельных вычислений.
Источники
- Ruby 3.0 Release Notes
- Koichi Sasada, «Ractor: Ruby's Actor-like Concurrent Abstraction» (RubyConf 2020)
- Официальная документация Ruby:
Ractor(ruby-doc.org) - Статья «Understanding Ractor in Ruby» (RubyGuides)
BFOmetr — база данных и аналитика по компаниям России.
На главную BFOmetr →