CrackedRuby CrackedRuby

Overview

Mutexes (mutual exclusion locks) and locks are synchronization primitives that coordinate access to shared resources in concurrent programs. When multiple threads execute simultaneously, they often need to access shared data structures or resources. Without synchronization, concurrent access leads to race conditions where the final state depends on unpredictable thread interleaving. Mutexes prevent this by ensuring only one thread accesses a critical section at a time.

A critical section is a code region that accesses shared mutable state. The mutex acts as a gatekeeper: a thread must acquire the lock before entering the critical section and release it upon exit. Other threads attempting to acquire the same lock will block until the lock becomes available. This mechanism transforms potentially concurrent operations into a serialized sequence, preserving data integrity.

The concept originated from Edsger Dijkstra's semaphore work in the 1960s. Modern operating systems provide mutex implementations as kernel primitives, while programming languages expose them through standard libraries. The name "mutex" combines "mutual" and "exclusion" to describe its fundamental property: mutual exclusion of critical sections.

# Without mutex: race condition
counter = 0
threads = 10.times.map do
  Thread.new { 1000.times { counter += 1 } }
end
threads.each(&:join)
puts counter  # => unpredictable result, likely less than 10000

# With mutex: safe concurrent access
counter = 0
mutex = Mutex.new
threads = 10.times.map do
  Thread.new { 1000.times { mutex.synchronize { counter += 1 } } }
end
threads.each(&:join)
puts counter  # => 10000

Locks extend beyond simple mutexes to include reader-writer locks (allowing concurrent reads but exclusive writes), recursive locks (allowing the same thread to acquire multiple times), and condition variables (enabling threads to wait for specific conditions). Each variant addresses specific synchronization patterns found in concurrent systems.

Key Principles

Mutual Exclusion represents the core guarantee of locks: at most one thread holds the lock at any time. When thread A holds a lock, thread B attempting to acquire the same lock must wait. This serializes access to protected resources, preventing simultaneous modifications that would corrupt shared state. The mutual exclusion property transforms a concurrent system into one where critical sections execute atomically from the perspective of other threads.

Critical Section defines the code region protected by a lock. The critical section should be minimal, containing only operations that must execute atomically. Expanding critical sections reduces concurrency and creates contention. Shrinking them too much leaves operations unprotected. The principle: include operations that access shared mutable state, exclude independent computations.

# Bad: critical section too large
mutex.synchronize do
  value = shared_data.fetch(key)  # needs protection
  result = expensive_computation(value)  # independent, shouldn't be locked
  shared_data[key] = result  # needs protection
end

# Good: minimal critical section
value = mutex.synchronize { shared_data.fetch(key) }
result = expensive_computation(value)
mutex.synchronize { shared_data[key] = result }

Atomicity means operations within a critical section appear instantaneous to other threads. Even though the critical section takes time to execute, no other thread observes intermediate states. If thread A reads and modifies a variable inside a critical section, thread B either sees the state before A's modifications or after them, never partially modified values. Atomicity derives from mutual exclusion but applies at the logical level of operations.

Lock States define a mutex lifecycle. A lock exists in one of two states: unlocked (available) or locked (held by exactly one thread). State transitions occur through acquire and release operations. An unlocked lock transitions to locked when a thread successfully acquires it. A locked lock transitions to unlocked when the holding thread releases it. Attempting to acquire a locked lock blocks the requesting thread until the lock becomes unlocked.

Fairness addresses lock acquisition order when multiple threads wait. An unfair lock provides no guarantees about which waiting thread acquires the lock next. A fair lock typically uses FIFO (first-in-first-out) ordering, granting the lock to the longest-waiting thread. Unfair locks offer better performance but risk starvation where some threads never acquire the lock. Fair locks ensure eventual progress for all threads at the cost of additional bookkeeping overhead.

Reentrancy determines whether a thread holding a lock can acquire it again. A non-reentrant (standard) mutex deadlocks if the same thread tries to acquire it twice. A reentrant mutex maintains an acquisition count, allowing the same thread multiple acquisitions. Each acquisition increments the count; the lock releases only when the count returns to zero. Reentrancy simplifies recursive functions but adds complexity to the lock implementation.

Deadlock occurs when threads wait circularly for resources held by each other. Thread A holds lock X and waits for lock Y, while thread B holds lock Y and waits for lock X. Neither thread can proceed. Deadlock requires four conditions: mutual exclusion, hold and wait, no preemption, and circular wait. Breaking any condition prevents deadlock. Common prevention strategies include lock ordering (always acquire locks in consistent order) and timeout mechanisms.

# Deadlock scenario
lock1 = Mutex.new
lock2 = Mutex.new

thread_a = Thread.new do
  lock1.synchronize do
    sleep 0.1
    lock2.synchronize { puts "A got both" }
  end
end

thread_b = Thread.new do
  lock2.synchronize do
    sleep 0.1
    lock1.synchronize { puts "B got both" }
  end
end

# Both threads block forever

Livelock describes a scenario where threads actively change states but make no progress. Unlike deadlock where threads block, livelock has threads running but repeatedly encountering the same conflict. Example: two threads detect a deadlock condition and both release locks, then both attempt reacquisition in the same order, repeating indefinitely. Livelock often results from naive deadlock recovery mechanisms.

Priority Inversion happens when a high-priority thread waits for a lock held by a low-priority thread, while a medium-priority thread prevents the low-priority thread from completing. The high-priority thread effectively inherits the low-priority thread's priority. Operating systems address this through priority inheritance protocols, temporarily boosting the low-priority thread's priority while it holds the lock.

Ruby Implementation

Ruby provides several synchronization primitives through the Thread module and standard library. The Mutex class implements basic mutual exclusion locks. The Monitor module extends Mutex with condition variables for complex synchronization patterns. Thread-safe data structures like Queue offer built-in synchronization without manual locking.

Mutex Class provides the fundamental locking primitive. Creating a mutex allocates an unlocked lock. The lock method acquires the mutex, blocking until available. The unlock method releases it. The synchronize method combines both: it acquires the lock, executes a block, and releases the lock even if exceptions occur. Always prefer synchronize over manual lock/unlock pairs to prevent leaked locks from exceptions.

require 'thread'

mutex = Mutex.new

# Manual lock/unlock (risky)
mutex.lock
begin
  # critical section
ensure
  mutex.unlock
end

# Preferred: automatic cleanup
mutex.synchronize do
  # critical section
end

The Mutex class includes query methods: locked? returns true if any thread holds the lock (including the current thread), owned? returns true if the current thread holds the lock. These methods help debug lock state but should not drive synchronization logic since their results can immediately become stale.

mutex = Mutex.new

thread = Thread.new do
  mutex.synchronize do
    puts mutex.locked?  # => true
    puts mutex.owned?   # => true
    sleep 1
  end
end

sleep 0.1
puts mutex.locked?  # => true
puts mutex.owned?   # => false (different thread owns it)
thread.join

Try-Lock Pattern uses try_lock for non-blocking lock acquisition. The try_lock method attempts to acquire the lock immediately. If successful, it returns true and the thread now owns the lock (requiring subsequent unlock). If the lock is held by another thread, try_lock returns false immediately without blocking. This pattern enables timeout mechanisms and alternate code paths when locks are unavailable.

mutex = Mutex.new

def update_cache_if_available(mutex, cache, key, value)
  if mutex.try_lock
    begin
      cache[key] = value
      true
    ensure
      mutex.unlock
    end
  else
    false  # cache update skipped, lock busy
  end
end

Monitor Module extends Mutex with condition variables for complex synchronization. A monitor combines a mutex with the ability for threads to wait for specific conditions. Threads call wait_while or wait_until to block until a condition changes. Other threads call signal or broadcast to wake waiting threads. This pattern implements producer-consumer queues and similar coordination problems.

require 'monitor'

class BoundedBuffer
  def initialize(capacity)
    @buffer = []
    @capacity = capacity
    @monitor = Monitor.new
    @not_full = @monitor.new_cond
    @not_empty = @monitor.new_cond
  end

  def put(item)
    @monitor.synchronize do
      @not_full.wait_while { @buffer.size >= @capacity }
      @buffer << item
      @not_empty.signal
    end
  end

  def take
    @monitor.synchronize do
      @not_empty.wait_while { @buffer.empty? }
      item = @buffer.shift
      @not_full.signal
      item
    end
  end
end

Queue Class provides thread-safe FIFO queues with built-in synchronization. The Queue class handles all locking internally. The push method (also aliased as enq and <<) adds items. The pop method (also aliased as deq and shift) removes items, blocking if the queue is empty. SizedQueue adds capacity limits, blocking pushes when full. These classes eliminate manual mutex management for common producer-consumer patterns.

require 'thread'

queue = Queue.new

# Producer threads
producers = 5.times.map do |i|
  Thread.new do
    10.times { |j| queue << "item-#{i}-#{j}" }
  end
end

# Consumer threads
consumers = 3.times.map do
  Thread.new do
    loop do
      item = queue.pop
      break if item == :done
      process(item)
    end
  end
end

producers.each(&:join)
consumers.size.times { queue << :done }
consumers.each(&:join)

Thread-Safe Data Structures provide synchronized access without explicit locks. Concurrent-ruby gem offers thread-safe collections: ConcurrentArray, ConcurrentHash, and atomic variables. These structures use fine-grained internal locking or lock-free algorithms. Using these specialized structures outperforms wrapping standard collections with mutexes.

require 'concurrent'

# Atomic integer for counters
counter = Concurrent::AtomicFixnum.new(0)
threads = 10.times.map do
  Thread.new { 1000.times { counter.increment } }
end
threads.each(&:join)
puts counter.value  # => 10000

# Thread-safe hash
cache = Concurrent::Map.new
threads = 100.times.map do |i|
  Thread.new { cache[i] = expensive_computation(i) }
end
threads.each(&:join)

Ruby's Global Interpreter Lock (GIL) affects mutex behavior. The GIL prevents multiple Ruby threads from executing Ruby code simultaneously in MRI Ruby. However, mutexes remain necessary because the GIL can be released during blocking operations, and thread context switches can occur at any point. Other Ruby implementations like JRuby and TruffleRuby have no GIL, making mutexes essential for thread safety.

Common Patterns

Lock Ordering prevents deadlock by establishing a global lock acquisition order. All threads acquire locks in the same sequence, breaking the circular wait condition required for deadlock. Assign locks unique identifiers or ordering keys. Before acquiring multiple locks, sort them by identifier. Always acquire locks in sorted order, release in any order.

class BankAccount
  attr_reader :id, :balance
  
  def initialize(id, balance)
    @id = id
    @balance = balance
    @mutex = Mutex.new
  end
  
  def lock(&block)
    @mutex.synchronize(&block)
  end
end

def transfer(from, to, amount)
  # Order locks by account ID to prevent deadlock
  first, second = [from, to].sort_by(&:id)
  
  first.lock do
    second.lock do
      return false if from.balance < amount
      from.balance -= amount
      to.balance += amount
      true
    end
  end
end

Try-Lock with Backoff attempts lock acquisition without blocking, implementing retry logic with delays. The thread tries to acquire a lock using try_lock. On failure, it backs off (sleeps) before retrying. Exponential backoff increases delay on successive failures. This pattern reduces contention and prevents livelock in lock-ordering scenarios.

def try_transfer_with_backoff(from, to, amount, max_attempts = 5)
  delay = 0.001
  
  max_attempts.times do |attempt|
    first, second = [from, to].sort_by(&:id)
    
    if first.lock.try_lock
      begin
        if second.lock.try_lock
          begin
            return transfer_impl(from, to, amount)
          ensure
            second.lock.unlock
          end
        end
      ensure
        first.lock.unlock
      end
    end
    
    sleep(delay)
    delay *= 2  # exponential backoff
  end
  
  raise "Transfer failed after #{max_attempts} attempts"
end

Double-Checked Locking optimizes initialization by checking conditions before and after acquiring locks. The pattern first checks a condition without holding a lock. If the condition suggests work is needed, acquire the lock and check again (the condition might have changed while acquiring the lock). This pattern reduces lock contention for one-time initialization.

class LazyResource
  def initialize
    @resource = nil
    @mutex = Mutex.new
  end
  
  def get_resource
    # First check without lock (fast path)
    return @resource if @resource
    
    # Acquire lock for initialization
    @mutex.synchronize do
      # Double-check after acquiring lock
      @resource ||= expensive_resource_creation
    end
  end
  
  private
  
  def expensive_resource_creation
    # Expensive initialization
    sleep 1
    "resource"
  end
end

Double-checked locking has subtle issues in some languages due to memory model concerns, but Ruby's GIL makes it safe in MRI. The pattern remains valid in JRuby and TruffleRuby when accessing atomic references.

Reader-Writer Lock Pattern allows concurrent readers but exclusive writers. Multiple threads can simultaneously hold read locks, but write locks are exclusive. Ruby lacks built-in reader-writer locks, but the pattern can be implemented using monitors.

require 'monitor'

class ReadWriteLock
  def initialize
    @monitor = Monitor.new
    @readers = 0
    @writer = false
    @reader_cond = @monitor.new_cond
    @writer_cond = @monitor.new_cond
  end
  
  def read_lock
    @monitor.synchronize do
      @reader_cond.wait_while { @writer }
      @readers += 1
    end
  end
  
  def read_unlock
    @monitor.synchronize do
      @readers -= 1
      @writer_cond.signal if @readers == 0
    end
  end
  
  def write_lock
    @monitor.synchronize do
      @writer_cond.wait_while { @writer || @readers > 0 }
      @writer = true
    end
  end
  
  def write_unlock
    @monitor.synchronize do
      @writer = false
      @reader_cond.broadcast
      @writer_cond.signal
    end
  end
end

Lock-Free Patterns use atomic operations instead of locks. Atomic compare-and-swap (CAS) operations enable lock-free data structures. Ruby's concurrent-ruby gem provides atomic primitives. Lock-free code avoids deadlock and often offers better scalability but increases complexity.

require 'concurrent'

class LockFreeStack
  def initialize
    @head = Concurrent::AtomicReference.new(nil)
  end
  
  def push(value)
    loop do
      old_head = @head.get
      new_node = Node.new(value, old_head)
      return if @head.compare_and_set(old_head, new_node)
    end
  end
  
  def pop
    loop do
      old_head = @head.get
      return nil unless old_head
      return old_head.value if @head.compare_and_set(old_head, old_head.next)
    end
  end
  
  Node = Struct.new(:value, :next)
end

Scoped Locking uses code blocks to ensure lock release. Ruby's synchronize method implements this pattern. The block ensures unlock even if exceptions occur. Define custom synchronized methods that yield to blocks, making caller code cleaner and preventing lock leaks.

class ThreadSafeCollection
  def initialize
    @items = []
    @mutex = Mutex.new
  end
  
  def add(item)
    @mutex.synchronize { @items << item }
  end
  
  def transaction
    @mutex.synchronize { yield @items }
  end
end

collection = ThreadSafeCollection.new

# Safe multi-operation transaction
collection.transaction do |items|
  items.clear
  items.push(*new_items)
end

Practical Examples

Thread-Safe Counter demonstrates basic mutex usage. Multiple threads increment a shared counter. Without synchronization, final counts fall short due to lost updates. The mutex ensures each increment operation completes atomically.

class Counter
  def initialize
    @value = 0
    @mutex = Mutex.new
  end
  
  def increment
    @mutex.synchronize { @value += 1 }
  end
  
  def value
    @mutex.synchronize { @value }
  end
end

counter = Counter.new
threads = 100.times.map do
  Thread.new { 1000.times { counter.increment } }
end

threads.each(&:join)
puts counter.value  # => 100000

Cache with Expiration implements a thread-safe LRU cache with time-based expiration. The cache uses a mutex to protect the internal hash and linked list structure. The synchronize block encompasses both lookup and update operations to maintain consistency.

require 'time'

class ExpiringCache
  Entry = Struct.new(:value, :expires_at)
  
  def initialize(ttl_seconds)
    @cache = {}
    @ttl = ttl_seconds
    @mutex = Mutex.new
  end
  
  def get(key)
    @mutex.synchronize do
      entry = @cache[key]
      return nil unless entry
      
      if Time.now > entry.expires_at
        @cache.delete(key)
        return nil
      end
      
      entry.value
    end
  end
  
  def set(key, value)
    @mutex.synchronize do
      @cache[key] = Entry.new(value, Time.now + @ttl)
    end
  end
  
  def cleanup
    @mutex.synchronize do
      now = Time.now
      @cache.delete_if { |_, entry| now > entry.expires_at }
    end
  end
end

Connection Pool manages a fixed set of reusable connections. Threads acquire connections from the pool, perform work, and return connections. The pool uses a mutex and condition variable to coordinate access. Threads wait when the pool is empty.

require 'monitor'

class ConnectionPool
  def initialize(size)
    @connections = Array.new(size) { create_connection }
    @monitor = Monitor.new
    @available = @monitor.new_cond
  end
  
  def with_connection
    conn = acquire
    begin
      yield conn
    ensure
      release(conn)
    end
  end
  
  private
  
  def acquire
    @monitor.synchronize do
      @available.wait_while { @connections.empty? }
      @connections.pop
    end
  end
  
  def release(conn)
    @monitor.synchronize do
      @connections.push(conn)
      @available.signal
    end
  end
  
  def create_connection
    # Create actual connection (database, HTTP, etc.)
    Object.new
  end
end

pool = ConnectionPool.new(5)

threads = 20.times.map do
  Thread.new do
    pool.with_connection do |conn|
      # Use connection
      sleep(rand * 0.1)
    end
  end
end

threads.each(&:join)

Rate Limiter controls request throughput using a token bucket algorithm. The limiter grants permits at a fixed rate. Threads wait for available permits before proceeding. This example demonstrates monitors for coordination between token generation and consumption.

require 'monitor'

class RateLimiter
  def initialize(rate_per_second)
    @rate = rate_per_second
    @tokens = rate_per_second.to_f
    @max_tokens = rate_per_second.to_f
    @last_refill = Time.now
    @monitor = Monitor.new
    @available = @monitor.new_cond
  end
  
  def acquire(permits = 1)
    @monitor.synchronize do
      loop do
        refill_tokens
        
        if @tokens >= permits
          @tokens -= permits
          return
        end
        
        @available.wait
      end
    end
  end
  
  private
  
  def refill_tokens
    now = Time.now
    elapsed = now - @last_refill
    new_tokens = elapsed * @rate
    
    if new_tokens > 0
      @tokens = [@tokens + new_tokens, @max_tokens].min
      @last_refill = now
      @available.broadcast if @tokens > 0
    end
  end
end

limiter = RateLimiter.new(10)  # 10 requests per second

threads = 50.times.map do |i|
  Thread.new do
    limiter.acquire
    puts "Request #{i} at #{Time.now.strftime('%H:%M:%S.%L')}"
  end
end

threads.each(&:join)

Producer-Consumer with Queue demonstrates decoupled thread communication. Producers generate work items and push them to a queue. Consumers pull items and process them. The Queue class handles all synchronization internally.

require 'thread'

class WorkQueue
  def initialize(num_workers)
    @queue = Queue.new
    @workers = num_workers.times.map { create_worker }
  end
  
  def enqueue(item)
    @queue << item
  end
  
  def shutdown
    @workers.size.times { @queue << :shutdown }
    @workers.each(&:join)
  end
  
  private
  
  def create_worker
    Thread.new do
      loop do
        item = @queue.pop
        break if item == :shutdown
        process_item(item)
      end
    end
  end
  
  def process_item(item)
    # Simulate work
    sleep(rand * 0.1)
    puts "Processed: #{item}"
  end
end

work_queue = WorkQueue.new(4)

# Producer
producer = Thread.new do
  100.times { |i| work_queue.enqueue("task-#{i}") }
end

producer.join
work_queue.shutdown

Common Pitfalls

Deadlock from Lock Ordering Violations occurs when threads acquire locks in inconsistent orders. Thread A acquires lock 1 then lock 2, while thread B acquires lock 2 then lock 1. Both threads block indefinitely. The fix requires establishing a global lock order and adhering to it consistently across all code paths.

# Problematic code
def transfer_bad(from, to, amount)
  from.lock do
    to.lock do  # lock order depends on call order
      from.balance -= amount
      to.balance += amount
    end
  end
end

# Calling from different threads creates deadlock
Thread.new { transfer_bad(account_a, account_b, 100) }
Thread.new { transfer_bad(account_b, account_a, 50) }

# Fix: consistent lock ordering
def transfer_good(from, to, amount)
  first, second = [from, to].sort_by(&:id)
  first.lock do
    second.lock do
      from.balance -= amount
      to.balance += amount
    end
  end
end

Lost Wakeups happen when signals occur before threads wait. A producer signals an empty queue, then consumers arrive and wait. The signal was lost; consumers block forever. The fix requires checking conditions before waiting and using while loops instead of if statements for condition variables.

# Problematic code
producer_thread = Thread.new do
  @monitor.synchronize do
    @queue << item
    @condition.signal  # signal sent
  end
end

sleep 0.1  # producer completes

consumer_thread = Thread.new do
  @monitor.synchronize do
    @condition.wait if @queue.empty?  # signal already sent, waits forever
    @queue.pop
  end
end

# Fix: always use wait_while
consumer_thread = Thread.new do
  @monitor.synchronize do
    @condition.wait_while { @queue.empty? }  # checks condition even if signal missed
    @queue.pop
  end
end

Nested Lock Exceptions occur when trying to acquire the same non-reentrant mutex twice from the same thread. Ruby's Mutex raises ThreadError on reentrant acquisition. This commonly happens in recursive methods or callback chains. The fix requires avoiding nested acquisition or using Monitor instead of Mutex.

# Problematic code
class Account
  def initialize
    @balance = 0
    @mutex = Mutex.new
  end
  
  def deposit(amount)
    @mutex.synchronize do
      @balance += amount
      log_transaction(amount)  # calls synchronized method
    end
  end
  
  def log_transaction(amount)
    @mutex.synchronize do  # ThreadError: deadlock; recursive locking
      puts "Deposited #{amount}"
    end
  end
end

# Fix: use Monitor for reentrant locks
require 'monitor'

class Account
  def initialize
    @balance = 0
    @lock = Monitor.new
  end
  
  def deposit(amount)
    @lock.synchronize do
      @balance += amount
      log_transaction(amount)  # works with Monitor
    end
  end
  
  def log_transaction(amount)
    @lock.synchronize do
      puts "Deposited #{amount}"
    end
  end
end

Race Conditions from Insufficient Locking occur when lock scope is too narrow. Separate lock acquisitions for check and action operations create race conditions. The time-of-check-to-time-of-use (TOCTOU) gap allows state changes between operations. The fix requires expanding the critical section to encompass all related operations.

# Problematic code
class Counter
  def initialize
    @value = 0
    @mutex = Mutex.new
  end
  
  def increment_if_below(limit)
    current = @mutex.synchronize { @value }  # first lock
    
    if current < limit
      # Another thread might increment here
      @mutex.synchronize { @value += 1 }  # second lock, race condition
    end
  end
end

# Fix: atomic check-and-update
class Counter
  def increment_if_below(limit)
    @mutex.synchronize do
      if @value < limit
        @value += 1
        true
      else
        false
      end
    end
  end
end

Lock Granularity Problems manifest as either too-coarse locking (limiting concurrency) or too-fine locking (excessive overhead). A single global lock protecting multiple independent resources reduces parallelism. Per-item locks on large collections introduce substantial overhead. The fix requires analyzing access patterns and choosing appropriate granularity.

# Too coarse: single lock for entire cache
class CoarseCache
  def initialize
    @items = {}
    @mutex = Mutex.new
  end
  
  def get(key)
    @mutex.synchronize { @items[key] }  # all gets serialize
  end
end

# Better: striped locking
class StripedCache
  def initialize(stripes = 16)
    @stripes = Array.new(stripes) { { items: {}, mutex: Mutex.new } }
  end
  
  def get(key)
    stripe = @stripes[key.hash % @stripes.size]
    stripe[:mutex].synchronize { stripe[:items][key] }
  end
end

Signal vs Broadcast Confusion causes threads to wake incorrectly. Calling signal wakes only one waiting thread; if multiple threads can handle the condition, others miss the opportunity. Calling broadcast when only one thread should proceed wastes resources. The fix requires understanding whether one thread or all threads should respond to the condition change.

# Problematic: signal when multiple threads can proceed
def produce(item)
  @monitor.synchronize do
    @queue << item
    @condition.signal  # only one consumer wakes, even if multiple can run
  end
end

# Fix: broadcast when multiple threads can proceed
def produce(item)
  @monitor.synchronize do
    @queue << item
    @condition.broadcast  # all waiting consumers wake and compete
  end
end

Lock Leaks from Exceptions occur when manual lock/unlock pairs fail to release locks due to exceptions. The lock remains held, causing deadlock when other threads attempt acquisition. Ruby's ensure blocks and the synchronize method prevent this pitfall.

# Problematic code
mutex.lock
result = risky_operation  # might raise exception
mutex.unlock  # never executes if exception raised

# Fix: ensure block
mutex.lock
begin
  result = risky_operation
ensure
  mutex.unlock  # always executes
end

# Better: synchronize method
mutex.synchronize do
  result = risky_operation  # automatically unlocks on exception
end

Performance Considerations

Lock Contention occurs when multiple threads frequently compete for the same lock. High contention increases thread blocking time and context switches. Measuring contention requires profiling tools that track lock wait times. Reducing contention involves decreasing critical section duration, reducing lock acquisition frequency, or increasing parallelism through finer-grained locks.

# High contention: all threads serialize
class HighContentionCache
  def initialize
    @cache = {}
    @mutex = Mutex.new
  end
  
  def fetch(key)
    @mutex.synchronize do
      @cache[key] ||= expensive_computation(key)
    end
  end
end

# Lower contention: optimistic reading
class LowerContentionCache
  def initialize
    @cache = {}
    @mutex = Mutex.new
  end
  
  def fetch(key)
    # Check without lock first
    value = @cache[key]
    return value if value
    
    # Compute outside lock
    computed = expensive_computation(key)
    
    # Store with lock
    @mutex.synchronize do
      @cache[key] ||= computed
    end
  end
end

Critical Section Minimization reduces lock hold time by moving independent operations outside critical sections. Operations that don't access shared state should execute without holding locks. This principle increases parallelism and reduces contention. Profile code to identify expensive operations within critical sections that can be moved out.

# Poor: expensive work inside lock
def process_and_store(data)
  @mutex.synchronize do
    validated = validate_data(data)  # expensive, doesn't need lock
    processed = transform_data(validated)  # expensive, doesn't need lock
    @storage[data.id] = processed
  end
end

# Better: minimal critical section
def process_and_store(data)
  validated = validate_data(data)
  processed = transform_data(validated)
  
  @mutex.synchronize do
    @storage[data.id] = processed
  end
end

Lock Coarsening vs Lock Splitting represents a fundamental trade-off. Coarse locks (one lock protecting multiple resources) simplify code and reduce overhead but limit parallelism. Fine locks (separate locks per resource) enable parallelism but increase overhead and deadlock risk. The optimal granularity depends on access patterns: independent resources benefit from separate locks, while frequently accessed together resources should share locks.

# Coarse: one lock for all accounts (simple, low parallelism)
class BankCoarse
  def initialize
    @accounts = {}
    @mutex = Mutex.new
  end
  
  def transfer(from_id, to_id, amount)
    @mutex.synchronize do
      @accounts[from_id] -= amount
      @accounts[to_id] += amount
    end
  end
end

# Fine: per-account locks (complex, high parallelism)
class BankFine
  def initialize
    @accounts = {}
    @mutexes = Hash.new { |h, k| h[k] = Mutex.new }
  end
  
  def transfer(from_id, to_id, amount)
    first_id, second_id = [from_id, to_id].sort
    @mutexes[first_id].synchronize do
      @mutexes[second_id].synchronize do
        @accounts[from_id] -= amount
        @accounts[to_id] += amount
      end
    end
  end
end

Lock-Free Algorithms eliminate locks entirely using atomic operations. Lock-free code avoids deadlock and reduces contention but introduces complexity from CAS loops and ABA problems. Lock-free structures excel in high-contention scenarios where locks would serialize operations. The concurrent-ruby gem provides battle-tested lock-free data structures.

require 'concurrent'

# Lock-based counter (baseline)
class LockedCounter
  def initialize
    @value = 0
    @mutex = Mutex.new
  end
  
  def increment
    @mutex.synchronize { @value += 1 }
  end
end

# Lock-free counter (better for high contention)
class LockFreeCounter
  def initialize
    @value = Concurrent::AtomicFixnum.new(0)
  end
  
  def increment
    @value.increment
  end
end

# Benchmark shows lock-free scales better with thread count

Memory Barriers and Visibility affect performance in multi-core systems. Lock acquisition includes a memory barrier ensuring visibility of previous writes. Lock release includes a barrier ensuring visibility of critical section writes. These barriers prevent compiler and CPU reorderings but introduce overhead. Lock-free code using atomics also includes barriers at atomic operation boundaries.

False Sharing occurs when threads access different variables that share a cache line. One thread's writes invalidate another thread's cache, despite accessing different data. Locks exacerbate false sharing when lock variables and data variables occupy the same cache line. Padding separates variables across cache lines, eliminating false sharing at the cost of memory overhead.

Thundering Herd happens when many threads wake simultaneously after a condition broadcast. All threads compete for locks, but only one proceeds. The others immediately block again. This pattern wastes CPU on futile context switches. Using signal instead of broadcast when only one thread should proceed prevents thundering herd, but requires careful condition design.

# Thundering herd: all workers wake
def add_work(item)
  @monitor.synchronize do
    @queue << item
    @condition.broadcast  # all workers wake and compete
  end
end

# Better: only one worker wakes
def add_work(item)
  @monitor.synchronize do
    @queue << item
    @condition.signal  # only one worker wakes
  end
end

Spin Locks vs Blocking Locks represent different waiting strategies. Spin locks repeatedly check lock availability in a tight loop, avoiding context switch overhead but wasting CPU. Blocking locks deschedule the thread, saving CPU but incurring context switch costs. Spin locks suit very short critical sections; blocking locks suit longer waits. Ruby's Mutex uses blocking locks as the default strategy.

Reference

Mutex Methods

Method Description Return Value
Mutex.new Creates new unlocked mutex Mutex instance
lock Acquires mutex, blocks if unavailable self
unlock Releases mutex self
try_lock Attempts non-blocking acquisition true if acquired, false otherwise
synchronize Acquires lock, executes block, releases lock Block return value
locked? Checks if any thread holds lock Boolean
owned? Checks if current thread holds lock Boolean
sleep Releases lock, sleeps, reacquires lock Numeric sleep duration

Monitor Class Methods

Method Description Return Value
Monitor.new Creates new monitor Monitor instance
synchronize Executes block with monitor lock held Block return value
new_cond Creates condition variable ConditionVariable instance
enter Acquires monitor lock nil
exit Releases monitor lock nil
try_enter Attempts non-blocking acquisition Boolean

Condition Variable Methods

Method Description Return Value
wait Releases lock and waits for signal self
wait_while Waits while block returns true self
wait_until Waits until block returns true self
signal Wakes one waiting thread self
broadcast Wakes all waiting threads self

Queue Methods

Method Description Return Value
Queue.new Creates unbounded thread-safe queue Queue instance
push Adds item to queue self
pop Removes and returns item, blocks if empty Item
empty? Checks if queue is empty Boolean
size Returns current queue size Integer
clear Removes all items self
close Prevents further additions self
num_waiting Returns count of waiting threads Integer

SizedQueue Methods

Method Description Return Value
SizedQueue.new Creates bounded thread-safe queue SizedQueue instance
push Adds item, blocks if full self
pop Removes item, blocks if empty Item
max Returns capacity Integer
max= Sets new capacity Integer

Common Deadlock Prevention Strategies

Strategy Description Trade-offs
Lock Ordering Always acquire locks in consistent order Requires global lock identification
Timeout Abort if lock unavailable after timeout May cause transaction failures
Try-Lock Use non-blocking acquisition with retry Increases code complexity
Single Lock Use one lock for related resources Reduces parallelism
Lock-Free Use atomic operations instead of locks Limited to specific data structures

Lock Granularity Options

Granularity Description Best Use Case
Global Single lock for entire system Prototyping, low contention
Per-Class One lock per class instance Independent objects
Per-Resource Separate locks for each resource High parallelism needs
Striped Hash keys to fixed lock array Large collections
Hierarchical Nested locks for nested structures Tree structures

Concurrent-Ruby Atomic Types

Type Description Use Case
AtomicBoolean Thread-safe boolean Flags, state indicators
AtomicFixnum Thread-safe integer Counters, sequences
AtomicReference Thread-safe reference Object swapping
Map Thread-safe hash map Shared caches
Array Thread-safe array Shared collections
ThreadLocalVar Per-thread storage Thread-specific data

Performance Characteristics

Operation Ruby Mutex Monitor Queue Lock-Free
Acquire Blocking Blocking N/A Non-blocking
Contention Handling Serialize Serialize Internal Retry loop
Memory Overhead Low Medium Medium Low
CPU Overhead Medium Medium Medium High under contention
Scalability Poor Poor Good Excellent