CrackedRuby logo

CrackedRuby

Ractor-local Storage

A comprehensive guide to using Ractor-local storage for isolated, thread-safe data management across Ruby's actor-based concurrency model.

Concurrency and Parallelism Ractors
6.2.4

Overview

Ractor-local storage provides isolated, per-Ractor data storage that maintains thread safety across Ruby's actor-based concurrency model. Each Ractor maintains its own independent storage namespace that cannot be accessed by other Ractors, ensuring complete isolation while supporting thread-safe operations within each Ractor.

Ruby implements Ractor-local storage through a simple key-value interface using Ractor[] and Ractor[]= methods for basic access, complemented by Ractor.store_if_absent for atomic initialization. The storage operates similarly to thread-local variables but with stronger isolation guarantees - whereas threads share memory space, Ractors maintain completely separate storage namespaces.

The storage system handles three primary scenarios: basic value storage and retrieval, atomic initialization in multi-threaded environments, and integration with Ruby's broader concurrency primitives. Unlike global variables or shared objects, Ractor-local storage prevents data races by design, eliminating the need for explicit synchronization when accessing stored values from within the same Ractor.

# Basic storage access
Ractor[:config] = { timeout: 30, retries: 3 }
settings = Ractor[:config]

# Storage is completely isolated between Ractors
main_value = "main ractor data"
Ractor[:shared] = main_value

child_ractor = Ractor.new do
  Ractor[:shared] = "child ractor data"
  Ractor[:shared]  # => "child ractor data"
end

puts Ractor[:shared]      # => "main ractor data"
puts child_ractor.take    # => "child ractor data"

Each Ractor's storage exists independently, so setting Ractor[:key] in one Ractor does not affect the same key in another Ractor. This isolation extends to all nested objects and provides stronger guarantees than thread-local storage, which can still share objects through references.

The storage system accepts any Ruby object as both keys and values, though symbols are commonly used as keys for performance and consistency. Values are not copied or moved when stored - they maintain their original object identity within the Ractor that stores them.

Basic Usage

The primary interface for Ractor-local storage consists of three methods: Ractor[] for reading values, Ractor[]= for writing values, and Ractor.store_if_absent for atomic initialization. These methods operate only on the current Ractor's storage space.

# Setting and getting basic values
Ractor[:user_id] = 12345
Ractor[:session_token] = "abc123xyz"

user_id = Ractor[:user_id]           # => 12345
token = Ractor[:session_token]       # => "abc123xyz"

# Working with complex objects
Ractor[:database_config] = {
  host: 'localhost',
  port: 5432,
  database: 'myapp_production'
}

# Accessing nested values
db_host = Ractor[:database_config][:host]  # => "localhost"

Storage operates with typical Ruby semantics for accessing Hash-like structures. Accessing a non-existent key returns nil, and you can store any Ruby object including complex nested structures, arrays, and custom objects.

# Working with different data types
Ractor[:counter] = 0
Ractor[:users] = []
Ractor[:cache] = Hash.new

# Incrementing stored values
Ractor[:counter] += 1
current_count = Ractor[:counter]  # => 1

# Modifying stored collections
Ractor[:users] << { name: "Alice", id: 1 }
Ractor[:cache]["recent_query"] = "SELECT * FROM users"

The storage maintains object identity, so modifications to mutable objects affect the stored reference. This behavior matches standard Ruby variable assignment and allows for in-place modifications of stored data structures.

For thread-safe initialization, Ractor.store_if_absent provides atomic setup of storage values. This method accepts a key and a block, executing the block only if the key doesn't already have a value, then storing the block's result.

# Thread-safe initialization of expensive objects
mutex = Ractor.store_if_absent(:lock) { Mutex.new }

# Multiple threads calling this will all get the same Mutex instance
10.times.map do |i|
  Thread.new do
    my_mutex = Ractor.store_if_absent(:lock) { Mutex.new }
    my_mutex.object_id  # All threads see the same object_id
  end
end.map(&:join)

# Initialize complex configuration atomically
config = Ractor.store_if_absent(:app_config) do
  {
    worker_count: ENV.fetch('WORKER_COUNT', '4').to_i,
    timeout: ENV.fetch('TIMEOUT', '30').to_i,
    debug: ENV['DEBUG'] == 'true'
  }
end

The store_if_absent method returns the stored value whether it was just initialized or already existed, making it suitable for both initialization and subsequent access patterns. This eliminates race conditions that would occur with traditional Ractor[:key] ||= value patterns in multi-threaded code.

Storage keys should be chosen carefully as they form a shared namespace within each Ractor. Using symbols provides better performance for frequently accessed keys, while strings offer more flexibility for dynamic key generation.

# Organizing storage with prefixed keys
Ractor[:db_connection] = create_connection
Ractor[:cache_redis] = create_redis_client
Ractor[:queue_processor] = WorkerQueue.new

# Dynamic key generation
user_id = 12345
Ractor["user_#{user_id}_profile"] = fetch_user_profile(user_id)
Ractor["user_#{user_id}_permissions"] = fetch_permissions(user_id)

Advanced Usage

Ractor-local storage enables sophisticated patterns for managing per-Ractor state, coordinating with threads within Ractors, and building complex concurrent systems. Advanced usage typically involves combining storage with other concurrency primitives and managing lifecycle of stored objects.

The store_if_absent method serves as the foundation for implementing per-Ractor singletons and shared resources that need atomic initialization across multiple threads within the same Ractor. This pattern is particularly valuable when expensive objects like database connections or caches need to be shared among threads in a Ractor.

# Per-Ractor connection pool with lazy initialization
def get_database_connection
  Ractor.store_if_absent(:db_pool) do
    ConnectionPool.new(size: 5, timeout: 5) do
      PG.connect(
        host: ENV['DB_HOST'],
        port: ENV['DB_PORT'],
        dbname: ENV['DB_NAME']
      )
    end
  end
end

# Advanced worker pattern with per-Ractor state management
def initialize_worker_state
  Ractor.store_if_absent(:worker_state) do
    {
      processed_count: 0,
      error_count: 0,
      started_at: Time.now,
      metrics_collector: MetricsCollector.new,
      circuit_breaker: CircuitBreaker.new(failure_threshold: 5)
    }
  end
end

# Thread-safe metrics collection within a Ractor
def record_processing_result(success)
  state = Ractor[:worker_state]
  mutex = Ractor.store_if_absent(:state_mutex) { Mutex.new }
  
  mutex.synchronize do
    if success
      state[:processed_count] += 1
    else
      state[:error_count] += 1
      state[:circuit_breaker].record_failure
    end
    state[:metrics_collector].record(success)
  end
end

Complex storage patterns often involve layered initialization where different components depend on previously initialized storage values. The store_if_absent method's atomic behavior ensures proper ordering and prevents race conditions during complex setup sequences.

# Layered initialization pattern
def setup_ractor_environment
  # Base configuration must be initialized first
  config = Ractor.store_if_absent(:config) do
    load_configuration_from_env
  end
  
  # Logger depends on configuration
  logger = Ractor.store_if_absent(:logger) do
    Logger.new(
      config[:log_file],
      level: config[:log_level],
      formatter: config[:log_formatter]
    )
  end
  
  # Error handler depends on both config and logger
  error_handler = Ractor.store_if_absent(:error_handler) do
    ErrorHandler.new(
      logger: logger,
      notification_endpoint: config[:error_webhook],
      retry_attempts: config[:max_retries]
    )
  end
  
  # Return fully initialized environment
  { config: config, logger: logger, error_handler: error_handler }
end

Storage can implement sophisticated caching patterns that maintain consistency within a Ractor while isolating cached data from other Ractors. This provides automatic cache partitioning without explicit coordination.

# Per-Ractor LRU cache implementation
def get_cached_value(key)
  cache = Ractor.store_if_absent(:lru_cache) do
    LRUCache.new(max_size: 1000, ttl: 3600)
  end
  
  cache_mutex = Ractor.store_if_absent(:cache_mutex) { Mutex.new }
  
  cache_mutex.synchronize do
    cache.fetch(key) do
      # Expensive computation only happens on cache miss
      expensive_calculation(key)
    end
  end
end

# Batch processing with per-Ractor accumulation
def process_batch_item(item)
  batch_buffer = Ractor.store_if_absent(:batch_buffer) { [] }
  batch_size = Ractor.store_if_absent(:batch_size) { 100 }
  
  batch_buffer << item
  
  if batch_buffer.size >= batch_size
    process_full_batch(batch_buffer.dup)
    batch_buffer.clear
  end
end

Advanced patterns frequently combine Ractor-local storage with message passing to create hybrid architectures where Ractors maintain local state while communicating through messages. This approach provides both isolation and coordination capabilities.

# State machine pattern with local storage and message coordination
def run_stateful_worker
  Ractor.store_if_absent(:state) { :idle }
  Ractor.store_if_absent(:work_queue) { Queue.new }
  
  loop do
    message = Ractor.receive
    
    case [Ractor[:state], message[:type]]
    when [:idle, :start_work]
      Ractor[:state] = :working
      Ractor[:current_task] = message[:task]
      process_task(message[:task])
      
    when [:working, :pause]
      Ractor[:state] = :paused
      Ractor[:pause_time] = Time.now
      
    when [:paused, :resume]
      Ractor[:state] = :working
      resume_duration = Time.now - Ractor[:pause_time]
      Ractor[:total_pause_time] += resume_duration
      
    else
      handle_invalid_transition(Ractor[:state], message[:type])
    end
  end
end

Thread Safety & Concurrency

Ractor-local storage provides thread-safe access patterns when combined with proper synchronization primitives. While the storage itself is isolated per-Ractor, multiple threads within a single Ractor can access the same storage space, requiring coordination for thread safety.

The fundamental thread safety guarantee is that storage is completely isolated between Ractors - no synchronization is needed when different Ractors access their respective storage spaces. However, when multiple threads within the same Ractor access shared storage values, standard thread safety considerations apply.

# Thread-safe counter using Ractor-local storage
def increment_counter
  # Get or create a mutex for this Ractor's counter operations
  counter_mutex = Ractor.store_if_absent(:counter_mutex) { Mutex.new }
  
  counter_mutex.synchronize do
    current = Ractor[:counter] || 0
    Ractor[:counter] = current + 1
  end
end

# Multiple threads can safely increment the same Ractor-local counter
threads = 10.times.map do
  Thread.new { 100.times { increment_counter } }
end
threads.each(&:join)

puts Ractor[:counter]  # => 1000 (guaranteed to be accurate)

The store_if_absent method provides atomic initialization that eliminates race conditions during setup phases. This method uses internal synchronization to ensure that the initialization block runs exactly once, even when called concurrently from multiple threads.

# Race condition demonstration and solution
def unsafe_initialization
  # UNSAFE: Race condition if multiple threads call this simultaneously
  unless Ractor[:expensive_resource]
    puts "Initializing expensive resource..."
    sleep 0.1  # Simulate expensive initialization
    Ractor[:expensive_resource] = ExpensiveResource.new
  end
  Ractor[:expensive_resource]
end

def safe_initialization
  # SAFE: Atomic initialization prevents race conditions
  Ractor.store_if_absent(:expensive_resource) do
    puts "Initializing expensive resource..."
    sleep 0.1  # Simulate expensive initialization
    ExpensiveResource.new
  end
end

# Demonstrate race condition with unsafe version
threads = 5.times.map { Thread.new { unsafe_initialization } }
# May print "Initializing..." multiple times

# Demonstrate safety with atomic version  
threads = 5.times.map { Thread.new { safe_initialization } }
# Prints "Initializing..." exactly once

Complex concurrent patterns can leverage Ractor-local storage to implement per-Ractor work queues, connection pools, and other shared resources that need thread-safe access within their Ractor boundary.

# Thread-safe work queue with per-Ractor isolation
class RactorLocalWorkQueue
  def self.enqueue(item)
    queue = Ractor.store_if_absent(:work_queue) { Queue.new }
    queue << item
  end
  
  def self.dequeue(timeout: nil)
    queue = Ractor.store_if_absent(:work_queue) { Queue.new }
    
    if timeout
      Timeout::timeout(timeout) { queue.pop }
    else
      queue.pop
    end
  rescue Timeout::Error
    nil
  end
  
  def self.size
    queue = Ractor.store_if_absent(:work_queue) { Queue.new }
    queue.size
  end
end

# Worker threads within a Ractor can safely share the queue
def start_worker_threads(count)
  count.times.map do
    Thread.new do
      loop do
        item = RactorLocalWorkQueue.dequeue(timeout: 1)
        break unless item
        process_work_item(item)
      end
    end
  end
end

Producer-consumer patterns benefit significantly from Ractor-local storage, allowing multiple producer threads and consumer threads to coordinate through shared storage while maintaining isolation from other Ractors.

# Producer-consumer pattern with thread-safe statistics
def initialize_producer_consumer_state
  Ractor.store_if_absent(:statistics) do
    {
      items_produced: 0,
      items_consumed: 0,
      total_processing_time: 0.0
    }
  end
  
  Ractor.store_if_absent(:stats_mutex) { Mutex.new }
  Ractor.store_if_absent(:work_queue) { SizedQueue.new(100) }
end

def produce_item(item)
  queue = Ractor[:work_queue]
  stats = Ractor[:statistics]
  mutex = Ractor[:stats_mutex]
  
  queue << item
  
  mutex.synchronize do
    stats[:items_produced] += 1
  end
end

def consume_item
  queue = Ractor[:work_queue]
  stats = Ractor[:statistics]
  mutex = Ractor[:stats_mutex]
  
  item = queue.pop
  start_time = Time.now
  
  process_item(item)
  
  processing_time = Time.now - start_time
  mutex.synchronize do
    stats[:items_consumed] += 1
    stats[:total_processing_time] += processing_time
  end
  
  item
end

When designing thread-safe access patterns, consider that stored objects themselves may not be thread-safe even though the storage mechanism is isolated. Mutable objects like Arrays, Hashes, or custom objects require their own synchronization when accessed from multiple threads.

# Thread-safe access to mutable stored objects
def add_to_shared_collection(item)
  collection = Ractor.store_if_absent(:shared_items) { [] }
  collection_mutex = Ractor.store_if_absent(:collection_mutex) { Mutex.new }
  
  collection_mutex.synchronize do
    collection << item
  end
end

def read_from_shared_collection
  collection = Ractor.store_if_absent(:shared_items) { [] }
  collection_mutex = Ractor.store_if_absent(:collection_mutex) { Mutex.new }
  
  collection_mutex.synchronize do
    collection.dup  # Return a copy to avoid external mutation
  end
end

Common Pitfalls

Ractor-local storage introduces several subtle behaviors that can lead to unexpected results. Understanding these pitfalls helps developers avoid common mistakes and design more robust concurrent systems.

The most frequent mistake is attempting to access another Ractor's local storage. The [] and []= methods raise RuntimeError when called on a Ractor instance that is not the current Ractor, enforcing strict isolation.

# INCORRECT: Cannot access another Ractor's storage
main_ractor = Ractor.current
child_ractor = Ractor.new do
  Ractor[:child_data] = "child value"
  Ractor.yield self  # Return the child ractor instance
end

child_ractor_ref = child_ractor.take

# This will raise RuntimeError
begin
  value = child_ractor_ref[:child_data]
rescue RuntimeError => e
  puts e.message  # => "Cannot get ractor local storage for non-current ractor"
end

# CORRECT: Each Ractor manages its own storage
main_value = Ractor[:main_data] = "main value"
child_result = Ractor.new do
  Ractor[:child_data] = "child value"
  Ractor[:child_data]  # Return the value through message passing
end.take

Another common pitfall involves confusion between the instance methods Ractor#[] and Ractor#[]= versus the class methods Ractor.[] and Ractor.[]=. The instance methods are deprecated and require the target Ractor to be the current Ractor, while the class methods always operate on the current Ractor.

# DEPRECATED: Using instance methods (still works but discouraged)
current_ractor = Ractor.current
current_ractor[:old_style] = "deprecated approach"
value = current_ractor[:old_style]

# RECOMMENDED: Using class methods
Ractor[:new_style] = "recommended approach"  
value = Ractor[:new_style]

# The class methods are cleaner and don't require ractor instance references

Race conditions during initialization represent another significant pitfall. The traditional Ruby idiom variable ||= value is not atomic and can cause multiple initializations when used with Ractor-local storage in multi-threaded scenarios.

# PROBLEMATIC: Non-atomic initialization
def get_connection_unsafe
  # This pattern has a race condition with multiple threads
  Ractor[:db_connection] ||= expensive_database_connection
end

# Multiple threads calling this simultaneously may create multiple connections
# because the check and assignment are separate operations

# CORRECT: Atomic initialization
def get_connection_safe
  Ractor.store_if_absent(:db_connection) do
    expensive_database_connection
  end
end

# Demonstration of the race condition
def expensive_database_connection
  puts "Creating expensive connection..."  # This may print multiple times
  sleep 0.1
  "connection_#{rand(1000)}"
end

# Race condition in action
5.times.map { Thread.new { get_connection_unsafe } }.each(&:join)

Memory management pitfalls can occur when storing large objects or objects with external resources. Since Ractor-local storage maintains references to stored objects, they won't be garbage collected until explicitly removed or the Ractor terminates.

# PROBLEMATIC: Accumulating memory without cleanup
def cache_large_result(key, data)
  cache = Ractor.store_if_absent(:result_cache) { {} }
  cache[key] = data  # Large data accumulates indefinitely
end

# BETTER: Implementing size-limited cache with explicit cleanup
def cache_with_limit(key, data, max_size = 1000)
  cache = Ractor.store_if_absent(:bounded_cache) { {} }
  
  if cache.size >= max_size
    # Remove oldest entries (simplified LRU)
    cache.shift(cache.size - max_size + 1)
  end
  
  cache[key] = data
end

# BEST: Using proper cache object with automatic cleanup
def cache_with_proper_management(key, data)
  cache = Ractor.store_if_absent(:lru_cache) do
    LRUCache.new(max_size: 1000, ttl: 3600)
  end
  cache[key] = data
end

Incorrect assumptions about storage visibility across Ractor boundaries lead to synchronization problems. Developers sometimes expect stored objects to be visible to child Ractors or assume that changes in one Ractor affect another.

# INCORRECT ASSUMPTION: Child Ractors inherit parent storage
Ractor[:shared_config] = { timeout: 30, retries: 5 }

child_ractor = Ractor.new do
  # This will be nil - storage is not inherited
  config = Ractor[:shared_config]  # => nil
  config || "No config found"
end

puts child_ractor.take  # => "No config found"

# CORRECT: Pass data explicitly through message passing
config = { timeout: 30, retries: 5 }
child_ractor = Ractor.new(config) do |passed_config|
  Ractor[:local_config] = passed_config
  "Config received: #{Ractor[:local_config][:timeout]} second timeout"
end

puts child_ractor.take  # => "Config received: 30 second timeout"

Debugging pitfalls arise when trying to inspect storage contents from outside the owning Ractor. Standard debugging techniques like printing variable values don't work across Ractor boundaries.

# PROBLEMATIC: Cannot debug storage from outside
def debug_ractor_state(ractor_instance)
  # This will fail - cannot access another Ractor's storage
  begin
    puts ractor_instance[:debug_info]
  rescue RuntimeError
    puts "Cannot access remote Ractor storage for debugging"
  end
end

# SOLUTION: Build debugging into Ractor's message protocol
debugging_ractor = Ractor.new do
  Ractor[:processing_count] = 0
  Ractor[:error_count] = 0
  
  loop do
    message = Ractor.receive
    
    case message[:type]
    when :work
      Ractor[:processing_count] += 1
      # Process work...
      
    when :debug
      debug_info = {
        processed: Ractor[:processing_count],
        errors: Ractor[:error_count],
        storage_keys: Ractor.instance_variable_get(:@storage)&.keys
      }
      Ractor.yield debug_info
      
    when :shutdown
      break
    end
  end
end

# Request debug info through message protocol
debugging_ractor.send({ type: :debug })
debug_data = debugging_ractor.take
puts "Debug data: #{debug_data}"

Reference

Class Methods

Method Parameters Returns Description
Ractor[key] key (Object) Object or nil Retrieves value from current Ractor's local storage
Ractor[key] = value key (Object), value (Object) Object Sets value in current Ractor's local storage
Ractor.store_if_absent(key) { block } key (Object), block (Proc) Object Atomically initializes storage key with block result if not present
Ractor.main? None Boolean Returns true if current Ractor is the main Ractor
Ractor.current None Ractor Returns the current Ractor instance

Instance Methods (Deprecated)

Method Parameters Returns Description
ractor[key] key (Object) Object or nil Deprecated: Use Ractor[key] instead
ractor[key] = value key (Object), value (Object) Object Deprecated: Use Ractor[key] = value instead

Storage Characteristics

Property Behavior
Isolation Each Ractor maintains completely separate storage namespace
Thread Safety Access from multiple threads within same Ractor requires synchronization
Key Types Any Ruby object can serve as a key (symbols recommended for performance)
Value Types Any Ruby object can be stored as a value
Memory Management Values remain referenced until explicitly removed or Ractor terminates
Inheritance Child Ractors do not inherit parent Ractor's storage
Cross-Ractor Access Attempting to access another Ractor's storage raises RuntimeError

Atomic Operations

Operation Thread Safe Atomic Notes
Ractor[key] No Yes Single read operation is atomic
Ractor[key] = value No Yes Single write operation is atomic
Ractor[key] ||= value No No Read-check-write sequence has race conditions
Ractor.store_if_absent(key) { block } Yes Yes Guaranteed single execution of initialization block

Error Conditions

Error Type When Raised Example
RuntimeError Accessing non-current Ractor's storage other_ractor[:key]
NoMethodError Calling undefined methods on stored values Ractor[:nil_value].undefined_method
ThreadError Thread synchronization issues in stored objects stored_mutex.lock from wrong thread

Performance Characteristics

Operation Time Complexity Notes
Read access O(1) Hash-based key lookup
Write access O(1) Direct assignment
Atomic initialization O(1) + block execution time Includes internal synchronization overhead
Storage enumeration Not supported No built-in iteration over storage keys

Integration Patterns

Pattern Use Case Implementation
Per-Ractor Singleton Shared resources within Ractor store_if_absent with expensive object creation
Thread-Safe Counter Statistics and metrics Combine storage with Mutex synchronization
Configuration Management Per-Ractor settings Store configuration objects in :config key
Connection Pooling Database connections Store connection pool objects with proper cleanup
Caching Memoization and performance Store cache objects with size limits and TTL

Best Practices

Practice Rationale Example
Use symbols for keys Better performance and memory usage Ractor[:config] vs Ractor["config"]
Implement proper cleanup Prevent memory leaks Size-limited caches, explicit resource disposal
Synchronize mutable objects Ensure thread safety within Ractor Wrap collections with Mutex
Use atomic initialization Prevent race conditions store_if_absent instead of ||=
Design for message passing Maintain Ractor isolation Pass data through messages, not storage access