Threads, Mutex & Ractor
Reviewed & published by Brayan K
A thread is a separate flow of execution that lets your program do several things at once, while a Mutex guards shared data and a Ractor unlocks true multi-core parallelism.
Part of the free Ruby course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll run work concurrently, protect shared state safely, and meet Ruby's path to real parallelism.
What You'll Learn in This Lesson
- Starting work with Thread.new and waiting with join
- The GVL: why threads help I/O, not CPU
- Protecting shared state with a Mutex
- Using a thread-safe Queue for producer/consumer work
- Ractors for true multi-core parallelism
- Writing deterministic concurrent code
1️⃣ Threads, join, and the GVL
Thread.new { ... } launches a block concurrently; join makes the main thread wait for it to finish. Because thread finish order is unpredictable, we sort the collected results so the output is deterministic. Remember the GVL: threads interleave but only one runs Ruby code at a time.
# Thread.new starts code running concurrently. join waits for it to finish.
results = []
threads = (1..4).map do |n|
Thread.new do
squared = n * n
results << squared # each thread does a little work
end
end
threads.each(&:join) # wait for ALL threads before continuing
# Sort so the output is deterministic regardless of finish order.
puts results.sort.inspect
puts "All #{threads.size} threads done."2️⃣ Mutex: Safe Shared State
When several threads update the same variable, counter += 1 is three steps — read, add, write — and threads can interleave and lose updates. A Mutex fixes this: mutex.synchronize { ... } lets only one thread inside at a time, so the count is always correct.
# A Mutex protects shared state so two threads don't corrupt it.
mutex = Mutex.new
counter = 0
threads = 10.times.map do
Thread.new do
1000.times do
mutex.synchronize do # only ONE thread in here at a time
counter += 1 # read-modify-write is now safe
end
end
end
end
threads.each(&:join)
puts "Counter: #{counter}" # always exactly 10_000 with the mutex3️⃣ Queue & a Glimpse of Ractor
A Queue is a thread-safe pipe — producers push, consumers pop, and locking is handled for you, ideal for worker pools. And when you truly need multiple cores, a Ractor runs in parallel with isolated memory, passing data in rather than sharing it.
# A Queue is a thread-safe pipe: producers push, consumers pop, no mutex needed.
queue = Queue.new
processed = []
mutex = Mutex.new
# Producer: push 6 jobs onto the queue.
(1..6).each { |i| queue << i }
queue.close # signal "no more work coming"
# Two worker threads drain the queue.
workers = 2.times.map do
Thread.new do
while (job = queue.pop) # pop returns nil once closed and empty
result = job * 10
mutex.synchronize { processed << result }
end
end
end
workers.each(&:join)
puts processed.sort.inspect # sorted for deterministic outputAnd the parallel option — a Ractor sidesteps the GVL. Each one gets its own memory; you pass values in and take results out.
# A Ractor runs in TRUE parallel — each has its own isolated memory.
# (Ractors are experimental; Ruby prints a one-time warning to stderr.)
ractors = (1..4).map do |n|
Ractor.new(n) do |num| # values are PASSED IN, not shared
num * num # CPU work that can run on another core
end
end
# take blocks until each Ractor produces its value.
squares = ractors.map { |r| r.take }
puts squares.sort.inspect🎯 Your Turn
The threads compute partial sums, but the main thread races ahead before they finish. Replace the ___ with the method that waits for each thread.
# 🎯 YOUR TURN — fill in the ___ so the main thread waits for the workers.
totals = []
mutex = Mutex.new
threads = [10, 20, 30].map do |start|
Thread.new do
sum = (start...start + 5).sum
mutex.synchronize { totals << sum }
end
end
threads.each(&:___) # 👉 which method blocks until each thread finishes?
puts totals.sort.inspect
# ✅ Expected output:
# [60, 110, 160]Common Errors (and the fix)
- Program exits before threads run — ❌ You forgot to join. ✅ Call threads.each(&:join) before using results.
- Counter total is wrong/random — ❌ Unguarded shared write. ✅ Wrap it in mutex.synchronize.
- Exceptions vanish silently — ❌ A thread raised but you never joined it. ✅ join re-raises the error in the main thread.
- Deadlock (program hangs) — ❌ Two threads each waiting on a lock the other holds. ✅ Always acquire locks in the same order; keep critical sections tiny.
- Ractor "can't share" error — ❌ Passing an unshareable mutable object. ✅ Pass immutable data or copies; freeze what you share.
Pro Tips
- 💡 Sort before printing concurrent results to keep tests and output deterministic.
- 💡 Prefer Queue over hand-rolled Mutex juggling for producer/consumer pipelines.
- 💡 I/O vs CPU: threads for network/file waits, Ractors or processes for heavy computation.
Mini-Challenge: Parallel Word Count
Count words across several lines using one thread per line, a shared hash, and a Mutex to keep the tallies correct. Print sorted by word. Run with ruby wordcount.rb.
# 🎯 MINI-CHALLENGE: parallel word-count with threads + a Mutex.
# Save as wordcount.rb and run with: ruby wordcount.rb
#
# 1. Give each text line its own thread.
# 2. Each thread splits on spaces and tallies words into a SHARED hash.
# 3. Guard the hash with a Mutex so the counts stay correct.
# 4. Print results sorted by word for deterministic output.
#
# texts = ["the cat sat", "the dog ran fast", "a cat and a dog"]
#
# ✅ Expected output:
# a: 2
# and: 1
# cat: 2
# dog: 2
# fast: 1
# ran: 1
# sat: 1
# the: 2
# your code here📋 Quick Reference — Concurrency
| Goal | Ruby |
|---|---|
| Start a thread | Thread.new { ... } |
| Wait for threads | threads.each(&:join) |
| Guard shared state | mutex.synchronize { ... } |
| Thread-safe pipe | q = Queue.new; q << x; q.pop |
| True parallelism | Ractor.new(x) { |v| ... } |
| Get a Ractor result | ractor.take |
🎉 Lesson Complete!
- ✅ Thread.new runs work concurrently; join waits for it
- ✅ The GVL means threads help I/O, not CPU-bound work
- ✅ A Mutex makes shared updates safe
- ✅ A Queue is a thread-safe producer/consumer pipe
- ✅ Ractor gives true multi-core parallelism with isolated memory
Practice quiz
What does Thread.new do?
- Pauses the program
- Creates a new process
- Starts a block of code running concurrently
- Locks a mutex
Answer: Starts a block of code running concurrently. Thread.new launches its block to run concurrently with the rest of the program.
What does calling join on a thread do?
- Makes the caller wait until that thread finishes
- Cancels the thread
- Starts the thread
- Merges two threads
Answer: Makes the caller wait until that thread finishes. join blocks the calling thread until the joined thread has finished.
Because of MRI Ruby's GVL (GIL), how many threads run Ruby code at the same instant?
- As many as cores
- Two
- Unlimited
- Exactly one
Answer: Exactly one. The Global VM Lock allows only one thread to execute Ruby code at any instant.
For which kind of work do Ruby threads provide real speedup despite the GVL?
- CPU-bound number crunching
- I/O-bound work like network and file reads
- Nothing — threads never help
- Only sorting
Answer: I/O-bound work like network and file reads. The lock is released during blocking I/O, so threads overlap well for I/O-bound work.
Why is an unguarded counter += 1 across threads unsafe?
- It is read-modify-write, so threads can interleave and lose updates
- It is a comment
- It freezes the counter
- It always raises an error
Answer: It is read-modify-write, so threads can interleave and lose updates. counter += 1 is read-modify-write; without protection threads interleave and updates are lost.
What does mutex.synchronize { ... } guarantee?
- The block runs faster
- The block never runs
- Only one thread runs the protected block at a time
- All threads run it together
Answer: Only one thread runs the protected block at a time. synchronize makes the protected block atomic — only one thread inside at a time.
What is a Queue in Ruby's concurrency toolkit?
- A sorted array
- A thread-safe pipe where producers push and consumers pop
- A type of mutex
- A database
Answer: A thread-safe pipe where producers push and consumers pop. A Queue is already thread-safe: push with << and pull with pop, locking handled internally.
Calling pop on a Queue that is empty (but not closed) will...
- Raise an error
- Return nil immediately
- Close the queue
- Block and wait until an item arrives
Answer: Block and wait until an item arrives. pop blocks while the queue is empty, which suits producer/consumer pipelines.
What makes a Ractor able to run in true parallel across cores?
- It ignores the GVL by sharing memory
- Each Ractor has isolated memory and sidesteps the GVL
- It uses more threads
- It disables the garbage collector
Answer: Each Ractor has isolated memory and sidesteps the GVL. Each Ractor has isolated memory and its own lock, so CPU work can use multiple cores.
How do you keep concurrent output deterministic when finish order is unpredictable?
- Use more threads
- Avoid join
- Sort the collected results before printing
- Print inside each thread
Answer: Sort the collected results before printing. Sorting the gathered results makes output stable regardless of thread finish order.
Continue this course
- Previous: Fibers & Lazy Evaluation
- Next: Automation with Rake — Define tasks, dependencies, and namespaces in a Rakefile
- Quick reference: Ruby cheat sheet
Frequently asked questions
What is the GVL (Global VM Lock) and why does it matter?
MRI Ruby has a Global VM Lock (also called the GIL) that allows only one thread to execute Ruby code at any instant. This means Ruby threads do NOT speed up CPU-bound work like number crunching — they take turns. However, the lock is released during blocking I/O (network requests, file reads, sleeps), so threads overlap beautifully when they're waiting on the outside world. The practical rule: use threads for I/O-bound concurrency, and reach for Ractors or separate processes when you need real CPU parallelism.
When do I need a Mutex?
You need a Mutex whenever two or more threads modify the same shared object — an array, a hash, a counter. Operations like counter += 1 are read-modify-write, and without protection two threads can interleave and lose updates, producing a wrong total. mutex.synchronize { ... } guarantees only one thread runs the protected block at a time, making the update atomic. If each thread works only on its own local data, or you use a thread-safe structure like Queue, you may not need an explicit Mutex at all.
How is a Queue different from using a Mutex directly?
A Queue is a higher-level, already-thread-safe structure: push items with << and pull them with pop, and Ruby handles all the locking internally. It also blocks: pop waits if the queue is empty, which is perfect for producer/consumer pipelines where workers wait for jobs to arrive. A raw Mutex is lower-level — you decide exactly what to protect. Use a Queue when the problem is 'hand work between threads'; use a Mutex when you need to guard arbitrary shared state that doesn't fit the queue model.
What is a Ractor and how does it differ from a Thread?
A Ractor (Ruby Actor) is the building block for true parallel execution in Ruby: each Ractor has isolated memory and runs on its own, sidestepping the GVL so CPU-bound work can use multiple cores. The trade-off is that Ractors cannot freely share mutable objects — you pass messages or copy data between them, which prevents the data races that plague shared-memory threads. Ractors are still marked experimental, so the API may change, but they point to Ruby's future for parallel, multi-core workloads.