Ruby Logo

Ractors (Ruby 3.0+)

Explore Ruby's actor-based concurrency model for true parallel execution with memory isolation and message passing.

Home Ruby Ractors (Ruby 3.0+)

Ractors (Ruby 3.0+)

Learn Ruby's revolutionary actor-based concurrency model that provides true parallel execution while maintaining memory safety and eliminating data races.

What are Ractors?

Ractors (Ruby Actors) are Ruby 3.0's answer to true parallel computing. Unlike threads which are limited by the Global VM Lock (GVL), Ractors provide isolated execution contexts that can run in parallel across multiple CPU cores. Each Ractor has its own memory space and communicates with other Ractors through message passing, similar to the Actor model used in languages like Erlang.

Key Benefits:

  • True Parallelism: No GVL limitations
  • Memory Safety: Isolated memory spaces prevent data races
  • Fault Tolerance: Failures in one Ractor don't affect others
  • Message Passing: Safe communication through immutable messages

Basic Ractor Operations

Creating and Running Ractors

class RactorBasics
  def self.demonstrate_creation
    puts "Main Ractor: #{Ractor.current}"

    # Create a simple Ractor
    worker = Ractor.new do
      puts "Worker Ractor: #{Ractor.current}"
      "Hello from worker!"
    end

    # Wait for result
    result = worker.take
    puts "Result: #{result}"

    # Create Ractor with parameters
    calculator = Ractor.new(10, 20) do |a, b|
      puts "Calculator Ractor processing: #{a} + #{b}"
      a + b
    end

    sum = calculator.take
    puts "Sum: #{sum}"
  end

  def self.demonstrate_multiple_ractors
    # Create multiple workers
    workers = 5.times.map do |i|
      Ractor.new(i) do |worker_id|
        # Simulate work
        sleep(rand(0.1..0.5))
        "Worker #{worker_id} completed"
      end
    end

    # Collect results
    results = workers.map(&:take)
    puts "All results: #{results}"
  end
end

RactorBasics.demonstrate_creation
RactorBasics.demonstrate_multiple_ractors

Message Passing Between Ractors

class RactorMessaging
  def self.demonstrate_bidirectional_communication
    # Create a worker that processes messages
    processor = Ractor.new do
      loop do
        message = Ractor.receive
        case message[:type]
        when :process
          result = message[:data].upcase
          Ractor.yield(result)
        when :exit
          break
        end
      end
    end

    # Send messages to the processor
    processor.send({type: :process, data: "hello world"})
    result1 = processor.take
    puts "Processed: #{result1}"

    processor.send({type: :process, data: "ruby ractors"})
    result2 = processor.take
    puts "Processed: #{result2}"

    # Cleanup
    processor.send({type: :exit})
  end

  def self.demonstrate_select_pattern
    # Create multiple message sources
    sources = 3.times.map do |i|
      Ractor.new(i) do |id|
        sleep(rand(0.1..0.3))
        Ractor.yield("Message from source #{id}")
      end
    end

    # Use select to handle messages as they arrive
    received_count = 0
    while received_count < sources.length
      r, message = Ractor.select(*sources)
      puts "Received: #{message} from #{r}"
      received_count += 1
    end
  end
end

RactorMessaging.demonstrate_bidirectional_communication
RactorMessaging.demonstrate_select_pattern

Advanced Ractor Patterns

Worker Pool Pattern

class RactorWorkerPool
  def initialize(pool_size = 4)
    @pool_size = pool_size
    @workers = []
    @task_queue = []
    @results = {}
    @next_task_id = 0

    create_workers
  end

  def submit_task(&block)
    task_id = @next_task_id
    @next_task_id += 1

    @task_queue << { id: task_id, block: block }
    distribute_tasks

    task_id
  end

  def get_result(task_id, timeout: 5)
    start_time = Time.now

    while Time.now - start_time < timeout
      return @results.delete(task_id) if @results.key?(task_id)
      collect_results
      sleep(0.01)
    end

    raise "Task #{task_id} timeout"
  end

  def shutdown
    @workers.each { |worker| worker.send({ type: :shutdown }) }
    @workers.clear
  end

  private

  def create_workers
    @pool_size.times do |i|
      worker = Ractor.new(i) do |worker_id|
        loop do
          message = Ractor.receive

          case message[:type]
          when :task
            begin
              result = message[:block].call
              Ractor.yield({
                type: :result,
                task_id: message[:task_id],
                result: result,
                worker_id: worker_id
              })
            rescue => e
              Ractor.yield({
                type: :error,
                task_id: message[:task_id],
                error: e.message,
                worker_id: worker_id
              })
            end
          when :shutdown
            break
          end
        end
      end

      @workers << worker
    end
  end

  def distribute_tasks
    while !@task_queue.empty? && available_worker = find_available_worker
      task = @task_queue.shift
      available_worker.send({
        type: :task,
        task_id: task[:id],
        block: task[:block]
      })
    end
  end

  def find_available_worker
    @workers.find { |worker| !worker.instance_variable_get(:@busy) }
  end

  def collect_results
    @workers.each do |worker|
      begin
        result = worker.take(0.001)  # Non-blocking take
        case result[:type]
        when :result, :error
          @results[result[:task_id]] = result
        end
      rescue Ractor::ClosedError, Ractor::EmptyError
        # No message available
      end
    end
  end
end

# Usage example
pool = RactorWorkerPool.new(3)

# Submit CPU-intensive tasks
task_ids = []
5.times do |i|
  task_id = pool.submit_task do
    # Simulate CPU-intensive work
    sum = 0
    1_000_000.times { |n| sum += n }
    "Task #{i} completed with sum: #{sum}"
  end
  task_ids << task_id
end

# Collect results
task_ids.each do |task_id|
  result = pool.get_result(task_id)
  puts "Task #{task_id}: #{result[:result]}"
end

pool.shutdown

Pipeline Processing with Ractors

class RactorPipeline
  def initialize(stages)
    @stages = stages
    @ractors = []
    @input_ractor = nil
    @output_ractor = nil

    build_pipeline
  end

  def process(data)
    @input_ractor.send(data)
    @output_ractor.take
  end

  def process_batch(batch)
    results = []

    # Send all data
    batch.each { |data| @input_ractor.send(data) }

    # Collect results
    batch.size.times { results << @output_ractor.take }

    results
  end

  def shutdown
    @ractors.each { |ractor| ractor.send(:shutdown) }
  end

  private

  def build_pipeline
    previous_ractor = nil

    @stages.each_with_index do |stage_proc, index|
      ractor = if index == 0
        # First stage - receives external input
        Ractor.new(stage_proc) do |proc|
          loop do
            input = Ractor.receive
            break if input == :shutdown

            result = proc.call(input)
            Ractor.yield(result)
          end
        end
      else
        # Intermediate/final stages
        Ractor.new(previous_ractor, stage_proc) do |input_ractor, proc|
          loop do
            begin
              input = input_ractor.take
              result = proc.call(input)
              Ractor.yield(result)
            rescue Ractor::ClosedError
              break
            end
          end
        end
      end

      @ractors << ractor
      @input_ractor = ractor if index == 0
      @output_ractor = ractor
      previous_ractor = ractor
    end
  end
end

# Create a data processing pipeline
stages = [
  ->(data) { data.to_s.upcase },           # Stage 1: Convert to uppercase
  ->(data) { data.reverse },               # Stage 2: Reverse string
  ->(data) { "Processed: #{data}" }        # Stage 3: Add prefix
]

pipeline = RactorPipeline.new(stages)

# Process single items
puts pipeline.process("hello")
puts pipeline.process("world")

# Process batch
batch_results = pipeline.process_batch(["ruby", "ractors", "parallel"])
puts "Batch results: #{batch_results}"

pipeline.shutdown

Ractor Limitations and Best Practices

⚠️ Important Limitations

  • Shareable Objects: Only certain objects can be shared between Ractors (numbers, strings, symbols, frozen objects)
  • No Shared State: Variables cannot be shared directly between Ractors
  • Limited Class Access: Some classes cannot be used across Ractor boundaries
  • Experimental Status: Still experimental in Ruby 3.x with potential API changes
  • Debugging Complexity: Harder to debug than traditional threading

✅ Best Practices

  • Immutable Data: Use frozen objects for message passing
  • Small Messages: Keep message size reasonable to avoid serialization overhead
  • Error Handling: Always handle Ractor exceptions gracefully
  • Resource Cleanup: Properly shutdown Ractors to avoid resource leaks
  • CPU-Bound Tasks: Best suited for computationally intensive work

Safe Message Passing Example

class SafeRactorCommunication
  def self.demonstrate_safe_sharing
    # Shareable objects (safe to pass)
    safe_data = {
      number: 42,
      string: "hello".freeze,
      symbol: :test,
      array: [1, 2, 3].freeze
    }.freeze

    processor = Ractor.new do
      data = Ractor.receive
      puts "Received safe data: #{data}"

      # Process and return new frozen data
      result = {
        processed: data[:string].upcase,
        doubled: data[:number] * 2,
        count: data[:array].size
      }.freeze

      Ractor.yield(result)
    end

    processor.send(safe_data)
    result = processor.take
    puts "Processing result: #{result}"
  end

  def self.demonstrate_unsafe_sharing
    # This would raise an error
    begin
      unsafe_data = { mutable_array: [1, 2, 3] }  # Not frozen

      processor = Ractor.new do
        Ractor.receive
      end

      processor.send(unsafe_data)  # This will raise an error
    rescue => e
      puts "Error sharing unsafe data: #{e.message}"
    end
  end
end

SafeRactorCommunication.demonstrate_safe_sharing
SafeRactorCommunication.demonstrate_unsafe_sharing

Real-World Applications

Perfect for Ractors:

  • CPU-intensive computations: Mathematical calculations, data analysis
  • Parallel data processing: Image/video processing, batch operations
  • Independent workflows: Report generation, background jobs
  • Distributed algorithms: Map-reduce operations, parallel sorting

Consider Alternatives for:

  • I/O bound operations: Use async patterns or threads
  • Shared mutable state: Use threads with synchronization
  • Simple concurrent tasks: Fibers or threads may be simpler
  • Legacy system integration: May not support Ractor constraints

🎯 Key Takeaways

  • True Parallelism: Ractors provide genuine parallel execution without GVL limitations
  • Memory Isolation: Each Ractor has isolated memory, preventing data races by design
  • Message Passing: Communication happens through immutable message passing, not shared memory
  • Use Cases: Best for CPU-bound, parallelizable tasks that don't require shared mutable state
  • Experimental: Still evolving in Ruby 3.x, use with awareness of potential API changes

Quick Navigation

Related Topics

Video Tutorial

Watch and learn ractors (ruby 3.0+)

Pro Tip: After reading through the content above, watch this video to reinforce your understanding and see the concepts in action!