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