Overview
The Global VM Lock (GVL) is Ruby's primary mechanism for managing thread execution within the Ruby virtual machine. The GVL ensures that only one thread can execute Ruby code at any given time, preventing race conditions in the Ruby interpreter itself while allowing multiple threads to exist simultaneously.
Ruby implements the GVL as a mutex that threads must acquire before executing Ruby code. When a thread holds the GVL, all other Ruby threads must wait. The GVL automatically releases during I/O operations, system calls, and calls to C extensions that explicitly release it, allowing other threads to execute.
The GVL affects three main categories of operations differently:
Ruby Code Execution: All Ruby code executes under the GVL. Multiple threads cannot run Ruby code simultaneously, making Ruby threading cooperative rather than preemptive for CPU-bound tasks.
I/O Operations: Most I/O operations release the GVL, allowing other threads to execute Ruby code while one thread waits for I/O completion.
C Extensions: Native C extensions can choose to release the GVL during long-running operations, enabling true parallelism for those operations.
# Multiple threads created, but Ruby code executes sequentially
threads = 3.times.map do |i|
Thread.new do
puts "Thread #{i} starting"
1000000.times { |n| n * n } # CPU-bound work under GVL
puts "Thread #{i} finished"
end
end
threads.each(&:join)
# Output shows threads finishing one after another, not concurrently
The GVL serves as Ruby's solution to thread safety within the interpreter while maintaining the familiar threading API. This design choice prioritizes interpreter stability and Ruby object integrity over raw threading performance.
# I/O operations release the GVL, allowing concurrency
require 'net/http'
threads = 3.times.map do |i|
Thread.new do
puts "Thread #{i} starting HTTP request"
Net::HTTP.get('httpbin.org', '/delay/2') # Releases GVL during network I/O
puts "Thread #{i} finished HTTP request"
end
end
threads.each(&:join)
# These threads can execute concurrently due to GVL release during I/O
Basic Usage
Working with the GVL requires understanding how different operations interact with the lock. Ruby provides several mechanisms to observe and work within GVL constraints.
Thread creation and management work normally, but execution patterns differ from typical threading models:
require 'benchmark'
# CPU-bound work demonstrates GVL serialization
def cpu_bound_task(name)
puts "#{name} started at #{Time.now}"
result = 0
1_000_000.times { |i| result += i }
puts "#{name} finished at #{Time.now} with result #{result}"
end
# Single-threaded execution
puts "Single-threaded:"
Benchmark.realtime do
3.times { |i| cpu_bound_task("Task #{i}") }
end
# Multi-threaded execution (still serialized by GVL)
puts "\nMulti-threaded:"
Benchmark.realtime do
threads = 3.times.map { |i| Thread.new { cpu_bound_task("Thread #{i}") } }
threads.each(&:join)
end
The Thread.pass
method explicitly yields the GVL to other threads:
counter = 0
thread1 = Thread.new do
1000.times do |i|
counter += 1
Thread.pass if i % 100 == 0 # Yield GVL periodically
end
end
thread2 = Thread.new do
1000.times do |i|
counter += 1
Thread.pass if i % 100 == 0
end
end
thread1.join
thread2.join
puts "Final counter: #{counter}" # May not be 2000 due to race conditions
Monitoring thread execution patterns helps understand GVL behavior:
# Thread timing utility to observe GVL effects
class ThreadTimer
def self.time_execution(name)
start_time = Time.now
result = yield
end_time = Time.now
puts "#{name}: #{(end_time - start_time) * 1000}ms"
result
end
end
# Compare I/O vs CPU bound operations
ThreadTimer.time_execution("I/O bound") do
threads = 5.times.map do
Thread.new { sleep(0.5) } # Sleep releases GVL
end
threads.each(&:join)
end
ThreadTimer.time_execution("CPU bound") do
threads = 5.times.map do
Thread.new { 100_000.times { |i| i * 2 } } # Keeps GVL
end
threads.each(&:join)
end
Ruby's Mutex
class works alongside the GVL to provide application-level synchronization:
mutex = Mutex.new
shared_resource = []
threads = 10.times.map do |i|
Thread.new do
mutex.synchronize do
# GVL + Mutex provides double protection
shared_resource << "Thread #{i}"
sleep(0.01) # Simulates work while holding mutex
end
end
end
threads.each(&:join)
puts "Shared resource has #{shared_resource.length} items"
Thread Safety & Concurrency
The GVL provides built-in protection for Ruby object access but creates specific concurrency patterns that developers must understand. While the GVL prevents simultaneous Ruby code execution, it doesn't eliminate all race conditions or the need for proper synchronization.
Race Conditions Still Exist: The GVL releases at unpredictable points, creating race conditions in multi-step operations:
class Counter
def initialize
@count = 0
end
def increment
current = @count # GVL might release here
@count = current + 1 # Another thread could modify @count
end
def value
@count
end
end
counter = Counter.new
threads = 100.times.map do
Thread.new { 1000.times { counter.increment } }
end
threads.each(&:join)
puts counter.value # Often less than 100,000 due to race conditions
Proper Synchronization Patterns: Combine GVL awareness with explicit synchronization:
class ThreadSafeCounter
def initialize
@count = 0
@mutex = Mutex.new
end
def increment
@mutex.synchronize { @count += 1 }
end
def value
@mutex.synchronize { @count }
end
end
# Alternative using Atomic operations (Ruby 3.0+)
require 'concurrent'
class AtomicCounter
def initialize
@count = Concurrent::AtomicFixnum.new(0)
end
def increment
@count.increment
end
def value
@count.value
end
end
Queue-Based Communication: Thread-safe data structures work efficiently with GVL:
require 'thread'
# Producer-consumer pattern using SizedQueue
queue = SizedQueue.new(10)
results = Queue.new
# Producer threads
producers = 3.times.map do |i|
Thread.new do
10.times do |j|
work_item = "Producer #{i}, Item #{j}"
queue << work_item
sleep(0.1) # Simulates work, releases GVL
end
end
end
# Consumer threads
consumers = 2.times.map do |i|
Thread.new do
while item = queue.pop(true) rescue nil
processed = "Processed by Consumer #{i}: #{item}"
results << processed
sleep(0.05) # Processing time
end
end
end
# Wait for producers to finish
producers.each(&:join)
# Signal consumers to stop
consumers.each { |t| queue << nil }
consumers.each(&:join)
# Collect results
final_results = []
final_results << results.pop(true) while !results.empty? rescue nil
puts "Processed #{final_results.length} items"
Actor Pattern Implementation: Encapsulate state with message passing:
class ThreadSafeActor
def initialize
@mailbox = Queue.new
@state = {}
@thread = Thread.new { process_messages }
end
def send_message(message)
@mailbox << message
end
def shutdown
@mailbox << :shutdown
@thread.join
end
private
def process_messages
while message = @mailbox.pop
break if message == :shutdown
case message[:type]
when :set
@state[message[:key]] = message[:value]
when :get
message[:result].push(@state[message[:key]])
when :increment
@state[message[:key]] = (@state[message[:key]] || 0) + 1
end
end
end
end
# Usage with result synchronization
actor = ThreadSafeActor.new
result_queue = Queue.new
# Send operations from multiple threads
threads = 10.times.map do |i|
Thread.new do
actor.send_message(type: :set, key: "counter_#{i}", value: 0)
100.times do
actor.send_message(type: :increment, key: "counter_#{i}")
end
end
end
threads.each(&:join)
actor.shutdown
Performance & Memory
The GVL significantly impacts Ruby application performance, particularly for CPU-intensive workloads. Understanding these performance characteristics helps in designing efficient concurrent applications.
CPU-Bound vs I/O-Bound Performance: The GVL creates different performance profiles for different workload types:
require 'benchmark'
def cpu_intensive_task
result = 0
1_000_000.times { |i| result += Math.sqrt(i) }
result
end
def io_intensive_task
sleep(0.1) # Simulates I/O operation
"completed"
end
# CPU-bound tasks show no parallelism benefit
puts "CPU-bound tasks:"
Benchmark.bm(20) do |x|
x.report("Single thread:") do
4.times { cpu_intensive_task }
end
x.report("Four threads:") do
threads = 4.times.map { Thread.new { cpu_intensive_task } }
threads.each(&:join)
end
end
# I/O-bound tasks show significant parallelism benefit
puts "\nI/O-bound tasks:"
Benchmark.bm(20) do |x|
x.report("Single thread:") do
4.times { io_intensive_task }
end
x.report("Four threads:") do
threads = 4.times.map { Thread.new { io_intensive_task } }
threads.each(&:join)
end
end
Memory Overhead and Thread Management: Each thread consumes memory for its stack and associated data structures:
require 'objspace'
# Measure memory usage of threading
def measure_memory_usage
GC.start
ObjectSpace.count_objects[:TOTAL] - ObjectSpace.count_objects[:FREE]
end
baseline = measure_memory_usage
puts "Baseline objects: #{baseline}"
# Create threads without starting them
threads = 100.times.map { Thread.new { sleep } }
after_creation = measure_memory_usage
puts "After creating 100 threads: #{after_creation} (+#{after_creation - baseline})"
# Join threads to clean up
threads.each(&:join)
GC.start
after_cleanup = measure_memory_usage
puts "After cleanup: #{after_cleanup}"
GVL Release Patterns in Native Extensions: C extensions that release the GVL enable true parallelism:
# Example using JSON parsing (native extension)
require 'json'
require 'benchmark'
large_json = { data: (1..10000).map { |i| { id: i, value: "item_#{i}" } } }.to_json
puts "JSON parsing (native C extension, may release GVL):"
Benchmark.bm(20) do |x|
x.report("Single thread:") do
4.times { JSON.parse(large_json) }
end
x.report("Four threads:") do
threads = 4.times.map do
Thread.new { JSON.parse(large_json) }
end
threads.each(&:join)
end
end
Optimizing for GVL Constraints: Design patterns that work efficiently within GVL limitations:
# Thread pool pattern to amortize thread creation overhead
class GVLAwareThreadPool
def initialize(size = 4)
@size = size
@jobs = Queue.new
@threads = Array.new(@size) { create_worker }
end
def submit(&block)
@jobs << block
end
def shutdown
@size.times { @jobs << :shutdown }
@threads.each(&:join)
end
private
def create_worker
Thread.new do
while job = @jobs.pop
break if job == :shutdown
job.call
end
end
end
end
# Efficient for I/O-bound work
pool = GVLAwareThreadPool.new(4)
# Submit I/O-bound tasks
20.times do |i|
pool.submit do
sleep(0.1) # Simulates I/O
puts "Completed task #{i}"
end
end
pool.shutdown
Fiber vs Thread Performance: Fibers provide cooperative concurrency without GVL overhead:
require 'benchmark'
require 'fiber'
# Compare Fiber vs Thread for cooperative tasks
task_count = 1000
puts "Cooperative task performance:"
Benchmark.bm(15) do |x|
x.report("Threads:") do
threads = task_count.times.map do |i|
Thread.new do
5.times { Thread.pass } # Cooperative yielding
i * 2
end
end
threads.each(&:join)
end
x.report("Fibers:") do
fibers = task_count.times.map do |i|
Fiber.new do
5.times { Fiber.yield } # Cooperative yielding
i * 2
end
end
# Resume fibers until all complete
active_fibers = fibers.dup
until active_fibers.empty?
active_fibers.reject! { |f| !f.alive? || (f.resume; !f.alive?) }
end
end
end
Common Pitfalls
Developers frequently misunderstand GVL behavior, leading to inefficient designs and unexpected performance characteristics. These common pitfalls demonstrate the subtle aspects of GVL interaction.
Expecting CPU-Bound Parallelism: The most common misconception assumes threading provides CPU parallelism for Ruby code:
# Ineffective: Threading CPU-bound work
def prime_calculation(limit)
primes = []
(2..limit).each do |n|
is_prime = true
(2..Math.sqrt(n)).each do |d|
if n % d == 0
is_prime = false
break
end
end
primes << n if is_prime
end
primes.length
end
# This won't improve performance
threads = 4.times.map do |i|
Thread.new { prime_calculation(1000) }
end
results = threads.map(&:join).map(&:value)
# Better: Use process-based parallelism for CPU-bound work
# Or redesign the algorithm to be I/O-bound
Misunderstanding Atomic Operations: Assuming single Ruby operations are atomic under the GVL:
# Problematic: Non-atomic compound operations
class BankAccount
def initialize(balance = 0)
@balance = balance
end
def transfer_to(other_account, amount)
if @balance >= amount
# GVL might release between these operations
@balance -= amount
other_account.receive(amount)
true
else
false
end
end
def receive(amount)
@balance += amount
end
def balance
@balance
end
end
# Race condition example
account1 = BankAccount.new(1000)
account2 = BankAccount.new(0)
# Multiple transfers might overdraw account1
transfers = 10.times.map do
Thread.new { account1.transfer_to(account2, 200) }
end
transfers.each(&:join)
puts "Account 1: #{account1.balance}" # May be negative!
puts "Account 2: #{account2.balance}"
# Correct approach: Use explicit synchronization
class SafeBankAccount
def initialize(balance = 0)
@balance = balance
@mutex = Mutex.new
end
def transfer_to(other_account, amount)
@mutex.synchronize do
if @balance >= amount
@balance -= amount
other_account.receive(amount)
true
else
false
end
end
end
def receive(amount)
@mutex.synchronize { @balance += amount }
end
def balance
@mutex.synchronize { @balance }
end
end
Incorrect Exception Handling in Threads: Exceptions in threads behave differently and can cause silent failures:
# Dangerous: Unhandled thread exceptions
def risky_operation(id)
raise "Error in thread #{id}" if id == 3
puts "Thread #{id} completed successfully"
end
# Silent failure - exception in thread 3 is lost
threads = 5.times.map do |i|
Thread.new { risky_operation(i) }
end
threads.each(&:join) # Thread 3's exception is swallowed
puts "All threads completed" # This still prints
# Better: Explicit exception handling
results = 5.times.map do |i|
Thread.new do
begin
risky_operation(i)
{ success: true, id: i }
rescue => e
{ success: false, id: i, error: e.message }
end
end
end
results.each do |thread|
result = thread.value
if result[:success]
puts "Thread #{result[:id]} succeeded"
else
puts "Thread #{result[:id]} failed: #{result[:error]}"
end
end
Deadlock with Nested Mutexes: Combining mutexes with the GVL can create complex deadlock scenarios:
# Deadlock scenario
mutex_a = Mutex.new
mutex_b = Mutex.new
thread1 = Thread.new do
mutex_a.synchronize do
puts "Thread 1 acquired mutex A"
sleep(0.1) # Give time for thread2 to acquire mutex B
mutex_b.synchronize do
puts "Thread 1 acquired mutex B"
end
end
end
thread2 = Thread.new do
mutex_b.synchronize do
puts "Thread 2 acquired mutex B"
sleep(0.1) # Give time for thread1 to acquire mutex A
mutex_a.synchronize do
puts "Thread 2 acquired mutex A"
end
end
end
# This will deadlock - neither thread can proceed
# Use timeout to detect deadlock in practice
begin
Timeout.timeout(1) do
thread1.join
thread2.join
end
rescue Timeout::Error
puts "Deadlock detected!"
thread1.kill
thread2.kill
end
Performance Anti-patterns: Common patterns that work against the GVL:
# Anti-pattern: Excessive thread creation
def bad_parallel_processing(items)
threads = items.map do |item|
Thread.new { process_item(item) } # Creates too many threads
end
threads.map(&:join).map(&:value)
end
# Better: Use a thread pool
class SimpleThreadPool
def initialize(size)
@queue = Queue.new
@threads = size.times.map { create_worker }
end
def process(items, &block)
results = Concurrent::Array.new
items.each_with_index do |item, index|
@queue << [item, index, results, block]
end
# Wait for completion (simplified)
sleep(0.1) while results.length < items.length
results.sort_by(&:first).map(&:last)
end
private
def create_worker
Thread.new do
while job = @queue.pop
item, index, results, block = job
result = block.call(item)
results << [index, result]
end
end
end
end
Reference
Core GVL Behavior
Operation Type | GVL Status | Concurrency | Use Case |
---|---|---|---|
Ruby code execution | Held | Sequential only | CPU-bound processing |
I/O operations | Released | Concurrent | File/network operations |
Sleep/wait | Released | Concurrent | Timing/synchronization |
C extensions (most) | Released | Concurrent | Native computation |
Mutex operations | Held during acquisition | Sequential | Synchronization |
Thread Management Methods
Method | Parameters | Returns | Description |
---|---|---|---|
Thread.new |
Block | Thread |
Creates new thread |
Thread.pass |
None | nil |
Yields GVL to other threads |
Thread.current |
None | Thread |
Returns current thread |
Thread.list |
None | Array<Thread> |
Lists all live threads |
#join |
limit=nil |
Thread or nil |
Waits for thread completion |
#value |
None | Object |
Returns thread result value |
#kill |
None | Thread |
Terminates thread immediately |
#alive? |
None | Boolean |
Checks if thread is running |
Synchronization Primitives
Class | Primary Use | GVL Interaction | Thread Safety |
---|---|---|---|
Mutex |
Exclusive access | Works with GVL | Thread-safe |
Monitor |
Reentrant locking | Works with GVL | Thread-safe |
ConditionVariable |
Wait/signal coordination | Releases GVL during wait | Thread-safe |
Queue |
Thread communication | Releases GVL during blocking | Thread-safe |
SizedQueue |
Bounded communication | Releases GVL during blocking | Thread-safe |
GVL Release Scenarios
# Operations that release the GVL:
File.read(filename) # File I/O
Net::HTTP.get(uri) # Network I/O
sleep(duration) # Time-based waiting
Kernel.system(command) # External process execution
JSON.parse(string) # Native extension (typical)
Marshal.load(data) # Native serialization
Thread States
State | Description | GVL Status |
---|---|---|
run |
Currently executing | Holds GVL |
sleep |
Waiting for condition | May hold or release GVL |
aborting |
Being terminated | May hold GVL briefly |
dead |
Execution completed | No GVL interaction |
Performance Guidelines
Scenario | Recommended Approach | Avoid |
---|---|---|
CPU-bound work | Process-based parallelism | Thread-based parallelism |
I/O-bound work | Thread-based concurrency | Sequential processing |
Mixed workloads | Hybrid approach with thread pools | Excessive thread creation |
High-frequency operations | Lock-free data structures | Fine-grained locking |
Error Handling Patterns
# Exception propagation from threads
def safe_thread_execution(&block)
Thread.new do
begin
block.call
rescue => e
Thread.current[:exception] = e
raise
end
end
end
# Collecting exceptions
def collect_thread_results(threads)
threads.map do |thread|
begin
{ success: true, result: thread.value }
rescue => e
{ success: false, exception: e }
end
end
end
Memory Considerations
Component | Approximate Memory Usage | Notes |
---|---|---|
Thread stack | 8KB - 2MB per thread | Platform dependent |
Thread object | ~200 bytes | Ruby object overhead |
Mutex object | ~100 bytes | Plus platform mutex |
Queue object | Variable | Depends on queued items |
GVL Evolution by Ruby Version
Ruby Version | GVL Implementation | Notable Changes |
---|---|---|
1.9+ | Native threads with GVL | Replaced green threads |
2.0+ | Improved GVL algorithm | Better thread switching |
2.2+ | Symbol GC integration | Reduced memory overhead |
3.0+ | Ractor introduction | Parallel actors without GVL |
3.2+ | YJIT improvements | Better threaded performance |