Overview
Thread safety in Ruby refers to code that functions correctly when accessed by multiple threads simultaneously. Ruby provides several mechanisms for writing thread-safe code, including mutexes, condition variables, atomic operations, and thread-safe data structures. The Global Interpreter Lock (GIL) in MRI Ruby prevents true parallelism for CPU-bound tasks but still requires careful synchronization for shared mutable state.
Ruby's threading model creates race conditions when multiple threads access and modify shared data without proper synchronization. A race condition occurs when the program's behavior depends on the relative timing of thread execution. Consider this unsafe counter example:
class UnsafeCounter
def initialize
@count = 0
end
def increment
@count += 1 # Not atomic: read, add, write
end
def value
@count
end
end
counter = UnsafeCounter.new
threads = 10.times.map do
Thread.new { 1000.times { counter.increment } }
end
threads.each(&:join)
puts counter.value # Often less than 10,000 due to race conditions
The Thread
class provides the foundation for concurrent execution. Ruby includes several synchronization primitives in the standard library: Mutex
for mutual exclusion, ConditionVariable
for thread coordination, Queue
and SizedQueue
for thread-safe communication, and Monitor
for reentrant locking.
Ruby also provides Concurrent::
classes through the concurrent-ruby gem, offering modern thread-safe data structures and synchronization tools. These include Concurrent::Hash
, Concurrent::Array
, Concurrent::Atom
, and various executor services.
The key principle in thread safety is controlling access to shared mutable state. Immutable objects are inherently thread-safe since they cannot change after creation. When mutation is necessary, synchronization ensures only one thread modifies data at a time.
Basic Usage
The Mutex
class provides the most common synchronization mechanism. A mutex ensures exclusive access to a critical section of code. Only one thread can hold a mutex lock at any time:
require 'thread'
class SafeCounter
def initialize
@count = 0
@mutex = Mutex.new
end
def increment
@mutex.synchronize do
@count += 1
end
end
def value
@mutex.synchronize do
@count
end
end
end
The synchronize
method acquires the lock, executes the block, and releases the lock even if an exception occurs. This pattern prevents race conditions by serializing access to the @count
variable.
Queue
provides thread-safe communication between threads. Producers push items while consumers pop them. The queue handles synchronization internally:
queue = Queue.new
producer = Thread.new do
5.times do |i|
queue << "item #{i}"
puts "Produced: item #{i}"
sleep(0.1)
end
end
consumer = Thread.new do
5.times do
item = queue.pop
puts "Consumed: #{item}"
end
end
[producer, consumer].each(&:join)
SizedQueue
extends Queue
with a maximum capacity. When full, producers block until space becomes available:
buffer = SizedQueue.new(2)
producer = Thread.new do
10.times do |i|
buffer << i
puts "Added #{i} to buffer"
end
end
consumer = Thread.new do
sleep(1)