Concurrency with Threads
Ruby threads let you run multiple pieces of code simultaneously within a single Ruby process. Unlike spawning separate processes, threads share memory and are lighter weight, making them ideal for I/O-bound tasks like HTTP requests, database queries, and file operations.
Creating and Starting Threads
The simplest way to create a thread is with Thread.new, which takes a block of code to execute:
thread = Thread.new do
puts "Running in a separate thread!"
sleep 1
puts "Thread finished!"
end
puts "Main thread continuing..."
thread.join # Wait for the thread to finish
puts "All done!"
The join method blocks the calling thread until the target thread completes. Without join, the main thread might exit before your background threads finish their work.
Thread Lifecycle
Threads can be in several states. Check if a thread is still running with alive?:
t1 = Thread.new { sleep 5 }
puts t1.alive? # => true
t1.join
puts t1.alive? # => false
Use value to get the return value of a thread’s block:
result = Thread.new { 42 }
puts result.value # => 42
Thread-Local Variables
Each thread has its own set of local variables. You can also store thread-specific data using the thread-local accessor methods:
thread = Thread.new do
Thread.current[:progress] = 0
5.times do
Thread.current[:progress] += 1
puts "Progress: #{Thread.current[:progress]}"
sleep 0.1
end
end
thread.join
puts thread[:progress] # => 5
Thread-local variables are isolated per thread, making them useful for tracking thread-specific state without collisions.
Mutual Exclusion with Mutex
When multiple threads access shared data, you need synchronization to prevent race conditions. A Mutex (mutual exclusion lock) ensures only one thread can execute a critical section at a time:
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
Without the mutex, you’d get an incorrect count because multiple threads would read and write counter simultaneously, losing updates.
Common Patterns
Thread Pool
Create a fixed number of worker threads to process tasks:
require 'thread'
queue = Queue.new
results = []
# Add tasks to the queue
5.times { |i| queue.push(i) }
workers = 3.times.map do
Thread.new do
until queue.empty?
begin
task = queue.pop(false)
results << task * 2
rescue ThreadError
break
end
end
end
end
workers.each(&:join)
puts results.sort # => [0, 2, 4, 6, 8]
Producer-Consumer
One or more threads produce data while others consume it:
require 'thread'
queue = Queue.new
done = false
producer = Thread.new do
5.times do |i|
queue.push(i)
sleep 0.1
end
done = true
end
consumer = Thread.new do
loop do
break if done && queue.empty?
begin
item = queue.pop(true)
puts "Consumed: #{item}"
rescue ThreadError
retry
end
end
end
producer.join
consumer.join
Common Pitfalls
Race Conditions
When threads access shared state without synchronization, results become unpredictable:
# BAD: Race condition!
counter = 0
threads = 10.times.map do
Thread.new { 1000.times { counter += 1 } }
end
threads.each(&:join)
puts counter # Unpredictable! Likely less than 10000
Always use a Mutex or other synchronization primitive when accessing shared data.
Deadlocks
Two or more threads waiting for each other to release locks:
mutex1 = Mutex.new
mutex2 = Mutex.new
t1 = Thread.new do
mutex1.lock
sleep 0.1
mutex2.lock # Waits for t2 to release mutex2
end
t2 = Thread.new do
mutex2.lock
sleep 0.1
mutex1.lock # Waits for t1 to release mutex1
end
# Both threads wait forever - deadlock!
Avoid deadlocks by always acquiring locks in a consistent order, or use Mutex.try_lock with a timeout.
Thread-Safe Data Structures
Ruby’s core classes like Array and Hash are not thread-safe. Use Concurrent::Array and Concurrent::Hash from the concurrent-ruby gem, or always synchronize access:
require 'concurrent'
safe_array = Concurrent::Array.new
threads = 10.times.map do
Thread.new do
100.times { |i| safe_array << i }
end
end
threads.each(&:join)
puts safe_array.size # => 1000
When to Use Threads
Threads excel at I/O-bound tasks where you’re waiting on external resources. For CPU-bound work, consider:
Parallel::Mapfrom the parallel gemRactorfor true parallelism (Ruby 3+)- Separate processes
Ruby’s Global Interpreter Lock (GIL) means threads don’t provide true parallelism for CPU-intensive code. However, they still help when your code spends time waiting.
Summary
- Create threads with
Thread.newand block them withjoin - Use
Mutex#synchronizeto protect shared state - Avoid deadlocks by consistent lock ordering
- Use thread-safe data structures like
Concurrent::Array - Threads are ideal for I/O-bound concurrency, not CPU-bound parallelism
Thread Exception Handling
When an unhandled exception occurs in a thread, it doesn’t crash the main program by default. Instead, the thread terminates and the exception is stored:
thread = Thread.new do
raise "Something went wrong!"
end
thread.join # This re-raises the exception in the main thread
Use Thread#join to propagate exceptions, or check thread[:exception] to handle them gracefully:
thread = Thread.new do
sleep 1
raise "Error in thread!"
end
sleep 2
if thread.dead? && thread[:exception]
puts "Thread failed: #{thread[:exception].message}"
end
Thread Priority
Ruby threads have a priority system that affects scheduling. Higher priority threads get more CPU time:
low_priority = Thread.new do
loop { print "low " }
end
low_priority.priority = -1
high_priority = Thread.new do
loop { print "HIGH " }
end
high_priority.priority = 1
sleep 1
# Output shows HIGH appears more frequently
Note that thread priority behavior varies across Ruby implementations and operating systems.
Threads vs Fibers
Fibers are lighter-weight concurrency primitives that you explicitly yield and resume:
fiber = Fiber.new do
puts "Fiber start"
Fiber.yield
puts "Fiber resumed"
puts "Fiber end"
end
puts "Before fiber"
fiber.resume
puts "After yield"
fiber.resume
puts "Done"
Unlike threads, fibers don’t run simultaneously—they cooperatively yield control. Use fibers for coroutine-style programming where you control when execution switches. Use threads when you need true concurrent execution, especially for I/O operations.
See Also
- Ruby Fiber Scheduler — Learn about Ruby 3.0’s async I/O scheduler, which integrates with fibers for high-performance concurrent operations
- DRb — Distributed Ruby — See threads in action as DRb uses them to handle concurrent client connections
- Ruby Sidekiq Guide — Background job processing that relies on threads to run many workers simultaneously