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.
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.
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.
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
All three side by side
| Threads | Fibers | Ractors | |
|---|---|---|---|
| Backed by | a native OS thread | a Thread | one or more Threads, with its own GVL |
| Scheduling | preemptive (OS, plus GVL handoff) | cooperative, by Ruby or a scheduler | same as Threads, per Ractor |
| Parallel Ruby code | no | no | yes |
| Shared state | everything, guarded by you | everything (same as Threads) | only shareable objects |
| Best for | I/O-bound work | many concurrent I/O tasks | CPU-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.



