Concurrency with Threads

· 5 min read · Updated March 19, 2026 · intermediate
threads concurrency parallelism mutex

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::Map from the parallel gem
  • Ractor for 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.new and block them with join
  • Use Mutex#synchronize to 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