Ruby on Ractors: Parallel Execution Done Beautifully | by Dave Russell | Medium

Ask questions Research chat →

https://medium.com/@dave_russell/ruby-on-ractors-parallel-execution-done-beautifully-c05a09d22102 · scraped

ruby

Attachments

Scraped Content

— 1434 words · 2026-05-19 19:26:26 UTC ·

Excerpt

# Ruby on Ractors: Parallel Execution Done Beautifully ![](https://miro.medium.com/v2/da:true/resize:fill:64:64/0*DG5jq16w-HneCzb7) 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 ![](https://miro.medium.com/v2/da:true/resize:fill:64:64/0*DG5jq16w-HneCzb7) 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!

Visibility

Visible to everyone

Reading Status

Related Bookmarks

My Note


Saved!

Annotations

Export as Markdown
+ Annotate selection

Add Annotation