Concurrency in Ruby: Threads, the GIL, and Everything Around Them
This post introduces the core concepts of concurrency in Ruby: what concurrency is, how it differs from parallelism, the impact of Ruby's GIL, and the fundamentals of writing thread-safe code.
What Is Concurrency?
Concurrency is about dealing with multiple things at once. It's a way of structuring a program so that several tasks are in progress over the same period, even if only one is actually executing at any given instant.
The key word is structure. A concurrent program breaks work into independent tasks that can be interleaved. The system rapidly switches between them — task A runs briefly, pauses, task B runs, pauses, then task A resumes. Zoom in far enough and only one thing is happening at a time. Zoom out and all of them appear to be progressing together.
Think of one cook in a kitchen preparing three dishes. They start the pasta water, and while it heats they chop vegetables. When the water boils they drop the pasta, then return to chopping. One cook, one pair of hands, but three dishes are underway. That's concurrency: making progress on multiple tasks by never sitting idle during a wait.
What Is Parallelism?
Parallelism is about doing multiple things at once — literally, simultaneously. It requires multiple execution units: multiple CPU cores, multiple processors, multiple machines.
Now put three cooks in that kitchen, each with their own station and their own dish. At any given moment, all three are actively cooking. No one is taking turns. Nobody is taking turns.
Parallelism needs hardware support. On a single-core machine, true parallelism is impossible no matter how you write your code — you can only ever interleave.
What Is a Thread?
Concurrency and parallelism are ideas. A thread is one of the concrete tools Ruby gives you to actually build them.
A thread is a unit of execution inside a process. Every Ruby program starts with one — the main thread — and you can spawn more with Thread.new. Each new thread runs its block independently while the rest of your program carries on.
thread = Thread.new do
puts "Hello from a new thread!"
end
thread.join # wait for it to finish
Two things matter about that snippet.
First, Thread.new returns immediately. The block starts running, but the main thread doesn't stop and wait for it. That's the whole point — two things are now in progress.
Second, join is how you wait. It blocks the calling thread until the target thread finishes. Without it, your program may exit before the thread ever gets a chance to run, and the output silently disappears:
Thread.new { puts "Am I ever printed?" }
# program ends here — probably prints nothing
The other defining trait of threads: they share memory with the process that spawned them. Every thread sees the same objects, the same variables, the same everything. This makes passing data between them essentially free, and it's why threads are far cheaper than separate processes.
It is also the source of nearly every threading bug, which we'll get to shortly.
Concurrency vs. Parallelism
The clearest framing comes from Rob Pike: concurrency is about dealing with lots of things at once; parallelism is about doing lots of things at once. One is a property of how your program is structured, the other is a property of how it executes.
Rather than describe it, let's take one task and run it three ways. The task is deliberately chosen to have two stages — a part that waits and a part that computes — because that's what makes the difference between the two concepts visible.
The task
For each of three images: download it (the program waits on the network — no CPU work), then resize it (the CPU works flat out — no waiting). Each stage takes about one second per image.
IMAGES = ["photo1.jpg", "photo2.jpg", "photo3.jpg"]
def download(name)
sleep(1) # ~1s network wait; CPU idle
"image data for #{name}" # (stands in for Net::HTTP.get)
end
def resize(data)
sum = 0
18_000_000.times { |i| sum += i } # ~1s of pure CPU crunching
sum # (stands in for real image resizing)
end
def process(name)
data = download(name) # wait
resize(data) # compute
end
To keep the benchmark runnable anywhere — no gems, no network — we'll simulate the two stages honestly: sleep behaves exactly like a network wait (the CPU is idle, and Ruby treats it like I/O), and a pure-Ruby counting loop behaves exactly like image resizing (the CPU is pinned the whole time)
Keep those two stages in mind. Concurrency can overlap the waiting. Parallelism can overlap the computing. One example, and you'll see each concept act on a different stage.
Version 1: Sequential — neither concurrent nor parallel
start = Time.now
IMAGES.each { |name| process(name) }
puts "Sequential: #{(Time.now - start).round(2)}s"
# => Sequential: 6.09s
One image fully finished before the next begins: download, resize, download, resize, download, resize. Three seconds of waiting plus three seconds of computing, all in a line. ~6 seconds.
Time → |---1s---|---1s---|---1s---|---1s---|---1s---|---1s---|
photo1: [download][ resize ]
photo2: [download][ resize ]
photo3: [download][ resize ]
Nothing overlaps. Even while photo1 downloads and the
CPU is completely idle, photo2 just sits there waiting.
Version 2: Threads — concurrent, but not parallel
Same work, each image in a thread:
start = Time.now
IMAGES.map { |name| Thread.new { process(name) } }.each(&:join)
puts "Threaded: #{(Time.now - start).round(2)}s"
# => Threaded: 3.96s
Six seconds down to four. Watch which two seconds disappeared. While one thread sits waiting on its download, another thread runs — so all three downloads overlap and the 3 seconds of waiting collapse to about 1.
But the resizing does not overlap. Resizing is pure CPU, and only one thread can execute Ruby at a time, so the three one-second resizes still happen one after another — about 3 seconds. Total: ~1 (waiting, overlapped) + ~3 (computing, not overlapped) = ~4 seconds.
Time → |---1s---|---1s---|---1s---|---1s---|
Thread 1: [download][ resize ]
Thread 2: [download]·········[ resize ]
Thread 3: [download]··················[ resize ]
·········· = runnable, but waiting its turn to execute Ruby
└───┬────┘└──────────┬───────────────┘
downloads overlap resizes take turns —
(all three waits only one thread may
happen at once) execute Ruby at a time
That 6→4 improvement is concurrency. It ate the waiting and left the computing untouched.
Version 3: Processes — concurrent and parallel
Same work again, each image in its own process:
start = Time.now
IMAGES.map { |name| fork { process(name) } }
.each { Process.wait(_1) }
puts "Forked: #{(Time.now - start).round(2)}s"
# => Forked: 2.11s (on a machine with 3+ cores)
Four seconds down to two. The downloads still overlap as before — but now the resizes overlap too. Each process has its own interpreter and can run on its own core, so all three CPU-bound resizes execute at the same instant. The 3 seconds of computing collapse to about 1. Total: ~1 (waiting) + ~1 (computing) = ~2 seconds.
Time → |---1s---|---1s---|
Core 1: [download][ resize ] ← process 1
Core 2: [download][ resize ] ← process 2
Core 3: [download][ resize ] ← process 3
Every column of the timeline has three things
genuinely happening at the same instant.
That extra 4→2 improvement is parallelism. It ate the computing that concurrency alone couldn't touch.
(Two caveats for anyone running this: fork is Unix-only — it works on Linux and macOS but not Windows or JRuby. And the ~2s result needs at least three CPU cores; on a single-core machine the forked version takes ~4s, same as threads, because parallelism is a hardware property — there's simply no second core to run on.)
Reading the results
The exact numbers will vary with your machine and network — what stays constant is which stage shrinks at each step, and that's the whole point.
Going from sequential to threads, the waiting collapsed: the three downloads overlapped and the total dropped by roughly the two download-seconds that used to stack up. The computing didn't budge — the three resizes still ran one after another. That improvement is concurrency, and threads were enough to get it.
Going from threads to processes, the computing collapsed too: with each image in its own process on its own core, the three resizes ran at the same instant, and the total dropped again by roughly the two resize-seconds that threads couldn't touch. That extra improvement is parallelism, and it took separate processes to get there.
So in one example: concurrency overlapped the waiting, parallelism overlapped the computing, and only processes delivered both.
The reason threads couldn't touch the computing isn't a law of nature. It's specific to Ruby, and it has a name.
The GIL: Why This Distinction Matters in Ruby
Ruby's reference implementation (MRI/CRuby) has a Global Interpreter Lock — the GIL, or more precisely the GVL (Global VM Lock). It guarantees that only one thread executes Ruby code at a time, no matter how many threads you spawn or how many cores you have.
In other words: on MRI, threads give you concurrency but never parallelism.
Seeing the GVL in action
Two threads doing pure CPU work don't get faster, because they're taking turns holding the lock:
require 'benchmark'
def cpu_work
sum = 0
30_000_000.times { |i| sum += i }
sum
end
seq = Benchmark.realtime { cpu_work; cpu_work }
thr = Benchmark.realtime do
[Thread.new { cpu_work }, Thread.new { cpu_work }].each(&:join)
end
puts "CPU sequential: #{seq.round(2)}s" # => CPU sequential: 1.78s
puts "CPU two threads: #{thr.round(2)}s" # => CPU two threads: 1.85s
Same total time. The threads are both making progress — MRI hands the GVL back and forth between them every few milliseconds — but at any instant only one is executing. It looks like this:
Time →
Thread A: ▓▓▓▓░░░░▓▓▓▓░░░░▓▓▓▓░░░░
Thread B: ░░░░▓▓▓▓░░░░▓▓▓▓░░░░▓▓▓▓
GVL: A B A B A B
▓ = holds the GVL, executing Ruby
░ = runnable, waiting for the GVL
Two threads, but every column has exactly ONE ▓.
Total work done per second is unchanged — the lock
just changes hands. Add a second core and nothing
improves: the GVL, not the hardware, is the limit.
That sounds like a crippling limitation, and for CPU-bound work it is. But there's a crucial exception that makes threads genuinely useful: a thread releases the GVL while it waits on I/O. When your code calls out to the network, reads a file, queries a database, or sleeps, that thread hands the lock to someone else until the answer comes back.
Run the same experiment with waiting instead of computing:
io_seq = Benchmark.realtime { sleep(1); sleep(1) }
io_thr = Benchmark.realtime do
[Thread.new { sleep(1) }, Thread.new { sleep(1) }].each(&:join)
end
puts "I/O sequential: #{io_seq.round(2)}s" # => I/O sequential: 2.0s
puts "I/O two threads: #{io_thr.round(2)}s" # => I/O two threads: 1.0s
Now threads cut the time in half, because a waiting thread doesn't need the lock at all:
Time →
Thread A: ▓~~~~~~~~~~~~~~~~~~▓
Thread B: ░▓~~~~~~~~~~~~~~~~~▓
▓ = holds the GVL, executing Ruby
░ = waiting for the GVL
~ = waiting on I/O — GVL RELEASED, anyone may run
Both waits happen during the same second. The GVL
only serializes the tiny ▓ slivers of actual Ruby
execution at each end; the long ~ stretches overlap
freely.
Look back at the threaded image-processing diagram and you can now read it precisely: the downloads overlapped because sleep-style I/O waits release the GVL (the ~ case), and the resizes serialized because CPU work holds it (the ▓░ case).
Why does the GVL exist at all? Ruby objects are shared across threads. Without a global lock, every operation that reads or modifies an object would need fine-grained synchronization. Every array append, hash lookup, object allocation, reference count update (historically), or garbage collection step would become significantly more complex.
This single fact — held during computing, released during waiting — explains almost everything about concurrency in the Ruby ecosystem.
Where Concurrency Actually Helps
I/O-bound tasks: yes
An I/O-bound task spends most of its time waiting — for a database query, an HTTP response, a disk read. The CPU is idle during that wait, and idle CPU is wasted opportunity. That's the 3s → 1s speedup we measured earlier: the waits overlapped.
Recognizing this category is the skill worth building. I/O-bound work includes HTTP calls to third-party APIs, database queries, reading and writing files, cache lookups, sending email, and uploading to object storage.
Most web application work falls here. A typical Rails request spends the majority of its time waiting on the database. That's why Puma runs multiple threads per process and that's why Sidekiq's concurrency is measured in threads — the work is mostly waiting, and waiting overlaps beautifully.
# A stand-in external API: the sleep is real I/O-style waiting (the
# GVL is released), only the payload is fake. Swap the body for a
# Net::HTTP call in real code — the threading behavior is identical.
module ExternalAPI
def self.fetch(id)
sleep(0.5) # network latency; CPU idle
{ id: id, name: "User #{id}" } # parsed response
end
end
# Good use of threads — the work is waiting
user_ids = (1..20).to_a
user_ids.each_slice(10) do |batch|
batch.map { |id| Thread.new { ExternalAPI.fetch(id) } }.each(&:join)
end
(We'll reuse this ExternalAPI in examples through the rest of the post.)
CPU-bound tasks: no
A CPU-bound task keeps the processor busy the entire time, so there's never a moment when the GIL gets released. Typical examples: image and video processing, cryptography and password hashing, large numeric computations, parsing or serializing huge documents, and in-memory sorting of big collections.
Adding threads here makes things slower, not faster — you pay for context switching and get nothing back. That's the CPU benchmark from earlier, where three threads were no better than doing the work sequentially.
For CPU-bound work you need real parallelism, which on MRI means escaping the GIL:
- Multiple processes (
fork, or theparallelgem) — each process has its own GIL, so they truly run in parallel. Costs memory and gives you no shared state. - Ractors — parallel execution inside one Ruby process (more below).
- Native extensions — C extensions can release the GIL during heavy computation, which is how libraries like
nokogiriand numeric gems stay fast.
The rule of thumb: threads for waiting, processes or Ractors for computing.
Race Conditions
Threads within a process share memory. That makes communication between them nearly free — and introduces an entire category of bugs.
A race condition happens when two threads access shared data at the same time and the result depends on which one gets there first. Here's the example every threading tutorial starts with:
counter = 0
threads = 10.times.map do
Thread.new do
1000.times { counter += 1 }
end
end
threads.each(&:join)
puts counter
Ten threads, a thousand increments each. The classic claim is that you'll get less than 10,000, because counter += 1 is not one operation but three:
- Read the current value of
counter - Add one to it
- Write the result back
If a thread switch lands between the read and the write, an update gets lost:
Thread A reads counter -> 100
Thread B reads counter -> 100 (A hasn't written yet!)
Thread A computes 101, writes -> counter is now 101
Thread B computes 101, writes -> counter is still 101
Two increments happened; the counter went up by one.
But run that snippet on modern MRI and you'll get 10,000 every single time. Try it — crank it to a million iterations and it's still correct. So is the classic example wrong?
Why MRI hides this particular race
MRI doesn't switch threads at arbitrary instructions. It only checks whether it's time to switch at specific safe points — the end of a loop iteration, method calls, and similar boundaries. The optimized integer += on a local variable contains no safe point between its read and its write, so on today's MRI that three-step operation never actually gets split. The race exists in theory; the interpreter's scheduling happens to never expose it.
This is luck, not a guarantee. It's an implementation detail of current CRuby — not something the language promises. Run the same code on JRuby or TruffleRuby, which have no GVL and truly parallel threads, and it loses updates immediately.
The version that races on modern MRI
Change exactly one thing: route the increment through a method. No sleeps, no tricks — just the kind of indirection every real codebase has:
def bump(x)
x + 1
end
counter = 0
threads = 10.times.map do
Thread.new do
1_000_000.times { counter = bump(counter) }
end
end
threads.each(&:join)
puts counter # Expected 10,000,000. Actual runs:
# => 6660464
# => 8464459
# => 8572628
Now it loses 15–35% of its updates, every run, a different number each time. The method call is what changed: returning from bump is a safe point, so the thread can now be switched out after it read counter but before it writes the result back — exactly the gap the interleaving diagram described.
That's all it takes. Not artificial pressure—just one method call between the read and the write.
Real applications almost always have something between those two operations: a calculation, a validation, a database query, or an API call. The wider that gap becomes, the more opportunities there are for another thread to intervene.
The same pattern appears in everyday code:
@config ||= load_config- check-then-insert on a hash
- reading an account balance before withdrawing funds
Each is a read-modify-write sequence that can race when shared across threads.
The reason races are so dangerous is that they're nondeterministic — the code passes every test in development, then corrupts data in production under load, a different amount each time.
So the honest statement about the GVL is: it does not make your read-modify-write logic safe. It happens to mask the single-expression += case on MRI, but anywhere real work separates a read from a write on shared state, you have a race — and code that only works because of a CRuby scheduling quirk is broken code that hasn't failed yet.
Mutexes
A mutex (mutual exclusion lock) prevents this race by ensuring that only one thread at a time can enter a critical section. Take the method-call counter that actually races and wrap the read-and-write in a lock:
def bump(x)
x + 1
end
counter = 0
mutex = Mutex.new
threads = 10.times.map do
Thread.new do
1_000_000.times do
mutex.synchronize { counter = bump(counter) }
end
end
end
threads.each(&:join)
puts counter # Reliably 10000000
synchronize acquires the lock, runs the block, and releases it. If another thread already holds the lock, the arriving thread waits at the door. Note what the mutex does and doesn't do: thread switches can still happen inside the block — returning from bump is still a safe point — but a switched-in thread that reaches synchronize simply blocks until the current holder finishes. The read-modify-write is now indivisible with respect to every other thread using the same mutex, and that's all it takes to close the gap.
The tradeoff is contention. A thread waiting for a mutex can't make progress, so excessive locking reduces concurrency. Two guidelines follow:
Keep critical sections small. Lock only the shared-state access, not the slow work around it.
# Bad — holds the lock through a slow network call
mutex.synchronize do
data = ExternalAPI.fetch(id) # every other thread is now blocked
results << data
end
# Good — only the shared append is locked
data = ExternalAPI.fetch(id)
mutex.synchronize { results << data }
One more hazard worth naming: deadlock. If thread A holds lock 1 and wants lock 2, while thread B holds lock 2 and wants lock 1, both wait forever. The usual prevention is to always acquire multiple locks in the same order everywhere in your codebase.
A mutex is the first of Ruby's two low-level coordination primitives. The second answers a different question entirely.
ConditionVariable: Waiting for Something to Become True
A mutex answers "may I touch this state?" Sometimes the question is different: "wake me when the state changes."
Stay with our counter. Suppose some thread needs to react the instant the counter reaches ten million — log it, fire a webhook, whatever. How does it wait? It could loop, locking and checking over and over — but that burns CPU asking a question whose answer is almost always no. It could check, sleep a bit, and check again — but then it reacts late, and still wakes up pointlessly. Think of a restaurant buzzer: you don't stand at the counter asking "is it ready?" every ten seconds, and you don't wander back every five minutes. You take the buzzer, sit down, and the kitchen buzzes you at the exact right moment.
A ConditionVariable is that buzzer. wait is sitting down with it; signal is the kitchen pressing the button:
def bump(x)
x + 1
end
TARGET = 10_000_000
counter = 0
mutex = Mutex.new
cond = ConditionVariable.new
# The watcher: sleeps until the counter reaches the target
watcher = Thread.new do
mutex.synchronize do
until counter >= TARGET
cond.wait(mutex) # release the lock and sleep, in one atomic step
end
puts "Counter reached #{counter}!"
end
end
# The workers: same increment loop as before, plus one buzz at the end
threads = 10.times.map do
Thread.new do
1_000_000.times do
mutex.synchronize do
counter = bump(counter)
cond.signal if counter == TARGET # tap the watcher awake
end
end
end
end
threads.each(&:join)
watcher.join
The watcher sleeps through ten million increments — zero CPU spent checking — and wakes exactly once, at exactly the right moment.
Two details make this work, and both hide in cond.wait(mutex):
It releases the lock before sleeping. The watcher checks counter while holding the mutex — it has to; the counter is shared state. But if it kept holding the lock while asleep, no worker could ever acquire the mutex to increment, and everyone would deadlock. So wait drops the lock, sleeps, and reacquires it when woken — the waiter sleeps unlocked, so the signalers can get in.
Releasing and sleeping happen as one atomic step. If they were two separate steps, a worker could slip into the gap — increment to ten million and signal after the watcher released the lock but before it fell asleep. The buzz would hit a thread that isn't waiting yet, wake nobody, and the watcher would then sleep forever waiting for a signal that already fired. The atomicity closes that gap.
Note also the until loop rather than an if: a woken thread always re-checks the condition, because being woken is a hint, not a promise — the buzzer went off, but confirm your food is actually there before walking away with it. (And signal wakes one waiter; broadcast wakes them all.)
If this looks like fiddly machinery you'd rather not hand-assemble — agreed. And Ruby agrees: it ships the two primitives pre-packaged.
Queue: the Two Primitives, Packaged
Take a Mutex (safe access to shared state) and a ConditionVariable (sleep until the state changes), wrap them around an array, and you've built Ruby's Queue — a thread-safe structure where push adds an item and pop blocks efficiently until one is available. Under the hood, pop on an empty queue is exactly a cond.wait, and push is exactly a cond.signal. Everything from the previous two sections, hidden behind two methods.
That packaging enables the best guideline in threaded programming: prefer not sharing state at all. The safest shared variable is the one that doesn't exist. Instead of many threads mutating a common object under locks you maintain, have them pass items through a Queue and keep everything else thread-local — the producer/consumer pattern:
queue = Queue.new
producer = Thread.new do
10.times { |i| queue.push(i) }
queue.close
end
workers = 3.times.map do
Thread.new do
while (item = queue.pop)
puts "Processing #{item}"
end
end
end
[producer, *workers].each(&:join)
Not a single mutex or condition variable in sight — yet both are in there, doing the locking and the waking for you, correctly. The workers sleep when the queue is empty (no polling, courtesy of the ConditionVariable) and never corrupt shared state (courtesy of the Mutex).
This pattern — worker threads pulling from a shared queue — is essentially what Sidekiq does at production scale.
So when do you use the raw primitives? Only when your coordination rule is too custom for an existing structure to express — a threshold watcher like the one above, a bounded buffer with special rules, a "wait until all workers check in" gate. For everything else, reach for Queue first.
Thread Pools
Everything so far spawned a thread per task. That's fine for three downloads — and a disaster for ten thousand. Threads aren't free: each one costs memory for its stack (roughly 1 MB reserved on most systems), registration with the OS scheduler, and a database connection if it touches the database. Thread.new inside a loop over unbounded input means a traffic spike converts directly into a thread explosion.
A thread pool inverts the model: instead of one thread per task, you start a fixed number of worker threads and feed them tasks through a queue. Tasks can arrive by the thousands; the thread count never moves.
You've already seen one. The Queue producer/consumer example above is a thread pool — three workers, jobs flowing through a queue. Name the parts and it becomes:
POOL_SIZE = 3
jobs = Queue.new
def process(job)
# the work goes here — an API fetch, an image resize, a DB write.
# Left instant so the example runs in a blink even with 10,000 jobs.
end
# The pool: a fixed set of long-lived workers
workers = POOL_SIZE.times.map do
Thread.new do
while (job = jobs.pop) # pop sleeps until a job arrives —
process(job) # that's the ConditionVariable inside Queue
end
end
end
# Submit as many tasks as you like — thread count never changes
10_000.times { |i| jobs.push(i) }
jobs.close # no more jobs coming
workers.each(&:join) # workers drain the queue and exit
The pool size becomes your concurrency dial, and choosing it follows the rules from earlier in this post: for I/O-bound work, more threads than cores makes sense because most are waiting at any moment; sizing is bounded by what you're hammering (connection limits, API rate limits) rather than CPU. For CPU-bound work on MRI, the GVL means extra threads add nothing — which is why CPU pools belong in processes, not threads.
This isn't an academic pattern — it's the architecture of the Ruby ecosystem. Puma is a thread pool fed by a socket: N threads pulling incoming requests off a backlog. Sidekiq is a thread pool fed by Redis: N threads pulling jobs off a queue. When you configure threads 5, 5 in Puma or concurrency: 10 in Sidekiq, you are setting exactly the POOL_SIZE above. Parts two and three of this series are, in a real sense, about two production-grade queues feeding thread pools.
For production code, prefer a maintained implementation over hand-rolling. The de facto standard toolkit is concurrent-ruby — Rails itself depends on it. It packages everything this post built by hand into ready-made tools: Concurrent::FixedThreadPool is our pool with the operational edges handled, and its thread-safe structures replace hand-rolled locking entirely — the whole race-condition-plus-mutex saga collapses into counter = Concurrent::AtomicFixnum.new(0) and counter.increment, no mutex in sight. One caveat to carry with you: concurrent-ruby runs on ordinary Ruby threads, so it does not escape the GVL — every rule in this post applies to it unchanged. Great for I/O-bound work, no help for CPU-bound work on MRI.
Beyond Threads: Fibers and Ractors
Threads aren't Ruby's only concurrency primitive. Two others are worth knowing, each solving a different half of the problem.
Fibers: cooperative concurrency
A Fiber is a lightweight unit of execution that you control manually. Unlike threads, where the scheduler decides when to switch, a Fiber runs until it explicitly yields.
fiber = Fiber.new do
puts "step one"
Fiber.yield # pause here, hand control back
puts "step two"
Fiber.yield
puts "done"
end
fiber.resume # => "step one"
fiber.resume # => "step two"
fiber.resume # => "done"
Fibers are dramatically cheaper than threads — you can create tens of thousands without trouble — and because switching only happens where you say it does, many race conditions simply can't occur.
The catch is that raw Fibers require you to orchestrate everything by hand. Their real power arrives through the Fiber Scheduler introduced in Ruby 3.0, which lets libraries automatically yield a Fiber whenever it hits I/O. The async gem builds on this to deliver very high I/O concurrency with clean, ordinary-looking code:
require 'async'
Async do
urls.each do |url|
Async { Net::HTTP.get(URI(url)) } # yields on I/O automatically
end
end
Fibers solve the same problem threads do — overlapping I/O waits — but with less memory and fewer sharp edges. They still don't give you parallelism.
Ractors: actual parallelism
Ractors (Ruby 3.0+) are the answer to the CPU-bound problem. Each Ractor gets its own GIL, so multiple Ractors genuinely execute Ruby code in parallel across cores.
They achieve this by enforcing isolation: Ractors can't freely share mutable objects. Instead they pass messages, and objects are either copied or ownership is transferred.
ractors = 4.times.map do
Ractor.new do
1_000_000.times.reduce(:+) # runs truly in parallel
end
end
results = ractors.map(&:take)
Because there's no shared mutable state, the race conditions and mutexes from earlier in this post largely stop being a concern — the design prevents them structurally rather than asking you to lock correctly.
The caveat: Ractors are still experimental. You'll see a warning on use, the isolation rules reject a lot of ordinary Ruby, and most gems aren't Ractor-safe. They're worth experimenting with and worth watching, but for production CPU-bound parallelism today, multiple processes remain the pragmatic choice.
Summary
Concurrency is about structure—dealing with many things at once. Parallelism is about execution—doing many things at once. On MRI, threads give you the first but not the second, and that single constraint explains why they excel at I/O-bound work but do little for CPU-bound workloads.
Along the way, we've also seen the trade-offs: threads share memory, which makes communication cheap but introduces race conditions. When shared state is unavoidable, protect it with synchronization primitives like Mutex.
Keep those ideas in mind, and the rest of Ruby's concurrency model starts to make sense. Threads are for overlapping waits, processes and Ractors are for parallel computation, and choosing the right tool becomes a matter of understanding the work you're trying to do.