Ruby Concurrency Beyond Fibers: Threads, Ractors, and True Parallelism

Introduction

Ruby offers multiple concurrency primitives, and while Fibers have gained popularity for I/O bound tasks, they’re just one piece of the puzzle.

Understanding when to use Threads, Ractors, or Fibers is crucial for building performant Ruby applications.

This post explores Ruby’s concurrency landscape beyond fibers, with practical examples and guidance on choosing the right tool for your use case.

Understanding the GVL

Before diving into concurrency primitives, we need to understand the Global Virtual Machine Lock (GVL), sometimes called the Global Interpreter Lock (GIL).

The GVL ensures that only one thread executes Ruby code at a time within a single process. This design choice simplifies memory management and makes C extensions easier to write, but it limits true parallelism for CPU bound tasks.

However, the GVL is released during I/O operations, allowing other threads to run while one waits for network or disk operations.

# I/O bound tasks can run concurrently

threads = 3.times.map do |i|
  Thread.new do
    # GVL is released during sleep (simulating I/O)

    sleep(1)
    puts "Thread #{i} completed"
  end
end

threads.each(&:join)
# All three complete in ~1 second, not 3

Threads: The Foundation

Threads remain the foundation of Ruby concurrency. They’re ideal for I/O bound operations where the GVL releases allow genuine concurrent execution.

Creating and Managing Threads

# Basic thread creation

thread = Thread.new do
  puts "Hello from thread!"
end
thread.join

# Passing arguments to threads

thread = Thread.new(10, 20) do |a, b|
  puts "Sum: #{a + b}"
end
thread.join

Thread Synchronization

When threads share data, we need synchronization to prevent race conditions.

require 'thread'

counter = 0
mutex = Mutex.new

threads = 10.times.map do
  Thread.new do
    1000.times do
      mutex.synchronize do
        counter += 1
      end
    end
  end
end

threads.each(&:join)
puts counter # => 10000 (guaranteed)

Thread Pools for Better Resource Management

Creating threads is expensive. Thread pools help manage resources efficiently.

require 'concurrent-ruby'

pool = Concurrent::FixedThreadPool.new(5)

20.times do |i|
  pool.post do
    puts "Task #{i} running on #{Thread.current.object_id}"
    sleep(0.5)
  end
end

pool.shutdown
pool.wait_for_termination

When to Use Threads

Threads work best for:

  • I/O bound operations (HTTP requests, database queries, file operations)
  • Background processing where tasks wait on external resources
  • Applications that need to handle multiple connections
require 'net/http'
require 'uri'

urls = [
  'https://api.github.com',
  'https://httpbin.org/get',
  'https://jsonplaceholder.typicode.com/posts/1'
]

# Sequential: slow

start = Time.now
urls.each { |url| Net::HTTP.get(URI(url)) }
puts "Sequential: #{Time.now - start}s"

# Concurrent with threads: fast

start = Time.now
threads = urls.map do |url|
  Thread.new { Net::HTTP.get(URI(url)) }
end
threads.each(&:join)
puts "Concurrent: #{Time.now - start}s"

Ractors: True Parallelism

Introduced in Ruby 3.0, Ractors provide true parallel execution by giving each Ractor its own GVL.

This means CPU bound tasks can finally run in parallel on multiple cores.

How Ractors Work

Ractors achieve thread safety by severely limiting object sharing. Each Ractor has its own memory space, and objects must be explicitly passed between them.

# Create a ractor that computes something

ractor = Ractor.new do
  # This runs in parallel with the main thread

  sum = (1..1_000_000).sum
  sum
end

# Do other work while ractor computes

puts "Main thread working..."

# Get the result

result = ractor.take
puts "Ractor result: #{result}"

Message Passing Between Ractors

Ractors communicate through message passing, similar to the Actor model.

# Ractor that receives and processes messages

worker = Ractor.new do
  loop do
    message = Ractor.receive
    break if message == :stop

    # Process the message

    result = message * 2
    Ractor.yield result
  end
end

# Send messages and receive results

worker.send(5)
puts worker.take # => 10


worker.send(10)
puts worker.take # => 20


worker.send(:stop)

Shareable Objects

Not all objects can be shared between Ractors. Ruby provides methods to check and make objects shareable.

# Numbers and frozen strings are shareable

Ractor.shareable?(42)           # => true

Ractor.shareable?('hello'.freeze) # => true


# Mutable objects are not shareable by default

Ractor.shareable?([1, 2, 3])    # => false


# Make an object shareable (deep freezes it)

array = ['hello', 'world']
Ractor.make_shareable(array)
Ractor.shareable?(array)        # => true

array.frozen?                   # => true

Parallel CPU Bound Work

Here’s where Ractors shine: parallel computation across multiple cores.

def cpu_intensive_task(n)
  # Simulate CPU bound work

  (1..n).reduce(0) { |sum, i| sum + Math.sqrt(i) }
end

# Sequential execution

start = Time.now
4.times { cpu_intensive_task(5_000_000) }
sequential_time = Time.now - start

# Parallel execution with Ractors

start = Time.now
ractors = 4.times.map do
  Ractor.new do
    (1..5_000_000).reduce(0) { |sum, i| sum + Math.sqrt(i) }
  end
end
results = ractors.map(&:take)
parallel_time = Time.now - start

puts "Sequential: #{sequential_time}s"
puts "Parallel: #{parallel_time}s"
puts "Speedup: #{(sequential_time / parallel_time).round(2)}x"

Ractor Limitations

Ractors come with important limitations:

  • Cannot access outer scope variables
  • Cannot share mutable objects
  • Class and module instance variables have restrictions
  • Some gems may not be Ractor safe
  • Still marked as experimental (as of Ruby 3.4)
# This will raise an error

outer_value = 10
Ractor.new { puts outer_value } # Error!


# Instead, pass values explicitly

Ractor.new(10) { |value| puts value } # Works!

Comparing Concurrency Primitives

Here’s a quick comparison to help choose the right tool:

Fibers

  • Best for: I/O bound tasks with many concurrent operations
  • Memory: Very lightweight (~4KB per fiber)
  • Parallelism: No (cooperative scheduling within a thread)
  • Use case: Web servers handling thousands of connections

Threads

  • Best for: I/O bound tasks, background processing
  • Memory: Heavier than fibers (~1MB stack per thread)
  • Parallelism: Limited by GVL for Ruby code
  • Use case: Concurrent HTTP requests, database operations

Ractors

  • Best for: CPU bound parallel computation
  • Memory: Each has its own GVL and memory space
  • Parallelism: True parallel execution
  • Use case: Data processing, mathematical computations

Practical Example: Web Scraper

Let’s build a web scraper that demonstrates when to use each concurrency primitive.

require 'net/http'
require 'uri'
require 'json'

class WebScraper
  def initialize(urls)
    @urls = urls
  end

  # Using threads for I/O bound fetching

  def fetch_all_threaded
    threads = @urls.map do |url|
      Thread.new(url) do |u|
        fetch_url(u)
      end
    end
    threads.map(&:value)
  end

  # Using Ractors for CPU bound processing

  def process_with_ractors(data_array)
    ractors = data_array.map do |data|
      Ractor.new(data) do |d|
        # CPU intensive processing

        process_data(d)
      end
    end
    ractors.map(&:take)
  end

  private

  def fetch_url(url)
    uri = URI(url)
    response = Net::HTTP.get_response(uri)
    { url: url, status: response.code, body: response.body }
  rescue StandardError => e
    { url: url, error: e.message }
  end

  def process_data(data)
    # Simulate CPU intensive processing

    data[:body].chars.group_by(&:itself).transform_values(&:count)
  end
end

The Async Gem: Best of Both Worlds

For production applications, the Async gem provides a high level interface that combines the efficiency of fibers with an easy to use API.

require 'async'
require 'async/http/internet'

Async do
  internet = Async::HTTP::Internet.new

  urls = [
    'https://httpbin.org/get',
    'https://jsonplaceholder.typicode.com/posts/1'
  ]

  # All requests run concurrently

  tasks = urls.map do |url|
    Async do
      response = internet.get(url)
      puts "#{url}: #{response.status}"
      response.finish
    end
  end

  tasks.each(&:wait)
ensure
  internet&.close
end

Choosing the Right Approach

Here’s a decision guide:

  1. I/O bound with many connections: Use Fibers (via Async gem)
  2. I/O bound with moderate concurrency: Use Threads
  3. CPU bound parallel processing: Use Ractors
  4. Mixed workloads: Combine approaches (Threads for I/O, Ractors for CPU)

Conclusion

Ruby’s concurrency story has evolved significantly. While the GVL limits thread based parallelism for CPU bound tasks, Ractors now provide a path to true parallel execution.

For most web applications, Threads or Fibers handle I/O bound concurrency well. When we need to leverage multiple CPU cores for computation, Ractors offer a thread safe solution.

Understanding these primitives and their trade offs helps us build more performant Ruby applications.

References

Need help on your Ruby on Rails or React project?

Join Our Newsletter