Ractors: Real Parallelism in Ruby Without the GVL

May 14, 2026

In-depth technical analysis · RubyStackNews · Concurrency & Performance

For decades, the Global VM Lock (GVL) — also known as the GIL — was CRuby’s great concession: the safety and simplicity of an object model free of data races, in exchange for not being able to execute Ruby code in parallel within the same process. Ractors, introduced in Ruby 3.0, change that equation for the first time.


Tokyo Topographic Map
Built for Ruby on Rails

Build Maps Without
Google APIs

Generate beautiful production-ready maps directly from your Rails backend. Fast rendering, zero external dependencies, full control.

✓ No API fees ✓ Self-hosted ✓ Rails Native ✓ Fast Rendering
Why developers switch
Replace expensive map stacks.

Stop relying on third-party map billing and bloated JS libraries. Render static or dynamic maps directly in Ruby.

Try It Now
Tokyo MapView Demo

What Is a Ractor?

A Ractor is a parallel execution unit with object isolation. It looks superficially like a Thread, but its object-sharing rules are radically more restrictive — and that is exactly what allows CRuby to hold a GVL per ractor instead of one global lock.

Creating one is straightforward. A Ractor takes a block and can run in parallel with others:

r = Ractor.new { puts "Hello from Ractor #{Ractor.current}" }
r.join  # wait for it to finish

The fundamental difference from threads: a Ractor cannot access variables from the outer scope. The following code fails immediately:

a = 1
Ractor.new { puts a }
# Ractor::IsolationError: can not isolate a Proc because it
#   accesses outer variables (a).

Why this matters


Passing Objects Between Ractors

Communication happens exclusively through messages. There are two mechanisms: passing arguments to Ractor.new, or the message-passing model with send / receive.

Constructor arguments

a = 42
r = Ractor.new(a) do |value|
 puts "Received: #{value}"
end
r.join

send / receive (message passing)

r = Ractor.new do
 msg = Ractor.receive  # blocks until a message arrives
 puts "Processing: #{msg}"
end
r.send("work")
r.join

send is non-blocking: it returns immediately even if the receiving ractor is not yet waiting. receive, on the other hand, blocks the current ractor’s thread until a message arrives.


Ractors solve parallelism at the design level: if an object cannot be shared, the runtime tells you before the bug ever reaches production.


Shareable vs. Unshareable Objects

The heart of the Ractor model is the distinction between shareable and unshareable objects. You can check with Ractor.shareable?:

Ractor.shareable?(1)               #=> true — numbers and other basic immutables
Ractor.shareable?('hello')         #=> false — mutable string
Ractor.shareable?('hello'.freeze)  #=> true
Ractor.shareable?([Object.new].freeze) #=> false — inner object is mutable

To make a complex object shareable (including its internal parts), use Ractor.make_shareable, which recursively freezes the object:

ary = ['hello', 'world']
Ractor.make_shareable(ary)
ary.frozen?    #=> true
ary[0].frozen? #=> true — recursively frozen

Copy vs. Move

When an unshareable object is sent, Ruby has to do something with it. By default it copies it via deep clone. This is safe but potentially expensive:

data = ['foo'.dup, 'bar'.freeze]
r = Ractor.new do
 d = Ractor.receive
 # d[0] is a copy; d[1] is the same object (it was shareable)
end
r.send(data)
r.join

Alternatively, move: true transfers ownership of the object to the receiving ractor, invalidating it in the sending ractor:

data = ['foo', 'bar']
r = Ractor.new do
 d = Ractor.receive
 puts d.inspect
end
r.send(data, move: true)
r.join
data.inspect
# Ractor::MovedError: can not send any methods to a moved object

⚠️ Caution


Ractor::Port — The Explicit Port System

Ruby introduced Ractor::Port as the first-class communication channel. A port belongs to the ractor that creates it; only that ractor can receive from it and close it. Any ractor can send messages to it:

port = Ractor::Port.new
worker = Ractor.new(port) do |p|
 p.send("worker result")
end
result = port.receive
puts result   #=> "worker result"
worker.join
port.close

Ports also enable the fan-in pattern: multiple ractors sending to the same port, with the main ractor receiving results as they arrive.


Ractor.select — Waiting on Multiple Sources

Like Go’s channel select, Ractor.select blocks until any of the given ports or ractors has something available:

p1, p2 = Ractor::Port.new, Ractor::Port.new
Ractor.new(p1) { |p| sleep 0.1; p.send("from p1") }
Ractor.new(p2) { |p| sleep 0.2; p.send("from p2") }
2.times do
 source, value = Ractor.select(p1, p2)
 puts "Received #{value} from #{source.inspect}"
end

Accessing Constants and Class Variables

There is an important gotcha here. Classes and modules are shareable objects — their definitions are shared among all ractors. However, accessing instance variables of classes from non-main ractors is restricted:

class Config
 class << self
   attr_accessor :value
 end
end
Config.value = "mutable".dup
Ractor.new do
 Config.value  # RuntimeError: can not access instance variables
               #   of classes/modules from non-main Ractors
end.join

Constants are only accessible from non-main ractors if the value they point to is shareable:

GOOD = 'constant'.freeze   # shareable ✓
BAD  = 'constant'.dup      # unshareable ✗
Ractor.new { puts GOOD }.join  # OK
Ractor.new { puts BAD }.join
# NameError: can not access non-shareable objects in constant BAD

💡 Recommended pattern


Ractors vs. Threads

Note that inside a Ractor you can create threads: they share the GVL with the other threads of that ractor, but run in parallel with threads of other ractors.


When (and When Not) to Use Ractors

Good candidates for Ractors:

  • Batch data processing where each unit is independent
  • Computation-intensive workers (compression, encryption, large-scale parsing)
  • Actor-model transformation pipelines
  • Servers handling fully isolated connections

Situations where threads remain preferable:

  • Code that uses gems with non-thread-safe C extensions
  • I/O-bound operations where the GVL is already released naturally
  • Rails applications with complex shared in-memory state

⚠️ Current status — Experimental


Conclusion

Ractors represent the most significant architectural change to Ruby’s execution model in decades. They are not a universal solution — the overhead of copying objects, incompatibility with legacy C gems, and constant restrictions all require thinking about design differently.

But for workloads that genuinely need CPU-level parallelism, Ractors offer something that CRuby threads never could: running Ruby code across all your cores, with runtime isolation guarantees that make an entire family of concurrency bugs impossible.

The price is discipline in data design. The reward can be throughput that scales linearly with the number of available cores.

Article content

Ruby 3.x · Concurrency · Parallelism · Ractors · GVL · Performance


Source: CRuby reference implementation of Ractor (ractor.rb). Reproduced for educational purposes. · RubyStackNews.com

Leave a comment