CrackedRuby logo

CrackedRuby

Shareable Objects

A comprehensive guide to Ruby's Shareable Objects feature for safe concurrent object sharing between Ractor instances.

Concurrency and Parallelism Ractors
6.2.3

Overview

Shareable Objects represent Ruby's approach to safe concurrent programming within the Ractor parallelism model. When objects are shared between Ractor instances, Ruby typically creates deep copies to prevent data races and maintain thread safety. Shareable Objects bypass this copying mechanism by guaranteeing immutability or thread-safe access patterns.

Ruby categorizes objects into shareable and non-shareable types based on their mutability characteristics. Shareable objects include frozen strings, numbers, symbols, true, false, nil, and specifically marked frozen objects. The Ractor system enforces these sharing rules at runtime, raising Ractor::IsolationError when non-shareable objects cross Ractor boundaries.

The sharing mechanism operates through Ruby's object inspection system. When an object passes between Ractors, Ruby examines its shareability status. Shareable objects transfer directly without copying, while non-shareable objects trigger deep copying or raise isolation errors depending on the transfer context.

# Shareable objects transfer directly
number = 42
string = "hello".freeze
ractor = Ractor.new(number, string) do |n, s|
  puts "#{s} #{n}"
end

# Non-shareable objects trigger copying or errors
mutable_array = [1, 2, 3]
# Ractor.new(mutable_array) # Would raise Ractor::IsolationError

The shareability status affects performance significantly in concurrent applications. Shareable objects enable zero-copy transfers, reducing memory pressure and garbage collection overhead. Applications processing large datasets or requiring low-latency communication between parallel workers benefit substantially from maximizing shareable object usage.

# Performance comparison demonstration
data = (1..1000).to_a.freeze
shareable_data = Ractor.make_shareable(data)

# Zero-copy transfer with shareable data
fast_ractor = Ractor.new(shareable_data) { |arr| arr.sum }

# Deep copy required for non-shareable data
slow_ractor = Ractor.new(data.dup) { |arr| arr.sum }

Basic Usage

Creating shareable objects requires understanding Ruby's built-in shareable types and explicit shareability marking. Immutable objects like frozen strings, symbols, and numeric types achieve shareability automatically. Complex objects require the Ractor.make_shareable method or careful construction with shareable components.

# Automatically shareable types
num = 100
sym = :symbol
frozen_str = "text".freeze
bool_val = true

# Making arrays shareable
data = [1, 2, 3]
shareable_data = Ractor.make_shareable(data)

ractor = Ractor.new(shareable_data) do |arr|
  arr.map { |x| x * 2 }
end

result = ractor.take

The Ractor.make_shareable method recursively freezes objects and their contents, ensuring deep immutability. This operation modifies the original object, making it permanently immutable. Objects already frozen remain unchanged, while mutable objects undergo recursive freezing.

# Recursive freezing behavior
hash = { name: "John", scores: [95, 87, 92] }
shareable_hash = Ractor.make_shareable(hash)

# Original object is now frozen
hash.frozen?           # => true
hash[:scores].frozen?  # => true

Shareable objects enable efficient parallel processing patterns. Workers can access shared reference data without copying overhead, while maintaining thread safety guarantees. This pattern works particularly well for read-only configuration data, lookup tables, and immutable computational results.

# Shared reference data pattern
config = Ractor.make_shareable({
  database_url: "postgresql://localhost/app",
  cache_ttl: 3600,
  worker_count: 8
})

workers = 4.times.map do |i|
  Ractor.new(config, i) do |conf, worker_id|
    puts "Worker #{worker_id} using #{conf[:database_url]}"
    # Simulate work with shared configuration
    sleep(rand(1..3))
    "Worker #{worker_id} completed"
  end
end

results = workers.map(&:take)

Class and module objects require special consideration for shareability. Classes defined at the top level typically become shareable, while dynamically created classes or those with mutable state remain non-shareable. Constants referencing shareable objects become shareable themselves.

# Class shareability patterns
class SharedProcessor
  SHARED_CONFIG = Ractor.make_shareable({ threads: 4, timeout: 30 })
  
  def self.process(data)
    # Class methods accessible from any Ractor
    data.sum
  end
end

# Using shareable classes in Ractors
processor_ractor = Ractor.new do
  SharedProcessor.process([1, 2, 3, 4, 5])
end

result = processor_ractor.take

Thread Safety & Concurrency

Shareable Objects provide thread safety through immutability guarantees rather than locking mechanisms. Once an object becomes shareable, Ruby ensures no mutable operations can occur, eliminating race conditions and data corruption possibilities. This approach enables lock-free concurrent programming patterns with deterministic behavior.

The Ractor isolation model enforces strict boundaries around shareable object access. Each Ractor maintains its own object space, but shareable objects exist in a special shared space accessible to all Ractors. Ruby's garbage collector handles shared objects specially, ensuring proper cleanup when no Ractor references remain.

# Lock-free concurrent counter using shareable state
class SharedCounter
  def self.create_counter
    Ractor.make_shareable({
      count: 0,
      increment: -> (current) { current + 1 }
    })
  end
end

counter_state = SharedCounter.create_counter

# Multiple Ractors can safely access shared logic
ractors = 3.times.map do |i|
  Ractor.new(counter_state, i) do |state, worker_id|
    current_count = state[:count]
    new_count = state[:increment].call(current_count)
    "Worker #{worker_id}: #{current_count} -> #{new_count}"
  end
end

results = ractors.map(&:take)

Synchronization between Ractors occurs through message passing rather than shared mutable state. Shareable objects facilitate efficient message passing by avoiding copy overhead. Complex synchronization patterns emerge from combining shareable objects with Ractor communication primitives.

# Coordinator pattern with shareable configuration
coordinator_config = Ractor.make_shareable({
  batch_size: 100,
  timeout: 5.0,
  retry_count: 3
})

# Coordinator Ractor manages work distribution
coordinator = Ractor.new(coordinator_config) do |config|
  workers = []
  
  # Spawn worker Ractors with shared configuration
  4.times do |i|
    worker = Ractor.new(config, i) do |conf, worker_id|
      loop do
        batch = Ractor.receive
        break if batch == :shutdown
        
        # Process batch with shared configuration
        results = batch.map { |item| item * 2 }
        Ractor.yield({ worker_id: worker_id, results: results })
      end
    end
    workers << worker
  end
  
  # Distribute work batches
  work_items = (1..400).to_a.each_slice(config[:batch_size])
  work_items.each do |batch|
    Ractor.select(*workers.map { |w| [w, batch] })
  end
  
  # Signal shutdown
  workers.each { |w| w.send(:shutdown) }
  workers.map(&:take)
end

Race condition prevention occurs automatically with properly designed shareable objects. Since shareability requires immutability, concurrent access cannot produce inconsistent states. However, Ractor communication itself introduces timing dependencies that require careful coordination.

# Safe concurrent data processing with shared utilities
processing_utils = Ractor.make_shareable({
  sanitize: ->(text) { text.strip.downcase },
  validate: ->(email) { email.include?('@') },
  transform: ->(data) { data.map(&:to_s) }
})

# Multiple processing pipelines
processors = 2.times.map do |i|
  Ractor.new(processing_utils, i) do |utils, processor_id|
    loop do
      data_batch = Ractor.receive
      break if data_batch == :done
      
      # Use shared utilities safely
      sanitized = data_batch.map(&utils[:sanitize])
      validated = sanitized.select(&utils[:validate])
      transformed = utils[:transform].call(validated)
      
      Ractor.yield({ processor: processor_id, result: transformed })
    end
  end
end

Performance & Memory

Shareable Objects deliver significant performance benefits by eliminating object copying overhead in Ractor communication. Deep copying large data structures consumes substantial CPU cycles and memory allocation, while shareable objects transfer via reference sharing. Applications processing megabytes of data see dramatic improvements in both throughput and memory efficiency.

Memory usage patterns differ substantially between copied and shared objects. Copied objects create duplicate memory allocations proportional to data size and Ractor count. Shared objects maintain single memory allocations regardless of sharing frequency, reducing total memory consumption and garbage collection pressure.

# Memory usage comparison
require 'benchmark'

# Large dataset preparation
large_data = (1..100_000).map { |i| { id: i, data: "item_#{i}" * 10 } }
shareable_data = Ractor.make_shareable(large_data)

# Memory-efficient sharing
Benchmark.bm(20) do |x|
  x.report("Shareable transfer:") do
    ractors = 5.times.map do
      Ractor.new(shareable_data) { |data| data.count }
    end
    ractors.map(&:take)
  end
  
  x.report("Copy transfer:") do
    ractors = 5.times.map do
      Ractor.new(Marshal.load(Marshal.dump(large_data))) { |data| data.count }
    end
    ractors.map(&:take)
  end
end

Garbage collection behavior changes with shareable objects due to their special memory management requirements. Shared objects exist in a separate memory space that requires coordinated garbage collection across all Ractors. This coordination introduces periodic synchronization points but eliminates duplicate object collection overhead.

# GC impact demonstration
class MemoryTracker
  def self.measure_gc
    before_collections = GC.count
    before_memory = GC.stat[:total_allocated_objects]
    
    yield
    
    after_collections = GC.count
    after_memory = GC.stat[:total_allocated_objects]
    
    {
      gc_runs: after_collections - before_collections,
      objects_allocated: after_memory - before_memory
    }
  end
end

# Measure GC impact with shareable vs copied data
shared_dataset = Ractor.make_shareable((1..10_000).to_a)

shared_stats = MemoryTracker.measure_gc do
  10.times.map do
    Ractor.new(shared_dataset) { |data| data.sum }
  end.map(&:take)
end

copied_stats = MemoryTracker.measure_gc do
  original_data = (1..10_000).to_a
  10.times.map do
    Ractor.new(Marshal.load(Marshal.dump(original_data))) { |data| data.sum }
  end.map(&:take)
end

Cache locality improvements occur when multiple Ractors access the same shareable objects. Shared memory pages remain in CPU cache longer due to repeated access patterns, reducing memory fetch latency. Applications with hot data paths benefit significantly from this cache efficiency.

Processing throughput scales more predictably with shareable objects because memory allocation becomes the limiting factor less frequently. Traditional concurrent Ruby applications often hit memory allocation bottlenecks when spawning many threads or processes, while shareable objects enable linear throughput scaling up to CPU core limits.

# Throughput scaling demonstration
def process_workload(data, worker_count)
  start_time = Time.now
  
  workers = worker_count.times.map do |i|
    Ractor.new(data, i) do |dataset, worker_id|
      # Simulate CPU-intensive processing
      result = dataset.select { |x| x % (worker_id + 1) == 0 }
      result.sum
    end
  end
  
  results = workers.map(&:take)
  end_time = Time.now
  
  {
    worker_count: worker_count,
    processing_time: end_time - start_time,
    results_count: results.size
  }
end

# Compare scaling characteristics
shared_data = Ractor.make_shareable((1..50_000).to_a)
worker_counts = [1, 2, 4, 8]

scaling_results = worker_counts.map do |count|
  process_workload(shared_data, count)
end

Common Pitfalls

Freezing side effects catch many developers off guard when using Ractor.make_shareable. This method permanently modifies objects, making them immutable even in the original context. Code expecting mutable behavior after sharing operations fails with FrozenError exceptions.

# Unexpected freezing behavior
user_data = { name: "Alice", scores: [85, 92, 78] }
shared_data = Ractor.make_shareable(user_data)

# Original object is now frozen
begin
  user_data[:name] = "Bob"
rescue FrozenError => e
  puts "Cannot modify: #{e.message}"
end

# Nested objects also frozen
begin
  user_data[:scores] << 95
rescue FrozenError => e
  puts "Cannot modify nested: #{e.message}"
end

Object reference sharing creates unexpected behavior when developers assume object copying semantics. Multiple Ractors receive references to the same object instance, not independent copies. Mutations to non-frozen portions affect all Ractors sharing the object.

# Shared reference confusion
class MutableContainer
  attr_accessor :data
  
  def initialize
    @data = []
  end
end

container = MutableContainer.new
# This doesn't make the container shareable, just passes reference
ractor1 = Ractor.new(container) do |c|
  # This would raise Ractor::IsolationError
  # c.data << "from ractor1"
end

Circular reference handling in Ractor.make_shareable sometimes produces unexpected results. Objects containing circular references may not freeze completely or may raise errors during the sharing process. Complex object graphs require careful analysis before sharing attempts.

# Circular reference problems
parent = { children: [] }
child = { parent: parent }
parent[:children] << child

# This creates a circular reference
begin
  shareable_parent = Ractor.make_shareable(parent)
  puts "Sharing succeeded"
rescue StandardError => e
  puts "Sharing failed: #{e.message}"
end

Proc and lambda shareability limitations surprise developers expecting closures to work across Ractor boundaries. Closures capture lexical scope, which often contains non-shareable objects. Even when the closure itself appears shareable, captured variables may prevent sharing.

# Closure capture issues
def create_processor(multiplier)
  local_config = { factor: multiplier, debug: false }
  
  # This proc captures local_config
  processor = ->(value) { value * local_config[:factor] }
  
  begin
    Ractor.make_shareable(processor)
  rescue Ractor::IsolationError => e
    puts "Cannot share closure: #{e.message}"
  end
end

# Alternative: explicit shareable closure
def create_shareable_processor(multiplier)
  config = Ractor.make_shareable({ factor: multiplier })
  ->(value, shared_config = config) { value * shared_config[:factor] }
end

Class variable and instance variable access from shareable objects leads to isolation violations. Ruby prevents Ractors from accessing variables that could create data races, even when the accessing object is shareable. This restriction applies to methods that read or write instance state.

# Variable access restrictions
class SharedCalculator
  @@global_config = { precision: 2 }
  
  def initialize
    @instance_data = []
  end
  
  def calculate(value)
    # This would raise an error if called from another Ractor
    # @@global_config[:precision]
    value.round(2)  # Hard-coded instead
  end
end

calculator = SharedCalculator.new
# Even if we make it shareable, method calls accessing variables fail
begin
  shared_calc = Ractor.make_shareable(calculator)
  
  ractor = Ractor.new(shared_calc) do |calc|
    calc.calculate(3.14159)  # May raise isolation error
  end
rescue Ractor::IsolationError => e
  puts "Variable access prevented: #{e.message}"
end

Method visibility changes affect shareable object behavior in subtle ways. Private and protected methods may become inaccessible when objects cross Ractor boundaries, depending on the calling context. This visibility restriction can break existing object interfaces.

# Method visibility complications
class DataProcessor
  def process(data)
    validate_input(data)
    transform_data(data)
  end
  
  private
  
  def validate_input(data)
    raise ArgumentError unless data.is_a?(Array)
  end
  
  def transform_data(data)
    data.map(&:to_s)
  end
end

processor = Ractor.make_shareable(DataProcessor.new)

# Private method access may fail across Ractor boundaries
ractor = Ractor.new(processor) do |proc|
  begin
    proc.process([1, 2, 3])
  rescue StandardError => e
    puts "Method access failed: #{e.message}"
  end
end

Reference

Core Methods

Method Parameters Returns Description
Ractor.make_shareable(obj) obj (Object) Object Recursively freezes object and marks as shareable
Ractor.shareable?(obj) obj (Object) Boolean Checks if object is shareable
obj.frozen? None Boolean Checks if object is frozen
obj.freeze None Object Freezes object in place

Automatically Shareable Types

Type Example Notes
Numeric 42, 3.14, 1r All numeric types shareable
Symbol :symbol, :key Immutable by nature
Boolean true, false Singleton values
Nil nil Singleton value
Frozen String "text".freeze Must be explicitly frozen
Class String, Array Top-level classes only
Module Enumerable, Math Top-level modules only

Non-Shareable Objects

Type Reason Workaround
Mutable String Can be modified Use string.freeze
Array Contains mutable elements Use Ractor.make_shareable(array)
Hash Contains mutable key/values Use Ractor.make_shareable(hash)
Object instances May have mutable state Design immutable objects
Proc with captures Captures mutable scope Create closures without captures
IO objects External state dependencies Pass serialized data instead

Error Types

Error Trigger Condition Resolution Strategy
Ractor::IsolationError Non-shareable object passed to Ractor Use Ractor.make_shareable or redesign
FrozenError Mutation of frozen shareable object Create new objects instead of modifying
ArgumentError Invalid shareability operation Check object structure and references

Shareability Decision Matrix

Object State Contains Shareable Only Contains Non-Shareable Result
Already Frozen Shareable Non-shareable Based on contents
Mutable Non-shareable Non-shareable Use make_shareable
Empty Container Shareable after freezing N/A Shareable

Performance Characteristics

Operation Shareable Objects Non-Shareable Objects Impact Factor
Ractor Creation O(1) reference O(n) deep copy Linear with size
Memory Usage Single allocation Multiple allocations Multiplied by Ractor count
GC Pressure Shared collection Individual collection Reduced by sharing factor
Cache Efficiency High locality Low locality Depends on access pattern

Common Patterns

# Configuration sharing pattern
CONFIG = Ractor.make_shareable({
  database_url: ENV['DATABASE_URL'],
  cache_ttl: 3600,
  worker_settings: { timeout: 30, retries: 3 }
})

# Immutable data structure pattern
ImmutableList = Data.define(:items) do
  def self.create(items)
    new(Ractor.make_shareable(items.freeze))
  end
  
  def add(item)
    self.class.new(items + [item])
  end
end

# Factory method pattern
class SharedFactory
  def self.create_processor(config)
    shareable_config = Ractor.make_shareable(config)
    ->(data) { process_with_config(data, shareable_config) }
  end
  
  private_class_method def self.process_with_config(data, config)
    # Processing logic using immutable config
  end
end

# Message passing pattern
def create_worker_pool(size, shared_resources)
  size.times.map do |i|
    Ractor.new(shared_resources, i) do |resources, worker_id|
      loop do
        task = Ractor.receive
        break if task == :shutdown
        
        result = process_task(task, resources)
        Ractor.yield(result)
      end
    end
  end
end