Threads vs Ractors in Rails
Understanding Concurrency in Rails
In Ruby on Rails, managing multiple tasks simultaneously, or concurrency, can significantly enhance your application’s performance. Concurrency involves managing multiple tasks to make progress at the same time, even if they aren’t truly running simultaneously. Parallelism takes it further by actually executing multiple tasks at the same exact time using separate processors or cores.
Rails uses Threads for concurrency to handle multiple tasks within a single process, sharing memory and needing synchronization. For true parallelism, Rails employs Ractors, introduced in Ruby 3.0, which execute tasks in separate memory spaces simultaneously, avoiding conflicts and eliminating the need for mutexes.
Let’s dive into how Threads and Ractors work, with examples to illustrate their different approaches to concurrency and parallelism.
Threads in Rails
Threads allow you to run multiple operations concurrently within the same Ruby process. They share the same memory space, so they can access and modify the same data. However, this shared memory requires careful management to avoid conflicts.
Here’s a basic example demonstrating how threads work:
threads = []
# Create two threads that print numbers concurrently
threads << Thread.new { print_numbers("Thread 1") }
threads << Thread.new { print_numbers("Thread 2") }
def print_numbers(thread_name)
5.times do |i|
puts "#{thread_name}: #{i}"
sleep(1) # Simulate time-consuming task
end
end
# Wait for both threads to complete
threads.each(&:join)
puts "Threads completed!"
# OUTPUT =>
Thread 1: 0
Thread 2: 0
Thread 1: 1
Thread 2: 1
...
Thread 1: 4
Thread 2: 4
Threads completed!
- Concurrent Execution: Both threads print numbers concurrently, simulating parallel progress.
- Memory Sharing: Since both threads share the same memory space, they run in parallel but don’t conflict because they don’t modify shared data in this simple example.
When multiple threads access shared data, conflicts can occur if they try to read or write data simultaneously. To prevent such conflicts, you use a Mutex (short for Mutual Exclusion).
What is a Mutex?
A Mutex is a synchronization tool that ensures only one thread can access a critical section of code at a time. This prevents multiple threads from interfering with each other when accessing shared resources.
Here’s how you can use a Mutex to manage access to a shared counter:
mutex = Mutex.new
shared_counter = { value: 0 }
threads = []
# Create two threads to increment the shared counter
threads << Thread.new { increment_counter("Thread 1", mutex, shared_counter) }
threads << Thread.new { increment_counter("Thread 2", mutex, shared_counter) }
def increment_counter(thread_name, mutex, shared_counter)
10.times do
mutex.synchronize do
shared_counter[:value] += 1
puts "#{thread_name}: #{shared_counter[:value]}"
end
sleep(0.1) # Simulate time-consuming task
end
end
# Wait for both threads to complete
threads.each(&:join)
puts "Final counter value: #{shared_counter[:value]}"
# OUTPUT =>
Thread 1: 1
Thread 2: 2
Thread 1: 3
Thread 2: 4
...
Thread 1: 19
Thread 2: 20
Final counter value: 20
- Shared Resource: Both threads access and modify the same counter.
- Mutex Use: The
Mutex
ensures that only one thread updates the counter at a time, preventing conflicts and data corruption. - Final Value: Each thread increments the counter 10 times, so the total increments from both threads result in a final counter value of 20.
Ractors in Rails
Ractors were introduced in Ruby 3.0 to provide better parallelism by running tasks in separate memory spaces. Each Ractor operates independently, with its own memory, which avoids the need for synchronization tools like Mutex.
Here’s an example where each Ractor maintains its own independent counter:
ractors = []
# Create two Ractors, each with its own counter
ractors << Ractor.new { increment_counter("Ractor 1") }
ractors << Ractor.new { increment_counter("Ractor 2") }
def increment_counter(ractor_name)
counter = 0
10.times do
counter += 1
puts "#{ractor_name}: #{counter}"
sleep(0.1) # Simulate time-consuming task
end
counter
end
# Collect results from Ractors
results = ractors.map(&:take)
puts "Counters: #{results.join(', ')}"
# OUTPUT =>
Ractor 1: 1
Ractor 1: 2
Ractor 2: 1
Ractor 2: 2
...
Ractor 1: 10
Ractor 2: 10
Counters: 10, 10
- Separate Memory Space: Each Ractor runs independently with its own counter, so there are no conflicts or shared data issues.
- Final Values: Each Ractor performs its own countdown separately, resulting in each showing a final counter value of 10.
Summary
- Threads: Suitable for tasks that need to run concurrently within the same process and share memory. Mutex is used to manage access to shared resources and prevent conflicts.
- Ractors: Designed for true parallelism, running tasks in separate memory spaces without the need for synchronization tools like Mutex. They are ideal for independent parallel tasks.
By understanding how Threads and Ractors handle concurrency and parallelism, you can choose the best approach for your Rails application to optimize performance and manage resources effectively.