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 3Threads: 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.joinThread 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_terminationWhen 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? # => trueParallel 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
endThe 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
endChoosing the Right Approach
Here’s a decision guide:
- I/O bound with many connections: Use Fibers (via Async gem)
- I/O bound with moderate concurrency: Use Threads
- CPU bound parallel processing: Use Ractors
- 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.
