Introduction
Ractors enforce memory isolation: each Ractor has its own heap, and mutable objects cannot cross boundaries. This isolation is what makes true parallelism safe — no data races, no need for locks. But it also means you must understand how data moves between Ractors: copying, moving, or sharing frozen objects.
Key Concepts
- Shareable Object: An immutable (deeply frozen) object that multiple Ractors can reference simultaneously. Integers, Symbols,
true,false,nil, frozen Strings, Ractor objects, and Ractor::Port objects are all shareable. - Copying: When you send a mutable object, Ruby deep-copies it so each Ractor has its own independent copy.
- Moving (
move: true): Transfers ownership — the sender loses access and the receiver gets the original object. More efficient than copying for large data. Ractor.make_shareable(obj): Deeply freezes an object graph, making it shareable across all Ractors.Ractor.shareable?(obj): Returnstrueif the object can be shared without copying.
Real World Context
In a parallel web scraper, each Ractor processes different URLs. Configuration data (API keys, rate limits) must be shared across all Ractors. You freeze the config once with Ractor.make_shareable and pass it to every worker. Large response bodies, on the other hand, are moved rather than copied to avoid doubling memory usage.
Deep Dive
Automatically Shareable Types
Certain types are always shareable without any action:
rubyRactor.shareable?(42) # => true (Integer) Ractor.shareable?(:status) # => true (Symbol) Ractor.shareable?(true) # => true (Boolean) Ractor.shareable?(nil) # => true (NilClass) Ractor.shareable?("hello".freeze) # => true (frozen String)
These types are immutable by nature (or frozen explicitly), so multiple Ractors can read them concurrently without risk.
Deep Copying
Mutable objects are deep-copied when sent to a Ractor. The sender retains its original:
rubyoriginal = { users: ["Alice", "Bob"], count: 2 } worker = Ractor.new do data = Ractor.receive data[:users] << "Charlie" # Modifies the COPY only data end worker.send(original) modified = worker.value # => { users: ["Alice", "Bob", "Charlie"], count: 2 } original[:users] # => ["Alice", "Bob"] — unchanged
Ruby walks the entire object graph and duplicates every reachable mutable object. This is safe but can be expensive for large data structures.
Moving Data
For large objects, moving avoids the copy overhead by transferring ownership:
rubylarge_dataset = (1..1_000_000).to_a worker = Ractor.new do data = Ractor.receive data.sum end worker.send(large_dataset, move: true) # large_dataset is now inaccessible! begin large_dataset.size rescue Ractor::MovedError => e puts e.message # => can not send any methods to a moved object end worker.value # => 500000500000
After a move, any access to the original raises Ractor::MovedError. This guarantees no two Ractors ever hold the same mutable reference.
Making Objects Shareable
Ractor.make_shareable deeply freezes an object and all its nested references:
rubyconfig = { database: { host: "db.example.com", port: 5432 }, api_keys: ["key_abc", "key_def"] } Ractor.make_shareable(config) config.frozen? # => true config[:database].frozen? # => true config[:api_keys][0].frozen? # => true Ractor.shareable?(config) # => true # Now safe to pass to any number of Ractors 5.times { Ractor.new(config) { |c| puts c[:database][:host] } }
Pass copy: true to freeze a copy instead of the original: Ractor.make_shareable(obj, copy: true).
Shareable Procs with Ractor.shareable_lambda
Ruby 4.0 introduces Ractor.shareable_lambda and Ractor.shareable_proc for creating Proc objects that can be shared across Ractor boundaries:
ruby# Create a shareable lambda transform = Ractor.shareable_lambda { |x| x * 2 + 1 } Ractor.shareable?(transform) # => true # Pass the lambda to multiple Ractors results = 4.times.map do |i| Ractor.new(i, transform) do |num, fn| fn.call(num) end end results.map(&:value) # => [1, 3, 5, 7]
Normal Procs and lambdas capture their enclosing scope and cannot be shared. Ractor.shareable_lambda creates a lambda that is isolated from its lexical environment — it can only reference shareable values.
Common Pitfalls
- Capturing outer variables in Ractor blocks — The
Ractor.new { }block cannot close over local variables from the outer scope. Pass them as arguments:Ractor.new(data) { |d| ... }. Accessing outer locals raisesRactor::IsolationError. - Forgetting that
make_shareablemutates the original — It freezes the object in place. If you still need a mutable copy, useRactor.make_shareable(obj, copy: true).
Best Practices
- Freeze configuration at boot — Call
Ractor.make_shareableon config hashes, lookup tables, and constant data once during application startup so all Ractors can share them without copying. - Move large temporary data — When a Ractor produces a large intermediate result that will only be consumed by one downstream Ractor, move it instead of copying to halve memory usage.
- Use
shareable_lambdafor reusable transformations — When multiple Ractors need the same pure function, wrap it inRactor.shareable_lambdainstead of duplicating the logic.
Summary
- Ractors enforce memory isolation: no shared mutable state.
- Mutable objects are deep-copied by default; use
move: trueto transfer ownership. Ractor.make_shareable(obj)deeply freezes an object for cross-Ractor sharing.Ractor.shareable?(obj)checks if an object can be shared.Ractor.shareable_lambda { }creates Proc objects safe to share between Ractors.- Accessing a moved object raises
Ractor::MovedError.
Code Examples
# Sharing a frozen lookup table across multiple Ractors
currency_rates = Ractor.make_shareable({
"USD" => 1.0, "EUR" => 0.92, "GBP" => 0.79, "JPY" => 149.5
})
converter = Ractor.shareable_lambda { |amount, from, to|
amount * currency_rates[to] / currency_rates[from]
}
orders = [
{ amount: 100, from: "USD", to: "EUR" },
{ amount: 250, from: "GBP", to: "JPY" }
]
results = orders.map do |order|
Ractor.new(order, converter) do |o, fn|
fn.call(o[:amount], o[:from], o[:to])
end
end
results.each { |r| puts r.value.round(2) } # => 92.0, 47312.5 (approx)