Ruby Threads and Thread Safety — Practical Concurrency Without the Chaos
Threads in Ruby have a complicated reputation — partly because of the Global Interpreter Lock (GIL) in MRI Ruby, partly because concurrent code genuinely is harder to reason about, and partly because most Ruby developers rarely need to write threaded code directly. But threads matter more than they once did: Puma runs Rails requests concurrently across threads, background job processors are multi-threaded, and Ruby 3 pushed concurrency forward with both Ractors and improved Fiber scheduling. Understanding how threads behave in Ruby, where the GIL helps and where it doesn’t, and how to write thread-safe code is more relevant than the “just avoid threads” advice suggests.
Thread Basics in MRI Ruby
MRI Ruby uses a Global Interpreter Lock (GIL, also called GVL) that prevents more than one thread from executing Ruby code simultaneously. This means Ruby threads don’t achieve true parallelism for CPU-bound work on MRI — but they do allow concurrent I/O, because the GIL is released when a thread is waiting on I/O.
Example:
# Thread creation
t1 = Thread.new { sleep(1); puts "Thread 1 done" }
t2 = Thread.new { sleep(1); puts "Thread 2 done" }
t1.join # wait for t1 to finish
t2.join # wait for t2 to finish
# Both print at roughly 1 second (concurrent I/O, not sequential 2 seconds)
Example:
# Threads share the process's memory — and its variables
results = []
threads = (1..5).map do |i|
Thread.new { results << i * 2 }
end
threads.each(&:join)
results # => [2, 4, 6, 8, 10] — but ORDER is non-deterministic
The order of results is not guaranteed. Multiple threads writing to the same array concurrently is a race condition even in MRI Ruby — because array operations involve multiple steps, and the GIL can release between them.
Race Conditions — The Core Problem
A race condition happens when two threads read and write shared state and the outcome depends on which order they execute. The GIL reduces (but does not eliminate) races in MRI Ruby, because some operations that look atomic are not.
Example:
# Classic race condition — not safe even in MRI
counter = 0
threads = 100.times.map do
Thread.new { 1000.times { counter += 1 } }
end
threads.each(&:join)
counter # => usually NOT 100_000 — some increments are lost
counter += 1 is not atomic — it’s three operations: read counter, add 1, write back. The GIL can release between any two steps, allowing another thread to read the old value.
Example:
# Safe: use Mutex to protect the critical section
counter = 0
mutex = Mutex.new
threads = 100.times.map do
Thread.new do
1000.times do
mutex.synchronize { counter += 1 }
end
end
end
threads.each(&:join)
counter # => 100_000 — always correct
Mutex#synchronize acquires the lock before the block and releases it after. Only one thread can hold the mutex at a time — other threads block until it’s released.
Thread-Safe Data Structures
Example:
# Thread-safe queue — built into Ruby's standard library
require 'thread'
queue = Queue.new
# Producer thread
producer = Thread.new do
10.times do |i|
queue << i
sleep(0.1)
end
queue << :done
end
# Consumer thread
consumer = Thread.new do
loop do
item = queue.pop # blocks if empty, no busy-waiting
break if item == :done
puts "Processing: #{item}"
end
end
producer.join
consumer.join
Queue (and SizedQueue for bounded queues) are built-in thread-safe structures. They handle the synchronization internally. Prefer them over manually-synchronized arrays for producer-consumer patterns.
Concurrent I/O — Where Threads Shine in MRI
Even with the GIL, threads provide real speedup for I/O-bound work because the GIL releases while waiting on network, disk, or database operations.
Example:
require 'net/http'
urls = [
"https://api.example.com/users",
"https://api.example.com/products",
"https://api.example.com/orders"
]
# Sequential — takes sum of all response times
results = urls.map { |url| Net::HTTP.get(URI(url)) }
# Concurrent threads — takes max of response times (roughly)
results = urls.map do |url|
Thread.new { Net::HTTP.get(URI(url)) }
end.map(&:value) # .value joins the thread and returns its result
For three 500ms requests: sequential takes ~1500ms, threaded takes ~500ms. The GIL doesn’t interfere because threads release it during the HTTP wait.
Thread-Local Variables
Thread-local variables allow each thread to maintain its own copy of a value without sharing — useful for per-request context.
Example:
# Thread-local storage with Thread.current[]
Thread.current[:user_id] = 42
# Read in the same thread
Thread.current[:user_id] # => 42
# Each thread has its own value
t1 = Thread.new do
Thread.current[:user_id] = 1
sleep(0.1)
Thread.current[:user_id] # => 1
end
t2 = Thread.new do
Thread.current[:user_id] = 2
sleep(0.1)
Thread.current[:user_id] # => 2
end
[t1, t2].map(&:value) # => [1, 2]
Example:
# Rails uses ActiveSupport::CurrentAttributes for thread-safe per-request data
# Under the hood, it's thread-local storage
class Current < ActiveSupport::CurrentAttributes
attribute :user, :request_id
end
# Available throughout the request without passing as arguments
Current.user = User.find(session[:user_id])
# Later in a model, mailer, or service:
Current.user # => the user for this request
Common Thread Safety Patterns
1. Read-Write Lock Pattern
require 'concurrent-ruby'
# Multiple readers allowed, one writer at a time
lock = Concurrent::ReadWriteLock.new
cache = {}
# Readers (many concurrent)
def read_cache(lock, cache, key)
lock.with_read_lock { cache[key] }
end
# Writer (exclusive)
def write_cache(lock, cache, key, value)
lock.with_write_lock { cache[key] = value }
end
2. Thread Pool for Bounded Concurrency
require 'concurrent-ruby'
pool = Concurrent::FixedThreadPool.new(10)
100.items.each do |item|
pool.post do
process(item) # max 10 concurrent, rest queue
end
end
pool.shutdown
pool.wait_for_termination
Pro-Tip: Puma runs each request in a thread by default. Any instance variable or class variable that lives beyond a single request —
@@counter,MyClass.cache = {}— is shared across all threads serving requests simultaneously. The usual symptom is data leaking between requests: user A sees user B’s data, or counts that seem to drift. Audit any use of class-level mutable state in Rails apps running on Puma. UseThread.currentorCurrentAttributesfor request-scoped state, and a proper cache backend (Rails cache, Redis) for shared state.
Conclusion
Ruby threads don’t give you CPU parallelism in MRI, but they deliver real concurrency for I/O-bound work — which covers most of what web applications actually do. The GIL prevents some race conditions but not all; Mutex and thread-safe data structures are still required for correct concurrent code. Thread-local storage handles per-thread state cleanly without shared memory. And for true CPU parallelism in Ruby 3, Ractors are the path forward — but threads remain the right tool for concurrent I/O and for understanding how Puma and your background job processors actually work.
FAQs
Q1: Should I use threads or Ractors for CPU-bound work in Ruby 3?
Ractors for CPU-bound parallel work — they bypass the GIL and achieve true parallelism. Threads for concurrent I/O. The constraint is that Ractors require shareable (frozen) objects, making them harder to adopt in existing codebases.
Q2: Does the GIL make mutex unnecessary in MRI Ruby?
No. The GIL prevents simultaneous execution but still allows interleaving at arbitrary points. Multi-step operations like counter += 1 are not atomic. Use Mutex wherever shared mutable state is modified.
Q3: What is Thread#join vs Thread#value?
join waits for the thread to finish and re-raises any exception from the thread. value does the same but also returns the thread’s last expression value. Use value when you need the result; join when you just need completion.
Q4: Is ||= thread-safe in Ruby?
No. result ||= expensive_computation() is not atomic — it’s a read-check-write. Under concurrent access, two threads can both see nil, both compute, and both write. Use Mutex#synchronize or a purpose-built memoization pattern for thread-safe lazy initialization.
Q5: How many threads should a Puma worker use?
The puma default is min: 0, max: 5 threads per worker. For I/O-heavy Rails apps, 5–16 threads is common. CPU-heavy apps benefit less from threads — consider more workers instead. Benchmark with wrk or hey under realistic load; optimal thread count varies by database connection pool size and request I/O ratio.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀