Skip to content

Ruby Multithreading

Concurrency allows a program to handle multiple tasks seemingly simultaneously, improving responsiveness and throughput. Multithreading is one way to achieve concurrency, where a program is divided into multiple threads of execution that can run independently.

Ruby provides the Thread class for creating and managing threads. While threads in Ruby can execute different parts of your code concurrently, it’s important to understand the role of the Global Interpreter Lock (GIL) in MRI (Matz’s Ruby Interpreter), the standard Ruby implementation.

In MRI, the GIL ensures that only one thread can execute Ruby code at any given moment within a single process. This simplifies the C extension API and some internal Ruby workings but means that CPU-bound tasks (tasks that spend most of their time performing calculations) won’t achieve true parallelism on multi-core processors using only threads within a single MRI process. However, I/O-bound tasks (tasks that spend most of their time waiting for external operations like network requests or file access) can benefit significantly from threads, as one thread can release the GIL while waiting, allowing other threads to run.

For true parallelism with CPU-bound tasks in MRI, you might consider using multiple processes (e.g., via fork or libraries like parallel) or exploring Ractors (introduced in Ruby 3.0), which are a newer concurrency model designed for parallelism. Other Ruby implementations like JRuby or TruffleRuby do not have a GIL and can run multiple threads in parallel.

To start a new thread, associate a block of code with Thread.new. The original thread continues execution immediately after creating the new thread.

# Main thread is running here
Thread.new do
# New thread (Thread #2) runs this code
# ...
end
# Main thread (Thread #1) continues to run this code
#!/usr/bin/env ruby
def task_one
3.times do |i|
puts "Task One at iteration #{i}: #{Time.now}"
sleep(0.5) # Simulate I/O-bound work
end
end
def task_two
3.times do |j|
puts "Task Two at iteration #{j}: #{Time.now}"
sleep(0.3) # Simulate I/O-bound work
end
end
puts "Program Started At #{Time.now}"
t1 = Thread.new { task_one() }
t2 = Thread.new { task_two() }
# Wait for threads to complete before exiting the main program
t1.join
t2.join
puts "Program Ended At #{Time.now}"

This will produce output showing interleaved execution (exact timing will vary):

Program Started At 2023-10-27 10:00:00 +0000
Task One at iteration 0: 2023-10-27 10:00:00 +0000
Task Two at iteration 0: 2023-10-27 10:00:00 +0000
Task Two at iteration 1: 2023-10-27 10:00:00 +0000
Task One at iteration 1: 2023-10-27 10:00:01 +0000
Task Two at iteration 2: 2023-10-27 10:00:01 +0000
Task One at iteration 2: 2023-10-27 10:00:01 +0000
Program Ended At 2023-10-27 10:00:02 +0000

Threads are created with Thread.new (or its aliases Thread.start, Thread.fork). They begin execution automatically. The Thread#value method waits for the thread to complete and returns the value of the last expression in its block. Thread#join also waits for completion but returns the thread object itself.

Thread.current returns the Thread object for the currently executing thread. Thread.main returns the main thread object.

If an unhandled exception occurs in a thread (other than the main thread), that thread terminates silently by default. If another thread calls join or value on the terminated thread, the original exception is re-raised in the calling thread.

To make any unhandled exception in any thread terminate the entire Ruby program, set Thread.abort_on_exception = true. Alternatively, for more fine-grained control, you can set this per-thread: a_thread_instance.abort_on_exception = true.

It’s generally better practice to handle exceptions within each thread or use a robust error reporting mechanism.

Threads share global and instance variables. Local variables within a thread’s block are local to that thread. For thread-specific data that needs to be accessible by name, Ruby threads provide a hash-like interface using [] and []=: Thread.current[:my_key] = value.

#!/usr/bin/env ruby
shared_counter = 0
threads = []
5.times do |i|
threads << Thread.new do
# Using a thread-local variable to store an initial value
Thread.current[:initial_shared_value] = shared_counter
# This demonstrates a race condition if not synchronized for shared_counter itself
local_copy = shared_counter
sleep(rand * 0.01) # Small delay to increase chance of race condition
shared_counter = local_copy + 1
puts "Thread #{i} set shared_counter. Initial: #{Thread.current[:initial_shared_value]}, Final attempt: #{shared_counter}"
end
end
threads.each(&:join)
puts "Final shared_counter = #{shared_counter}" # Likely not 5 due to race condition

The example above illustrates thread-local storage but also highlights potential race conditions when multiple threads access shared mutable data without synchronization. The final shared_counter might not be 5.

Thread priority (thread.priority = value) can influence scheduling, but its behavior can be OS-dependent and is generally not a reliable way to control execution order. Relying on explicit synchronization mechanisms is preferred.

Thread Exclusion: Mutex and Synchronization

Section titled “Thread Exclusion: Mutex and Synchronization”

When multiple threads share and modify data, you need synchronization to prevent race conditions and ensure data consistency. Mutex (Mutual Exclusion) is a common tool for this. Only one thread can hold a mutex’s lock at a time.

#!/usr/bin/env ruby
require 'thread' # Not strictly needed for Mutex/ConditionVariable in modern Ruby
mutex = Mutex.new
safe_counter = 0
threads = []
5.times do |i|
threads << Thread.new do
1000.times do
# Critical section: only one thread can execute this at a time
mutex.synchronize do
safe_counter += 1
end
end
end
end
threads.each(&:join)
puts "Final safe_counter = #{safe_counter}" # Should be 5000

The mutex.synchronize block ensures that the code within it is executed atomically with respect to other threads using the same mutex.

Handling Deadlock and Condition Variables:

Section titled “Handling Deadlock and Condition Variables:”

Deadlock occurs when two or more threads are blocked forever, each waiting for a resource held by another. Careful lock ordering can prevent some deadlocks. ConditionVariable works with a Mutex to manage complex synchronization scenarios where threads need to wait for a certain condition to become true.

A thread can wait on a condition variable (cv.wait(mutex)), which atomically releases the mutex and puts the thread to sleep. Another thread, after changing the condition, can signal one waiting thread (cv.signal) or all waiting threads (cv.broadcast) to wake up. The awakened thread then reacquires the mutex.

#!/usr/bin/env ruby
require 'thread' # Not strictly needed for Mutex/ConditionVariable
mutex = Mutex.new
resource_available = ConditionVariable.new
item = nil
producer = Thread.new do
puts "Producer: Waiting to produce..."
sleep(1) # Simulate work
mutex.synchronize do
item = "Hello from producer!"
puts "Producer: Produced item. Signaling consumer."
resource_available.signal # Signal that the item is ready
end
end
consumer = Thread.new do
mutex.synchronize do
puts "Consumer: Waiting for item..."
while item.nil?
resource_available.wait(mutex) # Wait for producer's signal
end
puts "Consumer: Received item: '#{item}'"
item = nil # Consume the item
end
end
producer.join
consumer.join
puts "Finished."

A thread can be in various states. thread.status returns a string indicating its state ("run", "sleep", "aborting"), false if terminated normally, or nil if terminated by an unhandled exception.

Status ValueDescription
runThread is currently running or runnable.
sleepThread is sleeping or waiting on I/O or a synchronization primitive.
abortingThread is in the process of aborting.
false (Boolean)Thread has terminated normally.
nil (NilClass)Thread has terminated due to an unhandled exception.

Ruby 3.0 introduced Ractors (Ruby Actors), an actor-model concurrency mechanism designed to achieve true parallelism by allowing multiple Ractors to run Ruby code simultaneously on different CPU cores, without a GIL between them. Ractors communicate by message passing and have restrictions on sharing objects to ensure thread-safety. While a full discussion of Ractors is beyond this overview, they represent a significant advancement for CPU-bound parallel processing in Ruby.

The Thread class offers various class methods (e.g., Thread.list, Thread.stop) and instance methods (e.g., thr.join, thr.value, thr.kill). Refer to the official Ruby documentation for a complete list: https://ruby-doc.org/core/Thread.html

  • Threads in MRI are good for I/O-bound concurrency due to the GIL.
  • Use Mutex for synchronizing access to shared data.
  • Use ConditionVariable for complex coordination between threads.
  • Handle exceptions within threads or configure abort_on_exception carefully.
  • For CPU-bound parallelism in MRI, consider multiple processes or Ractors (Ruby 3.0+).
  • Alternative Ruby implementations (JRuby, TruffleRuby) offer true thread parallelism.