CrackedRuby logo

CrackedRuby

Thread Safety

A comprehensive guide to writing thread-safe Ruby code, covering synchronization primitives, concurrent data structures, and common concurrency patterns.

Concurrency and Parallelism Threading
6.1.2

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)