Ractor — True Parallelism in Ruby 3 Without the Shared State Nightmare
Ruby’s GIL — the Global Interpreter Lock — has been the punchline of many conversations about concurrency for a long time. The lock means that even with multiple threads, only one can execute Ruby code at a time. For CPU-bound work, threads in MRI Ruby aren’t actually parallel. Ractor, introduced experimentally in Ruby 3.0 and maturing through subsequent releases, changes this. It’s Ruby’s answer to the question developers have been asking for years: how do I use all my CPU cores?
What Ractor Is
Ractor is a concurrency primitive that provides true parallelism by running each Ractor in its own GIL context. Multiple Ractors can execute Ruby code simultaneously across CPU cores without the GIL blocking them. This is a fundamentally different guarantee from threads.
The catch — and it’s a deliberate one — is that Ractors cannot share mutable state. Two Ractors cannot both have a reference to the same mutable object. This constraint is what makes the parallelism safe: there’s no shared state to corrupt, so there’s no need for locks.
This is the tradeoff Ruby made. Erlang and Elixir made the same one. It eliminates whole classes of concurrency bugs at the cost of requiring you to think more carefully about how data moves between execution units.
Creating and Running a Ractor
Example:
r = Ractor.new do
sum = (1..1_000_000).sum
sum
end
result = r.take # blocks until the Ractor finishes
puts result # => 500000500000
Ractor.new accepts a block and starts executing it in a separate GIL context. take retrieves the last evaluated value from the Ractor. Between creation and take, other code can run.
For parallel computation across multiple Ractors:
Example:
ranges = [(1..250_000), (250_001..500_000), (500_001..750_000), (750_001..1_000_000)]
ractors = ranges.map do |range|
Ractor.new(range) { |r| r.sum }
end
total = ractors.sum(&:take)
puts total # => 500000500000
Four Ractors, four CPU cores, one-quarter the wall-clock time. The main thread collects results with take. This is genuine CPU parallelism in Ruby.
Communication: Send and Receive
Ractors communicate by passing messages through channels — a push/pull model rather than shared memory.
Example:
worker = Ractor.new do
loop do
input = Ractor.receive
break if input == :done
Ractor.yield(input * 2)
end
end
worker.send(5)
puts worker.take # => 10
worker.send(21)
puts worker.take # => 42
worker.send(:done)
Ractor.receive blocks until a message arrives. Ractor.yield sends a value back to whoever calls take. This message-passing model is safe by design — values are either moved (transferred ownership) or copied when crossing Ractor boundaries.
Shareable vs. movable objects
Not all objects can cross Ractor boundaries the same way:
- Shareable: frozen objects, integers, symbols,
true,false,nil— can be referenced from multiple Ractors simultaneously - Movable: mutable objects get transferred to the receiving Ractor; the sending Ractor loses access
Ractor.make_shareable(obj)deep-freezes an object so it can be shared safely
Example:
config = { timeout: 30, retries: 3 }.freeze
Ractor.make_shareable(config)
workers = 4.times.map do
Ractor.new(config) do |cfg|
# cfg is shareable — no copy, no transfer
cfg[:timeout]
end
end
puts workers.map(&:take).inspect # => [30, 30, 30, 30]
A Real Use Case: Parallel File Processing
Example:
files = Dir.glob("data/*.csv")
processors = files.map do |path|
Ractor.new(path) do |file_path|
content = File.read(file_path)
rows = content.lines.count
[file_path, rows]
end
end
results = processors.map(&:take)
results.each { |path, count| puts "#{path}: #{count} rows" }
For I/O-bound tasks, Fiber Scheduler is the better tool. But when processing involves real CPU work — parsing, aggregating, transforming — Ractor distributes that work across cores.
Pro-Tip: Ractor is still marked experimental in some capabilities, and not all standard library classes are Ractor-safe yet. Before adopting Ractor in production, audit your dependencies. Run
Ractor.new { require 'some_gem' }in a test and watch forRactor::UnsafeError. The error messages are explicit about what’s unsafe and why — use them as a migration guide rather than a hard stop.
What You Can’t Do (and Why It’s Fine)
Global variables, class variables, and non-frozen class-level state are off-limits inside Ractors. This initially feels restrictive, but it forces a design where computation is genuinely stateless — input goes in, output comes out, nothing persists sideways.
For many algorithmic problems, data pipelines, and batch processing jobs, this constraint maps naturally onto the problem. The work is pure computation: transform this data and give me a result. Ractor is an excellent fit.
For stateful coordination — something that needs to mutate shared data — look at Mutex with threads, or redesign around message passing. Ractor won’t bend to fit stateful patterns; you adapt the pattern.
Conclusion
Ractor is a genuine shift in what Ruby can do with CPU-bound workloads. It’s not a drop-in replacement for threads — the isolation model requires real design thought. But for applications that have been hitting the GIL ceiling, or for batch processing jobs that could be faster with more cores, Ractor opens a door that’s been closed in MRI Ruby for a long time. The ecosystem is still maturing, and some rough edges remain, but the underlying model is sound. Worth learning now.
FAQs
Q1: Is Ractor production-ready?
Partially. The core API is stable, but some standard library classes aren’t Ractor-safe yet. Use it for isolated computational tasks — batch processing, data transformation, parallel parsing — and audit dependencies carefully before adopting it in critical paths.
Q2: How does Ractor differ from Ruby threads?
Threads share a GIL — only one executes Ruby code at a time, even on multi-core machines. Ractors each have their own GIL context and run in parallel on separate cores. Threads share memory freely; Ractors share nothing mutable.
Q3: Can I use ActiveRecord inside a Ractor?
Not without significant work. ActiveRecord relies on class-level state that isn’t Ractor-safe. For database-backed parallelism, threads with connection pooling or separate processes are more practical today.
Q4: What happens if a Ractor raises an exception?
The exception is propagated to whoever calls take on that Ractor. It gets wrapped in a Ractor::RemoteError with the original exception accessible via .cause. Handle it at the take call site.
Q5: Should I use Ractor or Fiber Scheduler for concurrent HTTP requests?
Fiber Scheduler. It’s purpose-built for I/O concurrency — many requests waiting on network responses. Ractor shines for CPU-bound parallelism where actual computation happens in each unit of work. They solve genuinely different problems.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀