Introduction
Ruby's MRI interpreter uses a Global VM Lock (GVL, formerly GIL) that allows only one thread to execute Ruby code at a time. Despite this limitation, threads remain essential for I/O-bound workloads where waiting dominates execution. This lesson covers thread creation, race conditions, and how Mutex protects shared state.
Key Concepts
- Global VM Lock (GVL): A lock in MRI Ruby that prevents multiple threads from executing Ruby code simultaneously, though it is released during I/O operations.
- Race Condition: A bug where the program's correctness depends on the unpredictable timing of thread execution.
- Mutex: A mutual exclusion lock that ensures only one thread can execute a critical section at a time.
Real World Context
Every web server handling concurrent requests (Puma, Unicorn) relies on threads. When two requests update the same database record or modify a shared cache, race conditions cause data corruption. Mutex and other synchronization primitives are the standard defense.
Deep Dive
Creating Threads
The following code spawns a new thread and waits for it to finish with join. Without join, the main thread might exit before the spawned thread completes.
rubydownload_thread = Thread.new do puts "Downloading file..." # Simulates network I/O — GVL is released during sleep sleep 2 puts "Download complete" end download_thread.join # Wait for thread to finish puts "File ready" # Output: # Downloading file... # Download complete # File ready
The Thread.new block runs concurrently. During sleep (which simulates I/O), the GVL is released and other threads can run.
Race Conditions Without Synchronization
When multiple threads modify the same variable without synchronization, results become unpredictable. The increment operation counter += 1 is not atomic — it reads, increments, then writes, and another thread can interleave between those steps.
rubyaccount_balance = 0 deposit_threads = 10.times.map do Thread.new do 1000.times { account_balance += 1 } # NOT thread-safe! end end deposit_threads.each(&:join) puts account_balance # Often less than 10000!
The final value is unpredictable because threads read stale values of account_balance while another thread is mid-update.
Fixing with Mutex
A Mutex wraps the critical section so only one thread at a time can execute it. Every other thread blocks on synchronize until the lock is released.
rubybalance_lock = Mutex.new account_balance = 0 deposit_threads = 10.times.map do Thread.new do 1000.times do balance_lock.synchronize do account_balance += 1 # Now thread-safe end end end end deposit_threads.each(&:join) puts account_balance # Always 10000
The synchronize method acquires the lock, runs the block, then releases the lock — even if an exception occurs inside the block.
Common Pitfalls
- Forgetting to join threads — If the main thread exits before spawned threads finish, their work is lost. Always call
joinorvalueon threads you care about. - Locking too broadly — Wrapping large sections of code in
synchronizeeliminates parallelism. Lock only the minimal critical section that accesses shared state.
Best Practices
- Prefer high-level abstractions — Use
Queue,concurrent-rubygems, or thread pools rather than raw Mutex when possible. They encapsulate synchronization correctly. - Keep shared mutable state minimal — The less data threads share, the fewer synchronization bugs you will encounter. Design for isolation first, synchronization second.
Summary
- The GVL prevents parallel Ruby execution but is released during I/O, making threads useful for network and disk operations.
- Race conditions occur when threads read-modify-write shared data without synchronization.
Mutex#synchronizeis the fundamental tool for protecting critical sections in Ruby.
Code Examples
# Thread-safe counter using Mutex
balance_lock = Mutex.new
account_balance = 0
workers = 5.times.map do
Thread.new do
100.times do
balance_lock.synchronize { account_balance += 1 }
end
end
end
workers.each(&:join)
puts account_balance # => 500 (always correct)