Ruby Concurrency with Threads: Mutex, Thread Pools, and Patterns
Ruby concurrency with threads lets you run multiple pieces of code simultaneously within a single process. Unlike spawning separate processes, threads share memory and are lighter weight. This makes Ruby threads ideal for I/O-bound concurrency tasks like HTTP requests, database queries, and file operations where your code spends most of its time waiting rather than computing.
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.
That pattern is the simplest shape of Ruby concurrency: start the thread, do other work, then join it when you need the result. It gives you parallel waiting without making the main flow much harder to follow.
thread lifecycle
Threads can be in several states. Check if a thread is still running with alive?, which returns true while the thread is executing and false once it has finished or been killed. After calling join, the thread is guaranteed to have completed, so alive? will return false:
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. value blocks until the thread finishes, then returns the result of the block’s last expression. This is handy when the thread does work that naturally returns one result, such as parsing a file or fetching a response. The result retrieval stays in one place instead of being stored in a shared variable:
result = Thread.new { 42 }
puts result.value # => 42
value is handy when the thread does work that naturally returns one result, such as parsing a file or fetching a response. It keeps the result retrieval in one place instead of storing it in a shared variable.
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.
That makes them a good fit for counters, request IDs, or other temporary values that should not leak across workers. If the data really belongs to one thread, storing it there keeps the intent clear.
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.
The example is small, but the same rule applies to any shared state. If one thread can observe a half-finished write from another thread, the code needs a lock or a different design.
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]
A fixed pool keeps resource use predictable. Instead of creating a new thread for every item, you cap the number of workers and let them drain the queue as capacity becomes available.
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
This pattern is common when the producer and consumer do not run at the same speed. The queue absorbs the mismatch and lets each side work at its own pace.
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.
If you can redesign the code so each thread owns its own data, that is often better than adding more locking. Locks are correct, but they also add coordination overhead.
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.
Keeping the lock order stable is the simplest defense. If two resources must be locked together, decide on the order once and use it everywhere.
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
Thread-safe collections are useful when the code genuinely needs shared mutation, but they should still be used intentionally. A simple queue or a synchronized wrapper is often enough.
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.
When that waiting is on network requests, disk access, or database calls, threads can hide a lot of idle time. When the work is mostly math, they usually do not help in MRI, so another strategy is a better fit.
choosing the right concurrency primitive
Threads are one tool in Ruby’s concurrency toolbox, not the only one. Fibers are better when you want cooperative switching, queues are better when you want to hand work between producers and consumers, and separate processes are better when you need true CPU parallelism. Picking the smallest tool that matches the problem keeps the code easier to test and reason about.
For small scripts, a thread is often the fastest path from idea to working code. For long-lived services, it is worth asking whether the task needs isolation, back-pressure, or parallel execution. That question usually leads to a cleaner design than forcing every job into a thread.
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
Handling errors in the thread body is often even better than checking after the fact. If you can fail fast inside the worker, you avoid keeping broken state around longer than necessary.
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.
Treat priority as a hint rather than a guarantee. It can help with scheduling bias, but it should not be the main mechanism that keeps your program correct.
threads vs fibers
Fibers are lighter-weight concurrency primitives that you explicitly yield and resume. Unlike threads, fibers do not run simultaneously. They cooperatively yield control, which means only one fiber executes at a time and the programmer decides when to switch:
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"
Use fibers for coroutine-style programming where you control when execution switches, such as building custom iterators or managing cooperative task scheduling. Use threads when you need true concurrent execution, especially for I/O operations where multiple waits can overlap. The distinction matters because it affects how you structure the rest of the code: threads can overlap waiting, while fibers rely on explicit yield points and a scheduler that knows when to resume them.
When threads help most
Threads are most useful when your code spends time waiting rather than computing. A web request that calls an API, a worker that reads files, or a script that fans out database queries can often benefit from overlapping that waiting time. The thread count does not need to be huge for the idea to work. Even a small amount of overlap can make the process feel much more responsive.
The important thing is to keep the shared surface area small. If every thread touches the same global state, the performance gains are quickly eaten by coordination overhead. If each thread has a narrow job and only communicates through a queue or a synchronized section, the code stays much easier to debug.
Threads are also a good fit when the work naturally breaks into independent pieces. Fetching a few URLs, transforming several files, or building multiple independent reports can all run well as threads because each unit of work can finish without needing to know the internals of the others. That independence is what makes concurrency manageable.
The flip side is that threads are usually the wrong tool for heavy CPU work in Ruby MRI. The GIL means you do not get real parallel execution for pure computation, so a long numeric loop will not suddenly become faster just because it runs in a thread. For that kind of workload, separate processes or another concurrency model usually make more sense.
If you remember only one practical rule, make it this: use threads to hide waiting, not to hide complexity. When the waiting is external and the coordination is simple, threads are often the most direct solution Ruby offers.
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