Excerpt
# Ruby on Ractors: Parallel Execution Done Beautifully

Dave Russell
6 min read
·
Apr 9, 2025
- -
Listen
Share
> A practical deep dive into Ruby’s new concurrency model.
Press enter or click to view image in full size
## Introduction
Ruby developers have always had a bit of a love-hate relationship with concurrency.
On one hand, Threads gave us the illusion of doing multiple things at once. On the other, the Global Interpreter Lock (GIL) meant we were really just watching our code take turns — politely, but sequentially.
Meanwhile, CPUs got more cores. Web apps got more traffic. Background jobs exploded in number. And we started looking longingly at languages like Elixir and Go, where concurrency wasn’t a bolt-on afterthought.
Enter Ractors — Ruby’s bold answer to safe, parallel execution. They’re weird, they’re wonderful, and they just might change the way you write Ruby.
But fair warning: Racto
# Ruby on Ractors: Parallel Execution Done Beautifully

Dave Russell
6 min read
·
Apr 9, 2025
- -
Listen
Share
> A practical deep dive into Ruby’s new concurrency model.
Press enter or click to view image in full size
## Introduction
Ruby developers have always had a bit of a love-hate relationship with concurrency.
On one hand, Threads gave us the illusion of doing multiple things at once. On the other, the Global Interpreter Lock (GIL) meant we were really just watching our code take turns — politely, but sequentially.
Meanwhile, CPUs got more cores. Web apps got more traffic. Background jobs exploded in number. And we started looking longingly at languages like Elixir and Go, where concurrency wasn’t a bolt-on afterthought.
Enter Ractors — Ruby’s bold answer to safe, parallel execution. They’re weird, they’re wonderful, and they just might change the way you write Ruby.
But fair warning: Ractors aren’t Threads. They’re not even close. They’re something entirely new.
So let’s dig in. What are Ractors, really? What do they solve? And how can you actually use them without pulling your hair out?
## What are Ractors?
A Ractor (short for “Ruby Actor”) is Ruby’s built-in primitive for achieving true parallel execution, something traditional Threads couldn’t offer because of the Global Interpreter Lock (GIL).
Each Ractor runs in its own isolated Ruby VM, complete with its own GIL. That means two Ractors can literally run at the same time on separate CPU cores, a genuine “parallel” execution model.
But to make this possible (and safe), Ractors don’t share state. At all. You can’t pass an object from one Ractor to another unless it’s immutable, shareable, or explicitly transferred. Communication happens via message passing, using send and receive, like actors in a play tossing notes across the stage.
Let’s look at a trivial problem and see how a Ractor can help.
Say you want to calculate the square of several numbers — not a complicated task, but let’s pretend it’s a stand-in for something CPU-intensive, like image processing or cryptographic hashing. Here’s how you might spin up a Ractor to do that in parallel:
```plain text
squared_ractor = Ractor.new do
loop do
num = Ractor.receive
break if num == :stop
Ractor.yield num ** 2
end
end
[3, 5, 9].each do |n|
squared_ractor.send(n)
puts "Squared: #{squared_ractor.take}"
end
squared_ractor.send(:stop)
```
Why use a Ractor here? Because in a real-world version of this:
- Each calculation might take time (e.g. hashing a large file),
- You could spin up multiple worker Ractors on multiple cores, each chewing through a queue of jobs in parallel,
- And your main thread stays responsive, focused on coordination rather than heavy lifting.
The beauty is in the structure: you’re not managing locks, mutexes, or worrying about two threads trying to mutate the same object. You’re just sending and receiving messages — like mail between postboxes that never open each other’s doors.
## Why Do We Need Ractors?
To understand why Ractors matter, we first have to understand Ruby’s long-standing problem with concurrency.
Ruby’s threading model is… complicated. It has native threads (Thread.new), but the Global Interpreter Lock (GIL) means only one thread can execute Ruby code at a time. Even on an 8-core machine, your threads take turns — not because they’re polite, but because they’re forced to.
This is fine for IO-heavy tasks. But if you want to actually use those eight CPU cores for some serious number crunching? Tough luck.
That’s where people usually reach for one of two workarounds:
1. Multiple Processes — Using fork, Process.spawn, or libraries like Sidekiq. Each process gets its own memory and GIL, so they run in parallel. But they’re heavy, slow to spin up, and hard to coordinate.
2. Native Extensions — Write the performance-critical bits in C, Rust, or another language that can run in parallel. This works, but it’s not exactly “Ruby-friendly.”
Ractors offer a third way: true parallelism in Ruby, without shared state, and without turning your architecture into a haunted house of zombie processes.
Use Threads when you’re mostly waiting on IO. Use Ractors when you’re doing work — especially if that work is CPU-heavy or could benefit from parallel execution.
## How Ractors Work
Each Ractor has:
- Its own Ruby virtual machine
- Its own thread and GIL
- Its own heap (memory space)
- No access to the outside world — unless you send it something
## Communication: send, receive, yield, take
```plain text
doubler = Ractor.new do
loop do
value = Ractor.receive
break if value == :stop
Ractor.yield value * 2
end
end
[10, 20, 30].each do |num|
doubler.send(num)
result = doubler.take
puts "#{num} doubled is #{result}"
end
doubler.send(:stop)
```
## Isolation and Shareability
You can’t send an object to a Ractor in its normal, mutable state, e.g.
```plain text
my_array = [1, 2, 3]
inspector = Ractor.new do
puts Ractor.receive.inspect
end
inspector.send(my_array) # => Ractor::RemoteError
```
To give access to inspector you can either freeze the array:
```plain text
inspector.send(my_array.freeze)
```
Or transfer ownership:
```plain text
inspector.send(my_array, move: true)
```
You can check if an object can be sent to a Ractor with #shareable?
```plain text
my_array = [1, 2, 3]
Ractor.shareable? my_array # => false
Ractor.shareable? my_array.freeze # => true
```
## Ractor.select
Think of Ractor.select like sending the same Slack message to two colleagues and waiting to see who replies first. You don’t care who it is — you just want someone to answer.
Let’s model that with Ractors:
```plain text
alice = Ractor.new do
sleep rand(0.1..0.3) # Simulate unpredictable reply time
Ractor.yield "Hey, it's Alice!"
end
bob = Ractor.new do
sleep rand(0.1..0.3)
Ractor.yield "Yo, Bob here."
end
responder, reply = Ractor.select(alice, bob)
puts "Received message: #{reply} (from #{responder.inspect})"
```
### What’s going on?
- We send the same question to both alice and bob.
- They each respond after a random delay (simulating real-world unpredictability).
- Ractor.select waits for whichever one replies first and returns: — The Ractor that responded — The message they sent
You might get Alice. You might get Bob. You won’t know until runtime — and that’s the point. This kind of setup is ideal when you have multiple workers and want to process results as they come in, not in a fixed order.
## Real-Life Use Cases
### Parallel Data Processing:
```plain text
lines = CSV.read('my_big_ass_file.csv')
chunks = lines.each_slice(1000).to_a
ractors = chunks.map do |chunk|
Ractor.new(chunk.freeze) do |data|
data.map { |row| row.map(&:upcase) }
end
end
results = ractors.map(&:take).flatten
```
### Math-Heavy Tasks
```plain text
ractors = inputs.map do |input|
Ractor.new(input) { |n| heavy_calculation(n) }
end
results = ractors.map(&:take)
```
### API Fan-Out
```plain text
ractors = urls.map do |url|
Ractor.new(url.freeze) do |u|
require 'net/http'
URI(u).then { |uri| Net::HTTP.get(uri) }
end
end
responses = ractors.map(&:take)
```
### Actor-Based Systems
```plain text
player = Ractor.new do
hp = 100
loop do
msg = Ractor.receive
case msg
when :damage then hp -= 10; Ractor.yield "Ouch! HP: #{hp}"
when :heal then hp += 5; Ractor.yield "Feeling better. HP: #{hp}"
end
end
end
```
## Pros and Cons
Pros:
- True parallelism
- Built-in safety
- Message-passing clarity
- Auto-cleanup
Cons:
- Most objects not shareable
- Steep learning curve
- Performance overhead for small tasks
- Poor debugging
## Gotchas and Caveats
- Most objects aren’t shareable by default
- Global variables and constants are inaccessible
- Ownership transfer errors (Ractor::MovedError)
- No shared logging, caching, or DB connections
- Exceptions from Ractors re-raise as Ractor::RemoteError
## Patterns and Anti-Patterns
### DO: Long-Lived Workers
Loop, receive, yield, repeat.
## DO NOT: Spinning Up a Ractor Per Item
Use a Ractor pool instead.
## DO: Ractor Pool
Create 4 workers and assign jobs in round-robin.
## DO NOT: Sharing Globals
Pass them in or re-instantiate.
## DO: Supervisors
Spawn Ractors from another Ractor. Restart on crash.
## DO: Pipelines
Chain Ractors together to build transformation pipelines.
## The Future of Ractors
- Better error reporting and debugging
- More Ractor-safe standard libs and gems
- Efficient data sharing and memory handling
- Potential for actor-model frameworks in Ruby
## Final Thoughts
Ractors are the boldest step Ruby’s taken toward real parallelism. They won’t replace Threads or Processes, but they offer something new:
- Parallelism with safety
- Structure without spaghetti
- Performance for those who need it
Try them. Break things. Log everything. Build something amazing.
## What Next?
Leave a comment if you’ve used Ractors in the wild — or if you’re still waiting for a good reason to try. And if this article helped, share it with someone who still thinks Ruby is single-threaded forever.
Cheers!