CrackedRuby logo

CrackedRuby

Global VM Lock (GVL)

Comprehensive guide to Ruby's Global VM Lock (GVL), covering threading behavior, performance implications, and concurrency patterns.

Concurrency and Parallelism Threading
6.1.5

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