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 |