A few weeks back, at RailsConf Pune (Deccan Queen), Shopify engineers gave a talk titled "When Rails Met Fibers: Migrating Sidekiq from Puma to Falcon." Their challenge: an AI chatbot where every request was itself waiting on an external AI API call, and Puma's thread-per-request model was buckling under all that waiting.
I've used Thread for over a decade β Puma's thread pool, Sidekiq workers, the usual. But that talk made me want to go past the surface-level API and actually verify what's happening underneath: what the GVL really guarantees, where Fiber genuinely earns its place, and where Ractor fits in. So I sat down and did exactly that β measuring Thread, Fiber, and Ractor myself instead of taking any of it on faith.
Every number in this post came from a script I ran. The full, runnable repo β one commit per finding β is here: thread-fiber-ractor on GitHub. Clone it and run the scripts yourself; your numbers will differ from mine (different machine, different core count), and that's kind of the point β don't trust my numbers either, verify them.
Built and run on Ruby 4.0.7.
The GVL, the one idea everything else builds on
MRI's Global VM Lock (GVL) lets exactly one thread execute a Ruby instruction at any given instant, in one process β no matter how many threads you spawn. That's the single fact that explains almost everything below.
- A thread blocked on I/O (a network call, a DB round-trip) releases the GVL, so another thread can run while it waits.
- A thread doing pure CPU work won't release it voluntarily β but it's not quite "holds the lock forever" either. MRI's VM has an internal timer that can still force a switch between CPU-bound threads. The catch: that forced switch just means CPU-bound threads take turns. It doesn't make them any faster, because only one of them is ever actually executing at once.
That second point is the one worth sitting with, because it's exactly what separates Thread from Fiber later in this post.
Thread: concurrency on I/O, nothing on CPU
Here's the proof, straight from 01_gvl_thread_basics.rb:
require "benchmark"
def count_primes(upto)
count = 0
(2..upto).each do |n|
is_prime = (2..Math.sqrt(n).to_i).none? { |d| n % d == 0 }
count += 1 if is_prime
end
count
end
N = 1_000_000
# CPU-bound: does Thread give you parallelism?
serial = Benchmark.realtime { count_primes(N); count_primes(N) }
parallel = Benchmark.realtime do
t1 = Thread.new { count_primes(N) }
t2 = Thread.new { count_primes(N) }
[t1, t2].each(&:join)
end
# I/O-bound: does Thread give you concurrency?
serial_io = Benchmark.realtime { sleep(1); sleep(1) }
parallel_io = Benchmark.realtime do
t1 = Thread.new { sleep(1) }
t2 = Thread.new { sleep(1) }
[t1, t2].each(&:join)
end
Output:
---- CPU-bound: does Thread give you parallelism? ----
Two calls, one after another: 5.77s
Same two calls, on two Threads: 5.79s
speedup: 1.0x (expect ~1.0x β the GVL serializes CPU-bound Ruby)
---- I/O-bound: does Thread give you concurrency? ----
Two sleeps, one after another: 2.0s
Same two sleeps, on two Threads: 1.01s
speedup: 1.98x (expect ~2.0x β I/O releases the GVL)
Identical CPU-bound work: zero speedup across two threads β in fact marginally slower (5.79s vs 5.77s), which lines up with forced-switch overhead rather than one thread holding the lock uninterrupted. The same two threads doing I/O get a real ~2x speedup, because I/O actually releases the lock.
This is Thread's whole story: great concurrency primitive for I/O you already have (that's what Puma's thread pool and Sidekiq workers are doing for you), zero help for CPU-bound work.
The hidden ceiling
Threads map to real OS kernel threads, and the kernel hard-caps how many of those can exist. On my machine (02_thread_ceiling.rb):
Creating Threads until the OS refuses...
Hit a hard OS wall at 2046 threads -- can't create Thread: Resource temporarily unavailable
Creating 50000 Fibers (not an OS construct β no equivalent ceiling)...
Created all 50000 Fibers with zero errors (24.4x past where Threads hit their wall)
2,046 threads and the OS refuses to create more. 50,000 Fibers, in the same process, zero errors β because a Fiber isn't an OS-level construct at all. Which is a good place to introduce Fiber properly.
Fiber: cooperative, cheap, and not a speed trick
A Fiber is a coroutine: pause and resume control flow, entirely inside one Ruby process, with no OS involvement. Fiber.new creates one, Fiber.yield pauses it, #resume continues it (03_fiber_basics.rb):
fiber = Fiber.new do
puts "fiber: step 1"
Fiber.yield
puts "fiber: step 2"
end
fiber.resume # => "fiber: step 1"
fiber.resume # => "fiber: step 2"
One detail that trips people up: a Fiber only ever runs on whichever OS thread calls #resume. Resuming it from a different thread raises FiberError: fiber called across threads.
Fiber alone is not fast
It's tempting to assume Fiber is a speed trick because it's a newer, fancier control-flow tool. It isn't. A Fiber switch has real overhead, and for a simple "stop when you hit a condition" loop, a plain loop wins every time (04_fiber_is_not_a_speed_trick.rb):
plain loop: 0.0824s
fiber: 0.5839s
fiber is 7.1x SLOWER than a plain loop for this
Fiber's value isn't raw speed. It's structural β which is exactly what Fiber::SchedulerInterface unlocks.
The real payoff: Fiber::SchedulerInterface
Registering a scheduler (via Fiber.set_scheduler) makes Ruby intercept blocking calls β sleep, socket I/O β and hand them to the scheduler instead of actually blocking the OS thread. The scheduler registers interest with the kernel's own I/O multiplexer (epoll/kqueue), and the thread is immediately free to run other ready fibers. This is the exact mechanism behind Falcon β the app server from the RailsConf talk that kicked this whole thing off.
The async gem is the reference implementation. Here's the proof (05_fiber_scheduler.rb):
require "async"
Async do |task|
3_000.times { task.async { sleep 1 } }
end
---- Threads: 20 concurrent 'slow I/O calls' (sleep 1 each) ----
wall time: 1.01s
OS threads used: 20 (one per call)
---- Fibers + scheduler: same 20 calls, one task.async each ----
wall time: 1.0s
OS threads used: 1 (every task cooperatively shared the same thread)
---- Now push past where Threads hit their wall (~2,046) ----
Running 3000 concurrent 'slow calls' as Fibers -- Thread.new would have crashed by now.
wall time: 1.11s
3000 concurrent I/O-bound calls, 1 OS thread, no crash.
3,000 concurrent "I/O calls," one OS thread, 1.11 seconds. That's something Thread literally cannot attempt past its own ceiling.
The cost: no preemption safety net
Here's the part that doesn't make it into the pitch. Thread's GVL still has that forced timer switch we talked about earlier β even a pure CPU loop gets preempted eventually. Fiber has nothing like it. A fiber only ever gives up the thread when it decides to, by hitting a blocking call. A CPU-bound fiber with zero yield points starves every other fiber on that thread for its entire run β even ones whose I/O already finished (06_fiber_has_no_preemption.rb):
Async do |task|
task.async { busy_loop(2) } # pure CPU, no yield point at all
task.async { sleep(0.1) } # "ready" almost immediately
end
[CPU-bound fiber] finished after 2.0s
[I/O fiber] finished after 2.1s (expected ~0.1s if it weren't starved)
The I/O fiber was ready in 0.1 seconds. It didn't actually finish until 2.1 seconds β starved the entire time by a CPU-bound fiber that never yielded. If that CPU-bound fiber ran forever, so would the wait. There's no timeout coming to rescue you.
That tradeoff β massive I/O concurrency, zero CPU-starvation protection β is almost certainly the exact thing Shopify had to reason about migrating to Falcon.
Ractor: actual parallelism
Thread gives you concurrency, never parallelism, on CPU-bound work β that's the GVL's whole deal. Ractor is the one primitive that breaks that rule entirely.
Ractor basics
r = Ractor.new { "hello from a ractor" }
r.value # => "hello from a ractor"
Worth noting: Ractor's API is still marked experimental and has changed across Ruby versions β an older #take method from some tutorials doesn't exist anymore; #value is the current way to get a Ractor's result. Don't trust a blog post's (including this one's) method names without checking against your own Ruby version.
The sharing rules
Since Ractors genuinely run at the same instant, Ruby has to prevent two of them from mutating the same object at once. It does this by making the setup physically impossible, not just "risky" (08_ractor_sharing_rules.rb):
# closures over a mutable outer variable are refused immediately
arr = [1, 2, 3]
Ractor.new { arr << 4 }
# => ArgumentError: can not isolate a Proc because it accesses outer variables (arr)
# #send of a mutable object copies it
r = Ractor.new { Ractor.receive << 4 }
r.send([1, 2, 3])
r.value.object_id == [1, 2, 3].object_id # => false, it's a copy
# a frozen object is shared by TRUE reference
frozen = [1, 2, 3].freeze
r2 = Ractor.new { Ractor.receive }
r2.send(frozen)
r2.value.object_id == frozen.object_id # => true, zero-copy
# Ractor.make_shareable deep-freezes recursively
data = { name: "x", roles: ["lead", "engineer"] }
Ractor.make_shareable(data)
data.frozen? # => true
data[:roles].frozen? # => true, the nested array too
Put together: there is no path to "two Ractors holding a live reference to the same mutable object." Not a risky one, not a rare one β none. If you need real shared mutable state across Ractors, the only sanctioned path is message-passing β each Ractor owns its own private state, and they coordinate by sending copies (or transferring ownership via move: true) across, never by two of them reaching into the same box at once.
The payoff: real CPU speedup
Same CPU benchmark as the Thread section, now split across Ractors instead (09_ractor_vs_thread_cpu.rb):
ractors = 4.times.map { Ractor.new { count_primes(600_000) } }
ractors.each(&:value)
---- Serial: 4 calls, one after another ----
wall time: 5.79s
---- Threads: 4 concurrent, same GVL ----
wall time: 5.82s
speedup vs serial: 0.99x
---- Ractors: 4 concurrent, independent GVLs ----
wall time: 1.73s
speedup vs serial: 3.35x
Same workload, same machine, same core count available the whole time. Thread: flat. Ractor: 3.35x faster. The only variable that changed is whether each worker shares one GVL or gets its own.
Worth knowing before you reach for this in a Rails app: ActiveRecord isn't Ractor-safe yet. I tried calling Rails.application and ApplicationRecord.connection from inside a Ractor and both hard-failed β Rails.application raises Ractor::IsolationError because Rails itself holds the booted app in a mutable instance variable only the main Ractor can read. So the practical pattern is: pull data out of Rails/AR first, on the main Ractor, then hand off only plain, frozen data to a pool of Ractors for the CPU-heavy part.
Bonus: the Rails Current.user gotcha under Fiber concurrency
This one connects straight back to the Falcon migration. Most Rails apps keep request-scoped context β current user, request id β via ActiveSupport::CurrentAttributes. It's built on ActiveSupport::IsolatedExecutionState, which defaults to isolation_level = :thread β storing state as a plain attribute on the shared Thread object, not scoped per-fiber.
Under a Fiber-scheduler server like Falcon, multiple fibers share one OS thread. With the default :thread isolation, they'll clobber each other's Current state (10_thread_current_gotcha.rb):
class Current < ActiveSupport::CurrentAttributes
attribute :request_id
end
Async do |task|
task.async do
Current.request_id = "req-A"
sleep(0.3)
puts Current.request_id # => ???
end
task.async do
sleep(0.1)
Current.request_id = "req-B"
end
end
---- isolation_level = :thread (Rails' default) ----
[A] woke back up. Current.request_id is now: "req-B"
---- isolation_level = :fiber ----
[A] woke back up. Current.request_id is now: "req-A"
Under the default, fiber A's own request id got overwritten by fiber B's β a different request's data leaking into fiber A's execution. Rails actually ships a real fix for this: config.active_support.isolation_level = :fiber. If your app ever moves to a Fiber-scheduler server, this is a config line worth knowing exists.
Which one do you actually need?
- Thread β concurrency on I/O you already have (Puma, Sidekiq). Never parallelism on CPU-bound Ruby. Hard OS ceiling.
- Fiber β I/O concurrency at a scale Thread can't reach (thousands of fibers, one OS thread). Cooperative, with zero preemption safety net.
- Ractor β compute parallelism Thread can never give you. The price is real isolation: no shared mutable state, enforced by the VM, and (for now) no ActiveRecord inside one.
None of these replace the others. Pick based on where your actual bottleneck is.
Try it yourself
All ten scripts, with real output and one commit per finding: github.com/HuzaifaSaifuddin/thread-fiber-ractor
git clone https://github.com/HuzaifaSaifuddin/thread-fiber-ractor
cd thread-fiber-ractor
bundle install
ruby 01_gvl_thread_basics.rb
Your numbers will come out different from mine β different machine, different core count, different load. Run them anyway. That's the whole point of this post: stop trusting the docs, go measure it.
If you've used Fiber or Ractor in a real app β or migrated a service to Falcon β I'd genuinely like to hear what you ran into. Drop it in the comments.














