Choosing a Ruby Concurrency Model: Threads, Fibers, or Ractors in Ruby 3.2+
A decision guide for picking Ruby 3.2+ concurrency: Threads for I/O-bound legacy apps, Fibers for high-concurrency I/O with async gems, Ractors for CPU-bound parallelism. Includes comparison table, trade-offs, a runnable benchmark, migration steps, and verification commands.
26 Nov 2025, 11:13 UTC

The Decision: Concurrency Model for Your Workload
Ruby 3.2 gives you three built‑in concurrency primitives, each with a different relationship to the Global VM Lock (GVL). The right choice depends on whether your workload is I/O‑bound, CPU‑bound, or a mix, and on how much existing code you need to keep running.
Constraints and Assumptions
- Ruby version: 3.2 or later (Ractors are stable from 3.2, but still marked experimental in 3.3).
- You can change the application server (Puma, Falcon, custom) and gem dependencies.
- You need a model that works with your current gem set; some C extensions are not Ractor‑safe.
- Operational simplicity matters: Fibers require a scheduler set at boot; Ractors demand message‑passing isolation.
Comparison Table
| Model | GVL Behavior | Best For | Ecosystem Maturity | Parallelism | Migration Effort |
|---|---|---|---|---|---|
| Threads | Released during I/O, held for Ruby code | I/O‑bound (DB, HTTP, Redis), legacy codebases | Full gem compatibility | Limited to one CPU core for Ruby code | Low – drop‑in for most apps |
| Fibers | Never blocks GVL; cooperative scheduling | High‑concurrency I/O (10k+ connections) | Requires fiber‑aware gems (async, async‑http, async‑redis) | Single‑threaded, no true parallelism | Medium – scheduler + async gems |
| Ractors | Each Ractor has its own GVL | CPU‑bound parallelism, isolated tasks | Limited; many stdlib calls (Dir.chdir, ENV) and C extensions not Ractor‑safe | True multi‑core parallelism | High – isolate mutable state, message passing |
Trade‑offs Deep Dive
Threads
Threads are the default for Rails (Puma) and Sidekiq. They scale well for I/O because the GVL is released during blocking system calls. For CPU‑heavy work (image processing, heavy computation) they contend for the single GVL, so adding threads beyond core count yields diminishing returns. Thread pool sizing is critical: Puma defaults to 5 threads; tune via WEB_CONCURRENCY (processes) and RAILS_MAX_THREADS (threads per process) to match your I/O wait profile.
Fibers
Fibers (Ruby 3.0+ scheduler API) enable massive concurrency on a single thread. They excel when you have thousands of idle connections (WebSockets, long‑polling). The catch: every library in the call chain must be fiber‑aware. The async gem provides a scheduler and async versions of HTTP, Redis, and PostgreSQL drivers. You must set the scheduler early (e.g., in config/initializers/async.rb) and wrap entry points in Async do ... end blocks. Mixing synchronous gems will block the entire reactor.
Ractors
Ractors give each actor its own GVL, enabling true parallel Ruby execution. They enforce isolation via message passing (Ractor.new { ... }, send, take). This eliminates data races but also means you cannot share mutable objects. Many standard library methods (e.g., Dir.chdir, ENV access) are not Ractor‑safe and will raise Ractor::IsolationError. C extensions like nokogiri or pg may release the GVL but are not automatically Ractor‑compatible. Debugging is harder because stack traces span multiple Ractors.
Concrete Validation: Benchmarking I/O‑bound Concurrency
Run a quick micro‑benchmark to see the overhead difference between threads and fibers for a sleep‑heavy workload. This requires the async gem (gem install async). Execute in a terminal with Ruby 3.2+:
ruby -e "require 'benchmark'; require 'async'; n=1000; Benchmark.bm do |x| x.report('Thread') { n.times { Thread.new { sleep 0.001 }.join } }; x.report('Fiber') { Fiber.scheduler = Async::Scheduler.new; n.times { Fiber.schedule { sleep 0.001 } }; Async::Task.current.wait } end }"
Where to run: Development machine or CI container with the same Ruby version as production.
Permissions: No elevated privileges needed.
Placeholders: Adjust n and sleep duration to simulate your typical I/O wait.
Expected checks: Fiber version should complete faster with lower memory because fibers are lighter than threads. If the fiber version hangs, the scheduler may not be set correctly.
Risks: This only measures scheduler overhead; real‑world performance depends on driver support and syscall behavior.
Migration Paths
Threads → Fibers
- Add
asyncand fiber‑aware drivers (async-http,async-redis,async-postgres). - Set a global scheduler at boot:
Fiber.scheduler = Async::Scheduler.new. - Wrap external call entry points (controller actions, job
perform) inAsync do ... end. - Run integration tests; watch for blocking calls that stall the reactor.
Threads → Ractors
- Identify CPU‑hot spots that can run in isolation (e.g., image resizing, data parsing).
- Move that code into
Ractor.new { ... }and communicate viasend/take. - Ensure no shared mutable state crosses the boundary; freeze objects passed to Ractors.
- Test with
Ractor.verify(Ruby 3.3+) to catch isolation violations early.
Hybrid Deployment
Run Puma (threaded) for standard Rails requests and Falcon (fiber‑based) for WebSocket or high‑concurrency endpoints behind a load balancer. This lets you adopt fibers incrementally without rewriting the whole app.
Limitations and Verification Steps
- Ractor stability: Still experimental in 3.2/3.3. Run
ruby -vto confirm version. Test thoroughly; some stdlib methods raiseRactor::IsolationError. - Gem compatibility: Check for
ractor_safeorfiber_safemetadata:grep -r 'ractor_safe' $(bundle show --paths)or review gem changelogs. - Production benchmarking: Use
wrk -t4 -c100 -d30s http://localhost:9292against Puma (threads), Falcon (fibers), and a Ractor‑based endpoint. Compare latency percentiles and throughput. - GVL contention monitoring: For CPU‑bound Ractor workloads, run
perf top -p <pid>to see if multiple cores are utilized. For thread pools, monitor GC stats:ruby -e 'puts GC.stat[:major_gc_count]'to detect excessive GC from thread churn.
Start with the model that matches your dominant workload and migration budget. Threads remain the safest default; fibers unlock massive I/O concurrency when you can adopt async gems; ractors are the only path to true CPU parallelism in pure Ruby, but require careful isolation and gem auditing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.