Ruby Logo

Threads & Thread Safety

Master Ruby threading with Thread.new, Mutex synchronization, race conditions, and Global VM Lock (GVL) impact on performance.

Home Ruby Threads & Thread Safety

Threads & Thread Safety

Why Master Ruby Threading & Thread Safety?

Threading enables concurrent execution in Ruby applications, allowing you to handle multiple tasks simultaneously. Understanding thread safety is crucial for building robust, scalable applications that avoid race conditions and data corruption.

I/O Operations

Database queries, file operations, network requests

Background Processing

Background jobs, data processing, periodic tasks

Web Applications

Concurrent request handling, real-time features

Creating and Managing Threads

Ruby threads allow concurrent execution within a single process. Use Thread.new to create threads and various methods to manage their lifecycle and synchronization.

Basic Thread Creation and Management

class ThreadManager
  def self.basic_threading_demo
    puts "Main thread: #{Thread.current}"

    # Create a simple thread
    thread1 = Thread.new do
      5.times do |i|
        puts "Thread 1: Count #{i}"
        sleep(0.1)
      end
    end

    # Create another thread with parameters
    thread2 = Thread.new("Background task") do |task_name|
      3.times do |i|
        puts "#{task_name}: Processing #{i}"
        sleep(0.15)
      end
    end

    # Main thread continues execution
    puts "Main thread continues..."

    # Wait for threads to complete
    thread1.join
    thread2.join

    puts "All threads completed"
  end

  def self.thread_lifecycle_demo
    # Thread with return value
    calculator_thread = Thread.new do
      result = (1..1000).sum
      puts "Calculation completed in thread"
      result  # Thread return value
    end

    # Check thread status
    puts "Thread alive? #{calculator_thread.alive?}"
    puts "Thread status: #{calculator_thread.status}"

    # Get thread return value
    result = calculator_thread.value  # Blocks until thread completes
    puts "Thread result: #{result}"
    puts "Thread status after completion: #{calculator_thread.status}"
  end

  def self.thread_exceptions_demo
    # Thread exception handling
    problematic_thread = Thread.new do
      raise "Something went wrong!"
    end

    begin
      problematic_thread.join  # Exception propagates here
    rescue => e
      puts "Caught exception from thread: #{e.message}"
    end

    # Thread with internal exception handling
    safe_thread = Thread.new do
      begin
        risky_operation
      rescue => e
        puts "Exception handled within thread: #{e.message}"
      end
    end

    safe_thread.join
  end

  private

  def self.risky_operation
    raise "Internal error"
  end
end

# Demonstrate threading
ThreadManager.basic_threading_demo
puts "\n" + "="*50 + "\n"
ThreadManager.thread_lifecycle_demo
puts "\n" + "="*50 + "\n"
ThreadManager.thread_exceptions_demo

Thread Communication and Coordination

class ThreadCommunication
  def self.producer_consumer_demo
    queue = Queue.new

    # Producer thread
    producer = Thread.new do
      10.times do |i|
        item = "Item #{i}"
        queue << item
        puts "Produced: #{item}"
        sleep(0.1)
      end
      queue << :done  # Signal completion
    end

    # Consumer thread
    consumer = Thread.new do
      loop do
        item = queue.pop
        break if item == :done

        puts "Consumed: #{item}"
        sleep(0.15)  # Simulate processing time
      end
    end

    producer.join
    consumer.join
    puts "Producer-Consumer demo completed"
  end

  def self.thread_local_variables_demo
    # Thread-local variables
    Thread.main[:name] = "Main Thread"

    threads = 3.times.map do |i|
      Thread.new(i) do |thread_id|
        Thread.current[:name] = "Worker #{thread_id}"
        Thread.current[:processed] = 0

        5.times do |work_unit|
          Thread.current[:processed] += 1
          puts "#{Thread.current[:name]} processed unit #{work_unit}"
          sleep(0.05)
        end

        puts "#{Thread.current[:name]} completed #{Thread.current[:processed]} units"
      end
    end

    threads.each(&:join)
  end

  def self.thread_group_demo
    # Thread groups for organization
    group = ThreadGroup.new

    workers = 3.times.map do |i|
      thread = Thread.new(i) do |worker_id|
        puts "Worker #{worker_id} starting"
        sleep(1)
        puts "Worker #{worker_id} finished"
      end

      group.add(thread)
      thread
    end

    puts "Thread group has #{group.list.length} threads"

    # Wait for all threads in group
    workers.each(&:join)
  end
end

ThreadCommunication.producer_consumer_demo
puts "\n" + "="*40 + "\n"
ThreadCommunication.thread_local_variables_demo
puts "\n" + "="*40 + "\n"
ThreadCommunication.thread_group_demo

Race Conditions & Thread Safety

Race conditions occur when multiple threads access shared data simultaneously, leading to unpredictable results. Understanding and preventing race conditions is crucial for thread-safe programming.

Race Condition Examples

class RaceConditionDemo
  def self.unsafe_counter_demo
    counter = 0

    # Create multiple threads that increment the counter
    threads = 10.times.map do
      Thread.new do
        1000.times do
          # This is NOT thread-safe!
          temp = counter
          counter = temp + 1
        end
      end
    end

    threads.each(&:join)

    puts "Expected: 10000"
    puts "Actual: #{counter}"
    puts "Data race occurred!" if counter != 10000
  end

  def self.bank_account_race_demo
    class UnsafeBankAccount
      def initialize(balance = 0)
        @balance = balance
      end

      def balance
        @balance
      end

      def withdraw(amount)
        return false if @balance < amount

        # Simulate processing delay
        sleep(0.001)
        @balance -= amount
        true
      end

      def deposit(amount)
        sleep(0.001)
        @balance += amount
      end
    end

    account = UnsafeBankAccount.new(1000)

    # Multiple threads trying to withdraw
    threads = 5.times.map do
      Thread.new do
        10.times do
          account.withdraw(10) if account.balance >= 10
        end
      end
    end

    threads.each(&:join)

    puts "Final balance: #{account.balance}"
    puts "Expected: 500, but likely different due to race conditions"
  end

  def self.shared_array_demo
    shared_array = []

    threads = 5.times.map do |i|
      Thread.new(i) do |thread_id|
        100.times do |j|
          # Race condition: array modification is not atomic
          shared_array << "Thread-#{thread_id}-Item-#{j}"
        end
      end
    end

    threads.each(&:join)

    puts "Expected items: 500"
    puts "Actual items: #{shared_array.length}"
    puts "Some items may be lost due to race conditions"
  end
end

puts "=== Race Condition Demonstrations ==="
RaceConditionDemo.unsafe_counter_demo
puts "\n" + "-"*40 + "\n"
RaceConditionDemo.bank_account_race_demo
puts "\n" + "-"*40 + "\n"
RaceConditionDemo.shared_array_demo

⚠️ Common Race Condition Patterns

  • Read-Modify-Write: Multiple threads reading, modifying, and writing shared data
  • Check-Then-Act: Checking a condition then acting on it (condition may change)
  • Non-atomic operations: Operations that appear atomic but aren't (like ++ in other languages)
  • Shared collections: Multiple threads modifying arrays, hashes, or other collections

Mutex & Synchronization Primitives

Mutex (Mutual Exclusion) is the primary synchronization primitive in Ruby. It ensures that only one thread can access a critical section at a time, preventing race conditions.

Thread-Safe Programming with Mutex

class ThreadSafeExamples
  def self.safe_counter_demo
    counter = 0
    mutex = Mutex.new

    threads = 10.times.map do
      Thread.new do
        1000.times do
          mutex.synchronize do
            # Critical section - only one thread at a time
            temp = counter
            counter = temp + 1
          end
        end
      end
    end

    threads.each(&:join)

    puts "Thread-safe counter result: #{counter}"
    puts "Should always be 10000"
  end

  def self.safe_bank_account_demo
    class ThreadSafeBankAccount
      def initialize(balance = 0)
        @balance = balance
        @mutex = Mutex.new
      end

      def balance
        @mutex.synchronize { @balance }
      end

      def withdraw(amount)
        @mutex.synchronize do
          return false if @balance < amount

          sleep(0.001)  # Simulate processing delay
          @balance -= amount
          true
        end
      end

      def deposit(amount)
        @mutex.synchronize do
          sleep(0.001)
          @balance += amount
        end
      end

      def transfer(other_account, amount)
        # Avoid deadlock by always acquiring locks in same order
        first_lock, second_lock = [@mutex, other_account.instance_variable_get(:@mutex)].sort_by(&:object_id)

        first_lock.synchronize do
          second_lock.synchronize do
            if @balance >= amount
              @balance -= amount
              other_account.instance_variable_set(:@balance,
                other_account.instance_variable_get(:@balance) + amount)
              true
            else
              false
            end
          end
        end
      end
    end

    account1 = ThreadSafeBankAccount.new(1000)
    account2 = ThreadSafeBankAccount.new(1000)

    # Multiple threads performing operations
    threads = []

    # Withdrawal threads
    threads += 5.times.map do
      Thread.new do
        10.times do
          account1.withdraw(10)
        end
      end
    end

    # Transfer threads
    threads += 3.times.map do
      Thread.new do
        5.times do
          account1.transfer(account2, 20)
        end
      end
    end

    threads.each(&:join)

    puts "Account 1 balance: #{account1.balance}"
    puts "Account 2 balance: #{account2.balance}"
    puts "Total: #{account1.balance + account2.balance}"
  end
end

ThreadSafeExamples.safe_counter_demo
puts "\n" + "-"*40 + "\n"
ThreadSafeExamples.safe_bank_account_demo

Advanced Synchronization Patterns

class AdvancedSynchronization
  def self.condition_variable_demo
    require 'thread'

    mutex = Mutex.new
    condition = ConditionVariable.new
    items = []
    finished = false

    # Producer thread
    producer = Thread.new do
      10.times do |i|
        mutex.synchronize do
          items << "Item #{i}"
          puts "Produced: Item #{i}"
          condition.signal  # Wake up waiting consumer
        end
        sleep(0.1)
      end

      mutex.synchronize do
        finished = true
        condition.broadcast  # Wake up all waiting threads
      end
    end

    # Consumer threads
    consumers = 2.times.map do |consumer_id|
      Thread.new do
        loop do
          item = nil

          mutex.synchronize do
            # Wait until there's an item or production is finished
            while items.empty? && !finished
              condition.wait(mutex)
            end

            break if items.empty? && finished

            item = items.shift
          end

          if item
            puts "Consumer #{consumer_id} consumed: #{item}"
            sleep(0.15)  # Simulate processing
          end
        end
      end
    end

    producer.join
    consumers.each(&:join)
    puts "Producer-Consumer with ConditionVariable completed"
  end

  def self.thread_pool_demo
    class SimpleThreadPool
      def initialize(size)
        @size = size
        @jobs = Queue.new
        @pool = Array.new(size) do |i|
          Thread.new do
            Thread.current[:id] = i
            loop do
              job = @jobs.pop
              break if job == :shutdown

              begin
                job.call
              rescue => e
                puts "Error in thread #{Thread.current[:id]}: #{e.message}"
              end
            end
          end
        end
      end

      def schedule(&block)
        @jobs << block
      end

      def shutdown
        @size.times { @jobs << :shutdown }
        @pool.each(&:join)
      end
    end

    pool = SimpleThreadPool.new(3)

    # Schedule work
    10.times do |i|
      pool.schedule do
        puts "Job #{i} executed by thread #{Thread.current[:id]}"
        sleep(0.1)
      end
    end

    # Wait a bit then shutdown
    sleep(2)
    pool.shutdown
    puts "Thread pool demo completed"
  end

  def self.read_write_lock_demo
    # Simple read-write lock implementation
    class ReadWriteLock
      def initialize
        @readers = 0
        @writer = false
        @mutex = Mutex.new
        @reader_cv = ConditionVariable.new
        @writer_cv = ConditionVariable.new
      end

      def read_lock
        @mutex.synchronize do
          while @writer
            @reader_cv.wait(@mutex)
          end
          @readers += 1
        end
      end

      def read_unlock
        @mutex.synchronize do
          @readers -= 1
          @writer_cv.signal if @readers == 0
        end
      end

      def write_lock
        @mutex.synchronize do
          while @readers > 0 || @writer
            @writer_cv.wait(@mutex)
          end
          @writer = true
        end
      end

      def write_unlock
        @mutex.synchronize do
          @writer = false
          @reader_cv.broadcast
          @writer_cv.signal
        end
      end

      def with_read_lock
        read_lock
        yield
      ensure
        read_unlock
      end

      def with_write_lock
        write_lock
        yield
      ensure
        write_unlock
      end
    end

    data = { counter: 0 }
    rw_lock = ReadWriteLock.new

    # Reader threads
    readers = 5.times.map do |i|
      Thread.new do
        10.times do
          rw_lock.with_read_lock do
            puts "Reader #{i}: counter = #{data[:counter]}"
            sleep(0.01)
          end
        end
      end
    end

    # Writer threads
    writers = 2.times.map do |i|
      Thread.new do
        5.times do
          rw_lock.with_write_lock do
            data[:counter] += 1
            puts "Writer #{i}: incremented counter to #{data[:counter]}"
            sleep(0.02)
          end
        end
      end
    end

    (readers + writers).each(&:join)
    puts "Read-Write lock demo completed"
  end
end

AdvancedSynchronization.condition_variable_demo
puts "\n" + "="*50 + "\n"
AdvancedSynchronization.thread_pool_demo
puts "\n" + "="*50 + "\n"
AdvancedSynchronization.read_write_lock_demo

Global VM Lock (GVL) & Ruby Threading Model

Ruby's Global VM Lock (GVL) prevents true parallelism for CPU-bound tasks but allows concurrency for I/O-bound operations. Understanding the GVL is crucial for effective Ruby threading.

GVL Impact Demonstration

require 'benchmark'

class GVLDemo
  def self.cpu_bound_task
    # Simulate CPU-intensive work
    1_000_000.times { Math.sqrt(rand(1000)) }
  end

  def self.io_bound_task
    # Simulate I/O-bound work
    sleep(0.1)
  end

  def self.cpu_bound_comparison
    puts "=== CPU-bound Task Comparison ==="

    # Single-threaded execution
    single_threaded_time = Benchmark.realtime do
      4.times { cpu_bound_task }
    end

    # Multi-threaded execution
    multi_threaded_time = Benchmark.realtime do
      threads = 4.times.map do
        Thread.new { cpu_bound_task }
      end
      threads.each(&:join)
    end

    puts "Single-threaded: #{single_threaded_time.round(3)}s"
    puts "Multi-threaded:  #{multi_threaded_time.round(3)}s"
    puts "Speedup: #{(single_threaded_time / multi_threaded_time).round(2)}x"
    puts "Note: Speedup is minimal due to GVL limiting CPU parallelism"
  end

  def self.io_bound_comparison
    puts "\n=== I/O-bound Task Comparison ==="

    # Single-threaded execution
    single_threaded_time = Benchmark.realtime do
      4.times { io_bound_task }
    end

    # Multi-threaded execution
    multi_threaded_time = Benchmark.realtime do
      threads = 4.times.map do
        Thread.new { io_bound_task }
      end
      threads.each(&:join)
    end

    puts "Single-threaded: #{single_threaded_time.round(3)}s"
    puts "Multi-threaded:  #{multi_threaded_time.round(3)}s"
    puts "Speedup: #{(single_threaded_time / multi_threaded_time).round(2)}x"
    puts "Note: Significant speedup due to GVL release during I/O"
  end

  def self.gvl_release_demo
    puts "\n=== GVL Release Points ==="

    start_time = Time.now

    threads = 3.times.map do |i|
      Thread.new do
        puts "Thread #{i} started at #{(Time.now - start_time).round(3)}s"

        # GVL is released during sleep
        sleep(0.1)
        puts "Thread #{i} after sleep at #{(Time.now - start_time).round(3)}s"

        # GVL is held during computation
        1000.times { Math.sqrt(rand(100)) }
        puts "Thread #{i} finished at #{(Time.now - start_time).round(3)}s"
      end
    end

    threads.each(&:join)
  end

  def self.thread_switching_demo
    puts "\n=== Thread Switching Behavior ==="

    mutex = Mutex.new

    threads = 5.times.map do |i|
      Thread.new do
        10.times do |j|
          mutex.synchronize do
            print "#{i}"
            # Small delay to encourage thread switching
            sleep(0.001)
          end
        end
      end
    end

    threads.each(&:join)
    puts "\nNote: Output shows thread interleaving despite GVL"
  end
end

GVLDemo.cpu_bound_comparison
GVLDemo.io_bound_comparison
GVLDemo.gvl_release_demo
GVLDemo.thread_switching_demo

💡 GVL Key Points

  • CPU-bound tasks: Limited by GVL - consider processes or native extensions
  • I/O-bound tasks: GVL is released - threading provides real concurrency benefits
  • C extensions: Can release GVL for true parallelism
  • Alternative implementations: JRuby and TruffleRuby don't have GVL

Threading Best Practices & Patterns

Thread-Safe Patterns and Anti-Patterns

class ThreadingBestPractices
  # ✅ GOOD: Thread-safe singleton pattern
  class ThreadSafeSingleton
    @instance_mutex = Mutex.new

    def self.instance
      return @instance if @instance

      @instance_mutex.synchronize do
        @instance ||= new
      end
    end

    private_class_method :new
  end

  # ✅ GOOD: Immutable objects are thread-safe
  class ImmutableCounter
    def initialize(value = 0)
      @value = value.freeze
    end

    def increment
      self.class.new(@value + 1)
    end

    def value
      @value
    end
  end

  # ✅ GOOD: Thread-local storage
  class ThreadLocalCache
    def self.get(key)
      cache = Thread.current[:cache] ||= {}
      cache[key]
    end

    def self.set(key, value)
      cache = Thread.current[:cache] ||= {}
      cache[key] = value
    end
  end

  # ❌ BAD: Double-checked locking (broken in Ruby)
  class BrokenSingleton
    def self.instance
      return @instance if @instance  # Not thread-safe!

      @mutex.synchronize do
        @instance ||= new  # Race condition possible
      end
    end
  end

  # ✅ GOOD: Proper resource cleanup
  class ResourceManager
    def initialize
      @resources = {}
      @mutex = Mutex.new
    end

    def acquire_resource(id)
      @mutex.synchronize do
        @resources[id] ||= expensive_resource_creation(id)
      end
    end

    def release_resource(id)
      @mutex.synchronize do
        resource = @resources.delete(id)
        resource&.cleanup
      end
    end

    def cleanup_all
      @mutex.synchronize do
        @resources.each_value(&:cleanup)
        @resources.clear
      end
    end

    private

    def expensive_resource_creation(id)
      OpenStruct.new(id: id, cleanup: -> { puts "Cleaning up resource #{id}" })
    end
  end

  def self.demonstrate_patterns
    # Thread-safe singleton
    singleton1 = ThreadSafeSingleton.instance
    singleton2 = ThreadSafeSingleton.instance
    puts "Singleton works: #{singleton1.object_id == singleton2.object_id}"

    # Immutable counter
    counter = ImmutableCounter.new
    new_counter = counter.increment
    puts "Original: #{counter.value}, New: #{new_counter.value}"

    # Thread-local cache
    threads = 3.times.map do |i|
      Thread.new do
        ThreadLocalCache.set(:id, i)
        sleep(0.1)
        puts "Thread #{i} cached value: #{ThreadLocalCache.get(:id)}"
      end
    end
    threads.each(&:join)

    # Resource management
    manager = ResourceManager.new
    resource = manager.acquire_resource(:db_connection)
    manager.release_resource(:db_connection)
  end
end

ThreadingBestPractices.demonstrate_patterns

✅ Threading Best Practices

  • Minimize shared state: Use immutable objects and message passing
  • Use higher-level abstractions: Queue, ThreadPool, concurrent-ruby gems
  • Always use synchronization: for shared mutable state
  • Avoid nested locks: to prevent deadlocks
  • Handle exceptions: in threads to prevent silent failures
  • Use thread-local storage: for per-thread state
  • Profile and test: threading code thoroughly

❌ Common Threading Mistakes

  • Unprotected shared state: Always synchronize access to mutable shared data
  • Deadlocks: Acquire locks in consistent order, use timeouts
  • Resource leaks: Always clean up threads and resources
  • Ignoring exceptions: Handle exceptions in threads explicitly
  • Over-threading: More threads ≠ better performance

Real-world Threading Applications

Web Servers

Handle multiple HTTP requests concurrently using thread pools or thread-per-request models.

# Puma, Thin, WEBrick
config.threads 5, 15

Background Jobs

Process jobs concurrently using worker threads for I/O-bound tasks like email sending or API calls.

# Sidekiq, DelayedJob
worker_threads = 10

Database Connection Pools

Manage concurrent database connections efficiently with thread-safe connection pooling.

# ActiveRecord
pool: 15
timeout: 5000

Quick Navigation

Related Topics

Video Tutorial

Watch and learn threads & thread safety

Pro Tip: After reading through the content above, watch this video to reinforce your understanding and see the concepts in action!