Concurrency in Ruby is one of those topics that trips up even experienced developers, mostly because Ruby offers several different concurrency primitives — threads, fibers, and the newer Fiber::Scheduler — that solve overlapping but distinct problems. Understanding how Ruby actually schedules work, what the Global VM Lock does, and when to reach for a Thread versus a Fiber is the difference between writing concurrent Ruby code that scales and writing concurrent Ruby code that quietly serializes everything anyway.
This guide covers Ruby’s concurrency model end to end: how the thread scheduler works internally, how to manage threads directly, how fibers give you fine-grained control over execution, and how modern Ruby lets you influence scheduling decisions that used to be entirely opaque.
Ruby’s Concurrency Model: The Big Picture
Before touching any code, it’s worth being precise about terms Ruby developers often use loosely:
- Concurrency is about structuring a program to handle multiple tasks that can be in progress at once, even if only one is actually executing at any given instant.
- Parallelism is about literally executing multiple tasks at the same instant, on multiple CPU cores.
In MRI (Matz’s Ruby Interpreter, the reference implementation most people mean when they say “Ruby”), threads give you concurrency but generally not parallelism for pure Ruby code, because of the Global VM Lock.
The Global VM Lock (GVL/GIL)
MRI has a Global VM Lock (historically called the GIL, Global Interpreter Lock) that ensures only one thread executes Ruby code at a time, even on a multi-core machine. This exists because MRI’s internal object model and garbage collector are not thread-safe by design, and the GVL is the mechanism that protects them.
require 'benchmark'
def cpu_heavy_task
1_000_000.times { |i| i * i }
end
# Two threads doing CPU-bound work
time = Benchmark.realtime do
t1 = Thread.new { cpu_heavy_task }
t2 = Thread.new { cpu_heavy_task }
t1.join
t2.join
end
puts "Two threads: #{time.round(3)}s"
Run this and you’ll find it’s roughly the same speed as running the two tasks sequentially — sometimes even slightly slower due to context-switching overhead. That’s the GVL at work: only one thread actually runs Ruby bytecode at any instant, so CPU-bound work doesn’t parallelize across threads in MRI.
Where threads do help is I/O-bound work. The GVL is released whenever a thread performs a blocking I/O operation (reading a file, waiting on a network socket, querying a database), letting another thread run during that wait:
require 'net/http'
require 'benchmark'
urls = ['https://example.com'] * 5
time = Benchmark.realtime do
threads = urls.map do |url|
Thread.new { Net::HTTP.get(URI(url)) }
end
threads.each(&:join)
end
puts "Five requests concurrently: #{time.round(3)}s"
Here, while one thread waits on a network response, the GVL is free for another thread to make progress — so five concurrent HTTP requests complete in roughly the time of the slowest one, not the sum of all five.
Thread Management Fundamentals
Creating and Controlling Threads
thread = Thread.new do
puts "Running in a new thread"
sleep 1
puts "Thread finishing"
end
puts "Main thread continues immediately"
thread.join # Block main thread until 'thread' finishes
puts "Both threads are done"
Output:
Main thread continues immediately
Running in a new thread
Thread finishing
Both threads are done
Key thread lifecycle methods:
t = Thread.new { sleep 2 }
t.alive? # => true, while running
t.status # => "sleep", "run", "aborting", false (finished normally), or nil (finished with exception)
t.join(1) # wait up to 1 second, then return even if not finished
t.kill # forcibly terminate the thread (use sparingly)
t.value # blocks until the thread finishes, then returns its final expression's value
Passing Data Into Threads
Always pass data into a Thread.new block as arguments rather than relying on closures over loop variables, especially in older Ruby versions or tight loops:
threads = (1..5).map do |i|
Thread.new(i) do |n|
sleep(rand(0.1..0.3))
puts "Thread #{n} finished"
end
end
threads.each(&:join)
Passing i explicitly as a block parameter (Thread.new(i) { |n| ... }) avoids subtle bugs where all threads end up referencing the same final value of a shared loop variable.
Handling Exceptions in Threads
By default, an exception raised inside a thread does not crash the main program — it silently terminates just that thread, unless you check for it:
Thread.abort_on_exception = true # globally: propagate thread exceptions to main thread
t = Thread.new do
raise "Something broke inside the thread"
end
begin
t.join
rescue => e
puts "Caught: #{e.message}"
end
Output:
Caught: Something broke inside the thread
Without abort_on_exception = true (or calling t.join/t.value, which re-raises the stored exception), a failing thread can fail completely silently — a classic source of “why didn’t this work?” bugs in production systems.
Thread Synchronization: Mutex, ConditionVariable, and Queue
Even with the GVL, race conditions are very real in Ruby, because the GVL can switch between threads at almost any bytecode boundary — including in the middle of a “simple” operation like += on a shared variable.
counter = 0
mutex = Mutex.new
threads = 10.times.map do
Thread.new do
1000.times do
mutex.synchronize { counter += 1 }
end
end
end
threads.each(&:join)
puts counter # => 10000, reliably, because of the mutex
Without the mutex.synchronize block, this same code will non-deterministically produce a number less than 10,000, because two threads can read the same value of counter before either writes back the incremented result — a textbook race condition.
Thread::Queue for Producer-Consumer Patterns
Thread::Queue is a thread-safe FIFO queue purpose-built for coordinating work between threads, and it’s usually a better tool than manually managing mutexes for this kind of pattern:
require 'thread'
queue = Queue.new
producer = Thread.new do
5.times do |i|
queue << "job-#{i}"
puts "Produced job-#{i}"
sleep 0.1
end
queue.close
end
consumer = Thread.new do
while (job = queue.pop)
puts "Consumed #{job}"
end
end
producer.join
consumer.join
Queue#pop blocks automatically when empty and unblocks automatically once a producer adds work, so you get correct coordination without hand-rolling condition variables.
Fibers: Cooperative, Fine-Grained Control
Where Thread gives you preemptively scheduled concurrency managed by the VM, Fiber gives you cooperative concurrency that you control explicitly. A fiber only yields control when it chooses to, via Fiber.yield, and only resumes when explicitly told to via resume.
fiber = Fiber.new do
puts "Fiber: step 1"
Fiber.yield
puts "Fiber: step 2"
Fiber.yield
puts "Fiber: step 3"
end
fiber.resume # => "Fiber: step 1"
puts "Main: control returned to me"
fiber.resume # => "Fiber: step 2"
fiber.resume # => "Fiber: step 3"
Output:
Fiber: step 1
Main: control returned to me
Fiber: step 2
Fiber: step 3
This is fundamentally different from a thread: there’s no scheduler deciding when the fiber runs. Execution passes explicitly back and forth between resume and Fiber.yield, like a hand-off between two functions that remember exactly where they left off.
Passing Values Between Fiber and Caller
generator = Fiber.new do |start|
value = start
loop do
value = Fiber.yield(value * 2)
end
end
puts generator.resume(1) # => 2
puts generator.resume(5) # => 10
puts generator.resume(10) # => 20
This pattern — a fiber acting as a lazily-evaluated generator — is exactly how Enumerator is implemented internally in Ruby. Every time you call .next on a lazy enumerator, you’re resuming a fiber under the hood.
fib_enum = Enumerator.new do |y|
a, b = 0, 1
loop do
y << a
a, b = b, a + b
end
end
puts fib_enum.take(10).inspect
# => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Controlling the Thread Scheduler: What You Can and Can’t Influence
Ruby doesn’t expose a general-purpose “set thread priority and it will be honored precisely” API the way some lower-level languages do, but it does give you real levers.
Thread Priority
low = Thread.new { loop { Thread.pass } }
low.priority = -1
high = Thread.new { puts "High priority work" }
high.priority = 1
Thread#priority= is a hint to the VM’s scheduler, not a hard guarantee — MRI’s scheduler uses it to bias which runnable thread gets the GVL next, but it doesn’t preempt a thread mid-execution just because a higher-priority thread became runnable.
Thread.pass
Thread.new do
5.times do |i|
puts "Worker: #{i}"
Thread.pass # voluntarily yield remaining time slice to another thread
end
end.join
Thread.pass is a cooperative hint that tells the scheduler “you can run someone else now if you want,” useful in tight loops where you want to be a good citizen toward other runnable threads.
Fiber::SchedulerInterface (Ruby 3.0+)
The most powerful and modern lever is the Fiber::Scheduler interface, introduced in Ruby 3.0. It lets you implement a custom scheduler object that intercepts blocking operations (sleep, I/O waits, mutex waits) and decides what happens during those waits — typically, running other fibers instead of blocking the whole thread.
require 'fiber'
class SimpleScheduler
def initialize
@waiting = []
end
def kernel_sleep(duration)
@waiting << [Fiber.current, Time.now + duration]
Fiber.yield
end
def block(blocker, timeout = nil)
Fiber.yield
end
def unblock(blocker, fiber)
fiber.resume
end
def run
until @waiting.empty?
@waiting.each do |fiber, ready_at|
if Time.now >= ready_at
@waiting.delete([fiber, ready_at])
fiber.resume
end
end
end
end
def close
run
end
end
Fiber.set_scheduler(SimpleScheduler.new)
Fiber.schedule do
puts "Task A starts"
sleep 1
puts "Task A resumes"
end
Fiber.schedule do
puts "Task B starts"
sleep 0.5
puts "Task B resumes"
end
This is a simplified illustration — production schedulers (like the ones inside the async gem) implement the full Fiber::SchedulerInterface, covering io_wait, io_read, io_write, process_wait, and more. But the core idea is exactly what’s shown here: you get to define what “waiting” means, replacing thread-blocking I/O with fiber-yielding I/O, so thousands of lightweight fibers can share a single OS thread efficiently.
This is precisely the mechanism modern async Ruby frameworks (like the async gem, and Falcon web server) use to achieve extremely high concurrency without the memory overhead of one OS thread per connection.
Internal Working: What Actually Happens During a Context Switch
- Thread context switches in MRI involve the VM saving the current thread’s execution context (its call stack, instruction pointer, and local state) and releasing the GVL, then another runnable thread acquires the GVL and resumes. This happens either voluntarily (blocking I/O,
Thread.pass,sleep) or involuntarily, at periodic checkpoints the VM inserts between bytecode instructions (roughly every 100ms of execution by default, configurable viaRUBY_THREAD_TIMESLICEin some builds). - Fiber context switches are far cheaper because there’s no GVL release/reacquire involved and no OS-level thread switch — a fiber switch just swaps the Ruby-level call stack pointer to a different pre-allocated stack. This is why fibers can comfortably scale into the tens of thousands, while OS threads realistically cap out in the low thousands.
- Memory footprint. Each
Threadin MRI allocates a full native OS thread stack, typically defaulting to around 1MB depending on platform (tunable but not trivially so). EachFiber, by contrast, uses a much smaller allocated stack (historically around 4KB–16KB depending on Ruby version and platform, growing as needed), which is why fiber-based concurrency is dramatically more memory-efficient at scale.
Common Mistakes and Debugging Tips
- Assuming threads give CPU parallelism in MRI. They don’t, for pure Ruby code, because of the GVL. Use
Process.forkor external worker processes (e.g., viaParallelgem or Sidekiq’s multi-process model) for genuine CPU-bound parallelism, or consider JRuby/TruffleRuby, which don’t have a GVL. - Forgetting
Thread.abort_on_exception = true(or checkingthread.value). Silent thread failures are one of the most common production surprises in Ruby. - Mutating shared state without a Mutex. Even simple operations like
array << itemorhash[key] += 1are not guaranteed atomic across threads; wrap shared mutable state access inMutex#synchronize. - Deadlocks from nested mutex locking. Locking the same, non-reentrant mutex twice from the same thread will deadlock. Keep lock scopes small and avoid calling code that might re-acquire the same lock.
- Resuming a dead fiber. Calling
.resumeon a fiber that has already run to completion raisesFiberError: dead fiber called. Track fiber state explicitly if you need to resume conditionally. - Mixing fibers and threads carelessly. A
Fiberis tied to the thread that created it and cannot be resumed from a different thread. Passing a fiber across threads is a common source of confusingFiberErrorexceptions.
Debugging tools worth knowing:
Thread.list.each { |t| puts "#{t}: #{t.status}" } # inspect all live threads
# Ruby's built-in tracing for deep debugging
Thread.new { ... }.report_on_exception = true # per-thread exception reporting (default true since Ruby 2.5+)
Real-World Applications
- Web servers (Puma) use a thread pool per worker process to handle concurrent requests, relying on the GVL releasing during I/O (database queries, external API calls) to serve many requests “at once.”
- Background job processors (Sidekiq) use threads for I/O-bound job concurrency within each process, while using multiple OS processes for genuine parallelism.
- Async I/O frameworks (the
asyncgem,Falconweb server) useFiber::Schedulerto handle tens of thousands of concurrent connections on a single thread, avoiding the memory cost of thread-per-connection. - Lazy data pipelines use fibers (via
Enumerator) to process large or infinite sequences without loading everything into memory at once. - Rate limiters and batch processors commonly use
Thread::Queueto coordinate producer/consumer work safely across a fixed pool of worker threads.
Summary
Ruby’s concurrency toolkit is more nuanced than a single “use threads” answer suggests. Threads give you real concurrency for I/O-bound work, governed by the GVL, which means they shine for network calls and database queries but don’t deliver CPU parallelism in MRI. Fibers give you cheap, cooperative, fully controllable concurrency — the same mechanism quietly powering Enumerator under the hood — and become genuinely powerful once combined with Fiber::Scheduler, which lets modern async frameworks handle massive connection counts on minimal OS resources. Getting comfortable with Mutex, Thread::Queue, and the cooperative yield/resume model of fibers, along with a clear mental model of what the GVL actually does and doesn’t protect you from, is what turns “I used threads and it kind of worked” into concurrent Ruby code you can actually trust in production.
