Unlocking Ruby Concurrency with Ractors

Ruby, a language cherished for its developer-friendliness and elegant syntax, has historically faced challenges in achieving true parallelism due to its Global Interpreter Lock (GIL), also known as the Global VM Lock (GVL). This mechanism, while simplifying memory management and preventing race conditions, restricts Ruby programs to executing only one thread at a time, even on multi-core processors. This limitation has often led developers to explore external solutions for CPU-bound tasks. However, with the introduction of Ractors in Ruby 3, the landscape of concurrency in Ruby has dramatically shifted, offering a native path to harness the power of multi-core architectures.

The Global Interpreter Lock (GIL) and its Impact

Before diving into Ractors, it's crucial to understand the GIL's role and its implications for Ruby applications.

  • What is the GIL? The GIL is a mutex that protects the Ruby interpreter's internal state. It ensures that only one thread can execute Ruby code at any given time, regardless of the number of available CPU cores. This simplifies the interpreter's design and prevents many common concurrency bugs, such as race conditions on shared data structures.
  • Concurrency vs. Parallelism: It's important to distinguish between these two terms:
    • Concurrency is about dealing with many things at once. Ruby threads, even with the GIL, allow for concurrency by switching between tasks, making I/O-bound operations (like network requests or file I/O) appear to run simultaneously.
    • Parallelism is about doing many things at once. This means truly executing multiple operations simultaneously on different CPU cores. The GIL traditionally prevented Ruby from achieving true parallelism for CPU-bound workloads.

While Ruby's threading model is effective for I/O-bound tasks, where threads spend most of their time waiting for external resources, it offers no performance benefits for CPU-bound tasks, as only one thread can actively compute at a time.

Enter Ractors: A New Paradigm for Parallelism

Ractors, short for Ruby Actors, are a new concurrency primitive introduced in Ruby 3.0. They are designed to enable true parallel execution of Ruby code by allowing multiple Ractors to run concurrently on different CPU cores, each with its own independent Global VM Lock.

How Ractors Work

The core concept behind Ractors is isolation. Each Ractor has its own private set of objects and a dedicated GVL. This isolation is key to achieving parallelism:

  • Independent GVLS: Unlike traditional Ruby threads that share a single GVL, each Ractor manages its own GVL. This means that multiple Ractors can execute Ruby code simultaneously on different cores, bypassing the traditional GVL bottleneck.
  • Message Passing: Ractors communicate with each other by passing messages. This explicit communication mechanism enforces data isolation, preventing direct shared memory access that could lead to race conditions. Data passed between Ractors is either deeply copied or transferred, ensuring that each Ractor operates on its own set of data.
  • Immutable Objects: Ractors can safely share immutable objects (like Symbols, Integers, and true/false/nil). Mutable objects, however, must be explicitly sent between Ractors, which involves a deep copy, or frozen before sharing.

Practical Example: Parallel Computation with Ractors

Let's illustrate how Ractors can be used for a CPU-bound task, such as calculating prime numbers.

# Without Ractors (single-threaded)
def find_primes_sequential(range)
  primes = []
  range.each do |num|
    primes << num if is_prime?(num)
  end
  primes
end

# With Ractors (parallel)
def find_primes_parallel(max_num, num_ractors)
  ranges = (1..max_num).each_slice(max_num / num_ractors).to_a
  
  ractors = ranges.map do |range|
    Ractor.new(range) do |r|
      r.each do |num|
        Ractor.yield num if is_prime?(num)
      end
    end
  end

  all_primes = []
  ractors.each do |ractor|
    while (prime = ractor.take)
      all_primes << prime
    end
  end
  all_primes.sort
end

def is_prime?(num)
  return false if num < 2
  (2..Math.sqrt(num)).each do |i|
    return false if num % i == 0
  end
  true
end

# Example usage
# puts
← Back to ruby tutorials