Skip to content
RY

Ruby Concurrency Explained: Processes, Threads, Fibers, and Ractors

Ram Laxman Yadav11 min read

Ask five Ruby developers “should I use a thread or a fiber here?” and you’ll get five different answers — mostly because the words “concurrency” and “parallelism” get used interchangeably when they mean very different things, and Ruby gives you four different tools (processes, threads, fibers, Ractors) that all look like they solve the same problem.

They don’t. Each one trades off memory, isolation, and raw speed differently, and picking the wrong one either wastes server resources or doesn’t actually make your app faster at all. This post walks through what each one really does under the hood, with real numbers and simple examples, so you can pick the right tool instead of guessing.

Credit where due

The benchmark numbers in this post, and several of the mental models, come from Samuel Williams’ work on Ruby’s fiber scheduler as summarized in Paolino’s excellent writeup, “Ruby Concurrency: What Actually Happens”. If you want the deep technical dive, read that after this one.

1. What Is Concurrency?

Before touching any Ruby code, it helps to separate two words people mix up constantly: concurrency and parallelism.

  • Concurrency is about dealing with many things at once. A single chef in a kitchen can work on soup, then chop vegetables while the soup simmers, then stir the soup again — only one hands-on task happens at any instant, but multiple tasks are in progress at the same time.
  • Parallelism is about doing many things at the same literal instant. Two chefs, each with their own stove and cutting board, cooking two different dishes simultaneously.

Concurrency is a way of structuring work. Parallelism is a way of executing work using multiple CPU cores at once. You can have concurrency without parallelism (one chef, several tasks) and you can have parallelism without much concurrency (two chefs doing two completely independent jobs that never interact).

Why does this matter for Ruby specifically? Because of something called the GVL (Global VM Lock, sometimes still called the GIL — Global Interpreter Lock). In the standard Ruby implementation (CRuby, aka MRI), only one thread can execute Ruby code at a time, no matter how many CPU cores your machine has. This single fact is the reason Ruby has four different concurrency primitives instead of one — each one is a different way of working around, or living with, that constraint.

The one-sentence version

Threads give you concurrency for I/O (waiting on the network/disk), not parallelism for CPU work. If you want real CPU parallelism in Ruby, you need separate processes (or, experimentally, Ractors).

2. The Four Concurrency Primitives, in Detail

Processes

A process is the heaviest, most isolated unit. Each Ruby process gets its own memory space, its own Ruby VM, and its own GVL. The operating system schedules processes onto CPU cores independently, so multiple processes genuinely run in parallel — there’s no shared lock holding them back from each other.

# Spawns a brand-new OS process running this block
pid = Process.fork do
puts "I'm a separate process, PID: #{Process.pid}"
sleep 2
end
Process.wait(pid)
puts "Parent process continues here"

This is exactly how Puma (Rails’ default app server) gets parallelism: in “cluster mode,” it forks multiple worker processes, each handling its own requests on its own core, completely isolated from the others.

config/puma.rb
workers 4 # 4 separate OS processes, each with its own GVL
threads 5, 5 # each worker also runs 5 threads internally

The cost: each process duplicates your entire application in memory — every loaded gem, every class, every constant. Four Puma workers roughly means four full copies of your Rails app sitting in RAM (modern Ruby’s copy-on-write forking softens this somewhat, but it’s still the heaviest option).

Threads

A thread lives inside a process and shares that process’s memory with every other thread in it. In CRuby, threads are real native OS threads — but the GVL means only one of them can be executing Ruby bytecode at any given moment.

threads = 3.times.map do |i|
Thread.new do
puts "Thread #{i} starting"
sleep 1 # GVL is released during sleep/IO
puts "Thread #{i} done"
end
end
threads.each(&:join)

So what’s the point, if only one can run at a time? The GVL is released whenever a thread is waiting on I/O — a network call, a database query, reading a file, sleep. While one thread is blocked waiting for, say, a Postgres query to come back, the GVL is free, and another thread can run Ruby code. This is why threads are excellent for I/O-bound concurrency (handling many web requests that mostly wait on the database or external APIs) but don’t help much for CPU-bound work (crunching numbers, parsing large JSON, image resizing in pure Ruby).

A notable exception: some C extensions (like certain Numo/NMatrix numeric libraries, or native image-processing bindings) explicitly release the GVL during their C-level computation, so threads can give you real parallelism for that specific work — just not for pure Ruby CPU work.

Fibers

A fiber is dramatically lighter than a thread. It runs inside a thread, and unlike threads (which the VM can interrupt / preempt at any point), fibers are cooperatively scheduled — a fiber only pauses when it explicitly yields, and nothing resumes it until something tells it to.

fiber = Fiber.new do
puts "Fiber starts"
Fiber.yield "paused here"
puts "Fiber resumes"
"fiber finished"
end
puts fiber.resume # => "Fiber starts" then returns "paused here"
puts fiber.resume # => "Fiber resumes" then returns "fiber finished"

Used manually like this, fibers are just a cute flow-control tool. Their real power shows up with Ruby’s Fiber::Scheduler (added in Ruby 3), which lets a single thread run thousands of fibers, automatically pausing a fiber the instant it hits an I/O boundary (an HTTP call, a DB query) and resuming a different, ready fiber in its place — without you writing any async/await keywords anywhere.

require "async"
Async do
3.times do |i|
Async do
response = Net::HTTP.get(URI("https://example.com/#{i}"))
puts "Got response #{i}, #{response.length} bytes"
end
end
end
# All three HTTP calls are in flight "concurrently" on ONE thread,
# because each one yields the fiber while waiting on the network.

This is what the source article calls Ruby’s fibers being “colorless”: ordinary-looking synchronous code (Net::HTTP.get) automatically becomes non-blocking under a fiber scheduler, unlike Python or JavaScript where async/await has to be threaded through every layer of your call stack by hand.

The catch: fibers only help when there’s an I/O boundary to yield at. CPU-bound work inside a fiber (a tight loop doing math) has nowhere to yield, so it blocks the entire reactor — every other fiber on that thread has to wait.

Ractors

Ractors (Ruby’s “Actor”-inspired model, stable-ish since Ruby 3.0 but still marked experimental) are the newest and most radical option. Each Ractor has its own GVL, so multiple Ractors really can run Ruby code in parallel on multiple cores — something threads alone can never do. The tradeoff is isolation: Ractors can’t freely share mutable objects. They communicate by passing messages, not by sharing memory.

ractors = 4.times.map do |i|
Ractor.new(i) do |n|
# Each Ractor computes independently, in parallel, on its own GVL
result = (1..1_000_000).sum + n
result
end
end
results = ractors.map(&:take)
puts results.inspect

Every Ruby process actually already has one Ractor running by default — the “main Ractor” — so using a single Ractor is simply normal Ruby. Spinning up additional Ractors is the opt-in part, and it’s where things get restrictive: you generally can’t pass regular mutable objects between Ractors (they get frozen, copied, or rejected outright), and as of today, Rails itself doesn’t support running inside non-main Ractors — too much of Rails’ internals rely on shared mutable state that the Ractor model forbids.

3. Benchmarks and Other Details

Numbers vary by machine and Ruby version, but the relative differences below (drawn from Samuel Williams’ fiber scheduler benchmarks) are a useful mental model:

Metric Thread Fiber
Creation time ~80 microseconds ~3 microseconds
Context switch ~1.3 microseconds (kernel-level) ~0.1 microseconds (userspace)
Initial stack size ~8MB (virtual reservation) ~4KB (grows as needed)

In short: fibers are roughly 20x faster to create and roughly 10x faster to switch between than threads. The stack numbers are virtual address-space reservations, not actual memory used (real resident memory differs far less dramatically) — but the creation and switching cost difference is real and significant when you’re juggling hundreds or thousands of concurrent I/O operations.

For Ractors, the same source cites a CPU-bound test: computing fibonacci(38) five times took 2.26 seconds run sequentially, versus 0.68 seconds spread across 5 Ractors — a 3.3x speedup on CPU-bound pure-Ruby work, something threads simply cannot deliver because of the shared GVL.

Don't copy-paste these as guarantees

These numbers describe relative magnitudes on one benchmark setup, not a promise about your hardware or your Ruby version. Always benchmark your own workload with a tool like benchmark-ips before making an architecture decision based on raw numbers.

A practical, often-overlooked detail: database connection pool sizing changes completely depending on which model you use. For a thread-based background job system (like Sidekiq or Solid Queue in thread mode), each concurrently-running job ties up a connection for its whole lifetime, so the rule of thumb is roughly threads + 2 connections per process — 200 threads spread across 2 processes needs a pool of at least ~404 connections. Fiber-based job processing can get away with far fewer connections (e.g., 3 per process for I/O-heavy workers), because a fiber only holds the connection for the duration of an actual query, not for the whole time a job is “in flight” waiting on something else — though it’s worth noting a held transaction (BEGIN to COMMIT) still blocks other fibers waiting on that same connection.

4. Pros and Cons

Processes

  • ✅ True parallelism, zero shared-state bugs, crash isolation (one process dying doesn’t take others down)
  • ❌ Heavy memory footprint (each one loads a full copy of your app), slower to start, no shared memory means you need external coordination (DB, Redis, message queue) to communicate between them

Threads

  • ✅ Share memory directly (no serialization needed to pass data around), lightweight compared to processes, great for I/O-bound concurrency
  • ❌ No real CPU parallelism in pure Ruby (GVL), shared mutable state means you need to worry about race conditions and use Mutex/Monitor carefully, harder-to-debug bugs when things go wrong

Fibers

  • ✅ Extremely cheap to create and switch between, can scale to thousands of concurrent I/O operations on a single thread, “colorless” — existing synchronous-looking code works unmodified under a fiber scheduler
  • ❌ Useless for CPU-bound work (blocks the whole reactor), cooperative scheduling means a badly-behaved fiber can starve every other fiber on that thread, newer and less battle-tested across the gem ecosystem (not every gem supports the fiber scheduler yet)

Ractors

  • ✅ Real Ruby-level CPU parallelism without needing a full separate process, built-in safety against shared-mutable-state bugs by design
  • ❌ Still experimental, can’t freely share objects (steep learning curve around what can/can’t cross a Ractor boundary), not supported by Rails or much of the gem ecosystem yet, debugging tooling is immature

5. Use Cases: Which One Fits Best?

  • Handling many slow HTTP requests, webhooks, or streaming an LLM response → Fibers, via a scheduler like the async gem, Falcon, or Solid Queue’s fiber mode. These are almost pure I/O-wait workloads — exactly what fibers are built for.
  • CPU-heavy work in pure Ruby (image resizing without a native library, heavy data crunching, parsing huge files) → Processes today, in practice. Ractors could theoretically help, but they’re not production-ready for most apps yet, and Rails doesn’t support running inside them.
  • CPU-heavy work via a C extension that releases the GVL (numeric libraries, certain native image/video processing gems) → Threads work fine here, because the actual heavy lifting happens outside the GVL’s reach.
  • Serving web requests in Rails/Puma → a mix: multiple processes (Puma workers) for parallelism across cores, each running multiple threads for I/O concurrency within that process.
  • Background job processing with a lot of outbound API calls or webhooks → fiber-based worker mode (e.g., Solid Queue’s fiber workers) for the I/O-bound queues, combined with traditional thread or process workers for CPU-bound queues — exactly the “mixed configuration” pattern recommended in the source article.
  • Isolating untrusted or crash-prone code → Processes, since a crash is contained and can’t corrupt the rest of the application’s memory.

6. Conclusion

There’s no single “best” concurrency primitive in Ruby — only the right tool for a given workload:

  • Processes give you real parallelism and strong isolation, at the cost of memory.
  • Threads give you I/O concurrency and shared memory, but no CPU parallelism due to the GVL.
  • Fibers give you extremely cheap, massive-scale I/O concurrency on a single thread, but nothing for CPU-bound work.
  • Ractors promise real CPU parallelism with built-in safety, but remain experimental and unsupported by Rails today.

A well-built, production-grade Rails application typically doesn’t pick just one — it uses all three of processes, threads, and fibers together, matching each to the part of the workload it’s actually good at, and keeps an eye on Ractors as they mature. Understanding what’s actually happening underneath each keyword — rather than reaching for whichever one is trendiest — is what lets you make that call correctly.

References