All posts

Technical post

Ruby Concurrency: Threads, Fibers and Ractors

Ruby has three concurrency primitives and each one answers a different question. Threads share a lock, Fibers share a thread, and Ractors share almost nothing, which is why they are the only primitive that runs Ruby code in parallel.

One very interesting and important topic for Ruby developers to understand is how Ruby runs more than one thing at a time. CRuby gives us three primitives for it: Threads, Fibers and Ractors. They are easy to mix up, and they solve different problems.

This post started as a short LinkedIn series, one post per primitive. Here the three are put together, with a few runnable examples added. It is still a high-level introduction, so some implementation details are intentionally simplified.

All the examples were run on Ruby 4.0.

Threads and the GVL

A Thread is one of Ruby's classic concurrency primitives. Despite the name, a Ruby Thread is not the same thing as a hardware thread on your CPU. In CRuby, each Ruby Thread is backed by a native operating-system thread.

A Thread is an execution unit that can run Ruby code alongside other Threads. A good example is a Rails application running on Puma: a Puma worker can have multiple Threads, so multiple requests can be in progress at once, usually one request per available Thread.

This brings us to the important limitation. Threads inside the same Ractor can run concurrently, but they cannot execute Ruby code in parallel. Two Threads never execute Ruby code at exactly the same time inside the same Ractor.

That rule is enforced by the GVL (Global VM Lock). A Thread must hold the GVL to execute Ruby code.

When a Thread performs a blocking I/O operation, such as an HTTP request, a database query or a socket read, CRuby can release the GVL while that Thread waits. Another Thread can then acquire the GVL and run Ruby code in the meantime.

Sequence diagram of two Ruby Threads sharing the GVL. Thread A acquires the GVL, runs Ruby code, starts I/O and releases the GVL while it waits. Thread B acquires the GVL and runs Ruby code. When Thread A's I/O completes it becomes runnable, and once Thread B releases the GVL, Thread A reacquires it and resumes.

At a very high level, the flow is:

Thread A runs Ruby code → waits for I/O → releases the GVL → Thread B acquires the GVL and runs Ruby code → Thread A's I/O completes → Thread A eventually acquires the GVL again and continues.

You can see both sides of this in a few lines. sleep stands in for I/O here, because it releases the GVL the same way a socket read does. fib is pure Ruby computation, so it needs the GVL the whole time.

require 'benchmark'

def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)

io = Benchmark.realtime do
  4.times.map { Thread.new { sleep 1 } }.each(&:join) # stand-in for an HTTP call
end

cpu_serial = Benchmark.realtime { 4.times { fib(30) } }
cpu_threads = Benchmark.realtime do
  4.times.map { Thread.new { fib(30) } }.each(&:join)
end

puts "4 threads waiting on I/O:   #{io.round(2)}s"
puts "4x fib(30), one after another: #{cpu_serial.round(2)}s"
puts "4x fib(30), 4 threads:         #{cpu_threads.round(2)}s"
4 threads waiting on I/O:   1.01s
4x fib(30), one after another: 1.25s
4x fib(30), 4 threads:         1.55s

Four Threads waiting one second each finish in one second, because the waits overlap. Four Threads doing CPU work are no faster than doing the work one after another, and a bit slower here because they keep handing the GVL back and forth.

This is why Ruby Threads are particularly useful for I/O-bound workloads, even though the GVL limits the parallel execution of CPU-bound Ruby code.

Fibers

A Fiber is another concurrency primitive. Unlike a Thread, a Fiber is a lightweight execution context that runs inside a Thread instead of being backed by its own native OS thread.

The important difference between the two is how they are scheduled.

Threads are preemptively scheduled by the operating system, although in CRuby only the one holding the GVL runs Ruby code, and the VM makes it hand the GVL over periodically. Because they are native threads, scheduling and switching between them generally costs more than switching between Fibers.

Fibers use cooperative scheduling. A Fiber keeps running until it yields control or reaches a suspension point where another Fiber can continue. The OS does not decide which Fiber runs next. Fiber switching happens at the Ruby level, while the Thread underneath is still scheduled normally by the operating system.

The simplest way to see that is to switch Fibers by hand:

fiber_a = Fiber.new do
  puts "A: runs Ruby code"
  Fiber.yield # A hands control back, as a scheduler would while A waits on I/O
  puts "A: resumes on the same thread"
end

fiber_b = Fiber.new do
  puts "B: runs while A is suspended"
end

fiber_a.resume
fiber_b.resume
fiber_a.resume
puts "all of it ran on #{Thread.current.inspect}"
A: runs Ruby code
B: runs while A is suspended
A: resumes on the same thread
all of it ran on #<Thread:0x000071f28ee67fb0 run>

Nobody writes I/O code like this, though. Since Ruby 3.0 there is the Fiber Scheduler interface, which lets a scheduler implementation intercept potentially blocking operations and suspend a non-blocking Fiber while it waits. Ruby defines the interface, and gems implement it.

When a Fiber performs a compatible blocking operation, such as an HTTP request, database I/O or a socket read, the scheduler suspends only that Fiber instead of blocking the whole Thread. Another Fiber in the same Thread continues running.

Sequence diagram of two Fibers on one Thread with a Fiber scheduler. Fiber A runs Ruby code and starts I/O, the scheduler suspends it, and Fiber B is scheduled on the same thread and runs Ruby code. When Fiber A's I/O completes it becomes runnable, Fiber B yields, and Fiber A resumes on the same thread.

This lets many concurrent I/O-bound tasks share a small number of Threads. Instead of creating a large number of native Threads, many tasks are multiplexed inside the same one. That can reduce OS-level context switching and, in CRuby, reduce the number of Threads competing for the GVL.

Fibers do not provide CPU parallelism, though. Multiple Fibers inside the same Thread still execute Ruby code one at a time.

Libraries such as Async and servers such as Falcon use this model to handle many concurrent operations efficiently.

Ractors

Threads and Fibers both give us concurrency, but neither lets two pieces of Ruby code run at the same instant. Ractors are Ruby's modern answer to that.

A Ractor is Ruby's actor-like concurrency abstraction. It is designed for parallel execution while limiting shared mutable state. Unlike Threads and Fibers, multiple Ractors can execute Ruby code in parallel on different CPU cores.

The key is isolation. Each Ractor has its own GVL, so at a very high level:

  • Ractor A runs on CPU core 1
  • Ractor B runs on CPU core 2
  • both execute Ruby code at the same time

That is fundamentally different from Threads inside the same Ractor, where only the Thread holding that Ractor's GVL can execute Ruby code.

Sequence diagram of Ractors in CRuby. The main Ractor creates Ractor A and Ractor B and sends each a message. Both run Ruby code in parallel on different CPU cores. Ractor A sends a message to Ractor B, Ractor B answers, and both return their result to the main Ractor. An object is shared if it is shareable, otherwise copied or moved, and no mutable state is shared between Ractors.

The same CPU-bound work from the Threads section, spread across four Ractors:

require 'benchmark'

def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)

serial = Benchmark.realtime { 4.times { fib(30) } }
parallel = Benchmark.realtime do
  4.times.map { Ractor.new { fib(30) } }.map(&:value)
end

puts "4x fib(30), one after another: #{serial.round(2)}s"
puts "4x fib(30), 4 Ractors:         #{parallel.round(2)}s"
warning: Ractor API is experimental and may change in future versions of Ruby.
4x fib(30), one after another: 1.29s
4x fib(30), 4 Ractors:         0.56s

Four Threads took longer than doing the work serially. Four Ractors took less than half the time. The speedup is not a perfect 4x, but the work is clearly running on several cores at once.

Note the warning: Ractors are still marked experimental, and the API has changed between versions. Ractor#value is the Ruby 4.0 way to wait for a result. On Ruby 3.x the equivalent is Ractor#take.

Isolation is the price

Ractors can run in parallel because they do not share mutable objects. Communication happens through messages, and the rules are strict:

worker = Ractor.new do
  numbers = Ractor.receive # blocks until something is sent to this Ractor
  numbers.sum
end

worker.send([1, 2, 3])
puts worker.value # => 6

config = { retries: 3 }
Ractor.new(config) { |c| c[:retries] += 1 }.value
puts config # => {retries: 3}, the Ractor changed its own copy

COUNTER = { hits: 0 }
begin
  Ractor.new { COUNTER[:hits] += 1 }.value
rescue Ractor::RemoteError => e
  puts e.cause.class # => Ractor::IsolationError
end
6
{retries: 3}
Ractor::IsolationError

Ruby also prints a terminated with exception trace for the failing Ractor to stderr. That is expected: the exception is still rescued in the main Ractor.

An object passed to a Ractor is shared only if it is shareable, which means deeply frozen (or Ractor.make_shareable). Anything else is copied, or moved if you ask for it with send(obj, move: true). That is why the Ractor above incremented its own copy of config and the original did not change. A non-main Ractor also cannot touch a constant that holds a mutable object, so the COUNTER example raises Ractor::IsolationError.

This is also where most real code runs into trouble. Global state, memoized class variables and C extensions that are not Ractor-safe are common in gems, and all of them get in the way. Ractors work best today for self-contained, CPU-bound work that you can hand off and collect a result from.

Threads vs Ractors

Comparison of Threads and Ractors. Threads: many threads in one Ractor take turns holding the GVL, so only one runs Ruby code at a time; sharing state is easy, there is no parallel Ruby code, they fit I/O-bound work and work with almost every gem. Ractors: Ractor A on CPU core 1 and Ractor B on CPU core 2 run Ruby code at the same time and talk through messages; they give true parallelism and isolated state, have strict rules on what can be shared, and fit CPU-bound work.

All three side by side

ThreadsFibersRactors
Backed bya native OS threada Threadone or more Threads, with its own GVL
Schedulingpreemptive (OS, plus GVL handoff)cooperative, by Ruby or a schedulersame as Threads, per Ractor
Parallel Ruby codenonoyes
Shared stateeverything, guarded by youeverything (same as Threads)only shareable objects
Best forI/O-bound workmany concurrent I/O tasksCPU-bound work

Each primitive answers a different question. Threads let you wait on several things at once while sharing one lock. Fibers do the same thing more cheaply, inside a single Thread. Ractors are the only primitive that runs Ruby code on several cores at once, and the cost is giving up shared mutable state.