Skip to Content
Course content

151: Ractor for Parallel Execution Revisited

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you've seen the basics of Ractors, but if you've actually tried to use them in a real project, you've probably hit a wall. Ractors aren't just "threads that actually run in parallel"—they are a completely different mental model because of how they handle memory. Let's dive into the parts that usually trip people up when they try to move from theory to production code.

Why does Ruby keep throwing "isolation errors" when I pass a simple object?

This is the number one frustration. In a standard Ruby thread, everything is shared. In a Ractor, almost nothing is. If you try to pass a mutable string or a hash into a Ractor, Ruby will scream at you because if two Ractors could change the same object at the same time, we'd be right back to the thread-safety nightmares that the GVL was designed to prevent.

To fix this, you have two choices: move the object (which makes it inaccessible to the sender) or make it shareable. I usually lean towards Ractor.make_shareable for configuration data. It recursively freezes the object and all its children.

# This will fail
config = { timeout: 30, retries: 3 }
r = Ractor.new do
  puts "Timeout is #{Ractor.receive[:timeout]}"
end
r.send(config) # Error: can't pass non-shareable object

# This works
config = { timeout: 30, retries: 3 }
Ractor.make_shareable(config) 
r = Ractor.new do
  # Since config is shareable, we can even access it via closure 
  # if it was defined in the outer scope before Ractor creation
  puts "Timeout is #{config[:timeout]}"
end
r.take

How do I actually coordinate a pool of Ractors to process a big list of tasks?

Creating a Ractor for every single small task is expensive. You'll spend more time on overhead than on actual computation. The pattern I recommend is a "Worker Pool" where you spin up a fixed number of Ractors (usually matching your CPU core count) and feed them messages.

In this example, imagine we're doing some heavy CPU work, like calculating primes or processing large chunks of text. We can use Ractor.select to grab results as they finish, rather than waiting for them in a strict sequence.

# A simple worker pool for heavy computation
workers = 4.times.map do |i|
  Ractor.new(i) do |id|
    loop do
      # Wait for a number to process
      num = Ractor.receive
      # Simulate heavy CPU work
      result = (1..num).reduce(:+) 
      Ractor.yield "Worker #{id} finished #{num}: #{result}"
    end
  end
end

# Feed the workers some jobs
[100_000, 200_000, 300_000, 400_000].each_with_index do |job, i|
  workers[i % 4].send(job)
end

# Collect results as they become available
4.times do
  # Ractor.select returns [ractor, value]
  _r, message = Ractor.select(*workers)
  puts message
end

When should I just give up on Ractors and go back to Threads?

I get asked this a lot. Here is my rule of thumb: if your bottleneck is I/O (waiting for an API response, reading from a database, or writing to a disk), do not use Ractors. Ruby's standard Threads are perfectly fine for I/O because the GVL is released during those blocking operations anyway. Ractors add a lot of complexity regarding data isolation that you just don't need for I/O.

Use Ractors only when you have "CPU-bound" work—things like image processing, heavy mathematical calculations, or parsing massive JSON files where you can actually see your CPU usage sitting at 10% while your program crawls. If you're just hitting an endpoint 50 times, stick to Thread.new or a gem like Async.




📋 Practical Task

Exercise: Building a Parallel Log File Analyzer

You have a set of large log strings (simulating files) and you need to count the occurrences of the word "ERROR" across all of them in parallel. Because this is a CPU-intensive string scanning operation, it's a great candidate for Ractors.

Requirements:

  • Create a list of at least 5 large strings containing varying amounts of the word "ERROR".
  • Spin up 3 worker Ractors.
  • Distribute the strings among the workers using send.
  • Each worker should return the count of "ERROR" found in its assigned string.
  • Use Ractor.select or Ractor#take to aggregate the final total count in the main thread.
  • Ensure you handle the data isolation correctly so that the workers can process the strings without throwing an IsolationError.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.