| name | ruby-concurrency |
| description | Ruby concurrency expert - advises on threads, fibers, Ractors, GVL/GIL, async I/O, synchronization primitives, and the concurrent-ruby gem. Use when the user asks about Ruby threading, parallelism, race conditions, deadlocks, fiber schedulers, Ractor design, or scaling concurrent Ruby applications. |
Ruby Concurrency Expert
Expert guidance on Ruby concurrency, parallelism, and thread safety. Covers the full spectrum: GVL mechanics, threads, fibers, Ractors, async I/O, synchronization, and the concurrent-ruby gem.
Concurrency Model at a Glance
| Primitive | Parallelism | Overhead | Scheduler | Best For |
|---|
Process (fork) | ✅ True | High (own heap) | OS | CPU-bound, isolation |
| Thread | ⚠️ I/O only (MRI) | Medium | OS (preemptive) | I/O-bound, Rails |
| Fiber | ❌ | Very low | You (cooperative) | Generators, async I/O |
| Ractor | ✅ True | Medium | OS per-Ractor | CPU-bound, experimental |
The GVL (Global VM Lock)
The GVL is a mutex that serializes Ruby bytecode execution across all threads within one MRI process. Only one thread runs Ruby code at a time.
What it is NOT: a bug, a performance flaw per se, or a guarantee of thread safety.
When threads run in parallel (GVL released): blocking I/O, sleep, select, native C extensions that release it explicitly (OpenSSL, MySQL2, pg, etc.).
When threads are serialized: pure Ruby computation, Array#sort, string manipulation—anything that stays in the Ruby VM.
threads = 5.times.map do
Thread.new { Net::HTTP.get(URI('https://api.example.com/data')) }
end
results = threads.map(&:value)
threads = 5.times.map do
Thread.new { 10_000_000.times.sum }
end
GVL does NOT guarantee thread safety. Non-atomic Ruby operations (e.g., n += 1, hash[k] ||= []) can interleave across GVL handoffs.
M:N Scheduling (Ruby 3.3+)
Ruby 3.3 introduced M:N thread scheduling (opt-in):
- M Ruby threads mapped to N OS threads (pool), reducing OS thread creation cost
- Especially beneficial for Ractor-heavy workloads
- Enable with
RUBY_MN_THREADS=1, tune with RUBY_MAX_CPU=n (default: 8)
Threads
Lifecycle
t = Thread.new { do_work }
t = Thread.new(payload) { |data| process(data) }
t.join
result = t.value
t.status
t.kill
Thread-Local Storage
Thread.current[:request_id] = SecureRandom.uuid
Thread.current.thread_variable_set(:name, "worker")
Exception Handling
By default, thread exceptions are swallowed silently:
Thread.abort_on_exception = true
t = Thread.new { raise "boom" }
t.abort_on_exception = true
t = Thread.new do
do_work
rescue => e
logger.error("Thread failed: #{e.message}")
end
Synchronization Primitives
See synchronization reference for complete API.
Mutex
Protects shared mutable state. Use synchronize (not raw lock/unlock):
mutex = Mutex.new
counter = 0
threads = 10.times.map do
Thread.new { 1000.times { mutex.synchronize { counter += 1 } } }
end
threads.each(&:join)
Deadlock rule: always acquire multiple mutexes in the same order across all threads.
ConditionVariable
Signals state changes between threads. Always used with a Mutex. Use while (not if) to guard against spurious wakeups:
mutex = Mutex.new
cond = ConditionVariable.new
queue = []
producer = Thread.new do
10.times do |i|
mutex.synchronize do
queue << i
cond.signal
end
end
end
consumer = Thread.new do
10.times do
mutex.synchronize do
cond.wait(mutex) while queue.empty?
puts queue.shift
end
end
end
[producer, consumer].each(&:join)
Monitor (re-entrant Mutex)
Use when the same thread must acquire the lock recursively:
require 'monitor'
class SafeCache
include MonitorMixin
def initialize
super
@data = {}
end
def fetch(key)
synchronize { @data[key] ||= synchronize { compute(key) } }
end
end
Fibers
Fibers are lightweight, cooperatively scheduled coroutines. The programmer controls switching; the VM never preempts.
fiber = Fiber.new do
Fiber.yield 1
Fiber.yield 2
3
end
fiber.resume
fiber.resume
fiber.resume
fiber.resume
Fibers as Generators (lazy sequences)
fib = Fiber.new do
a, b = 0, 1
loop do
Fiber.yield a
a, b = b, a + b
end
end
10.times { print "#{fib.resume} " }
Non-blocking Fibers (Ruby 3.0+)
The Fiber Scheduler interface enables async I/O without callbacks:
require 'async'
Async do
task1 = Async { Net::HTTP.get(URI('https://api1.example.com')) }
task2 = Async { Net::HTTP.get(URI('https://api2.example.com')) }
[task1.wait, task2.wait]
end
See fibers reference for scheduler internals and patterns.
Ractors (Ruby 3.0+, experimental)
Ractors achieve true parallelism by giving each Ractor its own GVL. Object isolation enforces thread safety structurally.
Key Constraints
- Ractors cannot share mutable objects — only shareable objects cross boundaries
- Shareable: frozen objects, Integers, Symbols,
Ractor itself, Ractor.make_shareable
- Communication via
send/receive (push) or yield/take (pull)
- Most C extensions are NOT Ractor-safe
workers = 4.times.map do |i|
Ractor.new(i) do |id|
(1..1_000_000).reduce(:+)
end
end
results = workers.map(&:take)
Pipeline Pattern
pipe = Ractor.new do
loop { Ractor.yield Ractor.receive * 2 }
end
source = Ractor.new(pipe) do |out|
5.times { |i| out.send(i) }
end
5.times { puts pipe.take }
See ractors reference for design patterns and limitations.
concurrent-ruby Gem
The concurrent-ruby gem provides production-ready high-level abstractions:
gem 'concurrent-ruby'
Promises (async pipelines)
require 'concurrent-ruby'
promise = Concurrent::Promises.future { fetch_data }
.then { |data| process(data) }
.then { |result| save(result) }
promise.value!
promise.value
Thread Pools
pool = Concurrent::FixedThreadPool.new(10)
pool.post { do_work }
pool = Concurrent::CachedThreadPool.new
pool.post { do_work }
pool.shutdown
pool.wait_for_termination(30)
Thread-Safe Data Structures
map = Concurrent::Map.new
array = Concurrent::Array.new
hash = Concurrent::Hash.new
atom = Concurrent::Atom.new(0)
atom.swap { |v| v + 1 }
atom.compare_and_set(0, 1)
See concurrent-ruby reference for Agents, Timers, Semaphore.
Processes
Best for CPU-bound parallelism without Ractor constraints:
pid = fork do
result = heavy_computation
exit!(result)
end
Process.wait(pid)
status = $?.exitstatus
reader, writer = IO.pipe
pid = fork do
reader.close
writer.puts heavy_computation.to_json
writer.close
end
writer.close
result = JSON.parse(reader.read)
reader.close
Process.wait(pid)
Warning: fork duplicates the entire process—don't fork with active DB connections, open files, or threads running. Call fork before connecting to external services.
Common Patterns
Worker Pool
require 'concurrent-ruby'
class WorkerPool
def initialize(size: 10)
@pool = Concurrent::FixedThreadPool.new(size)
@futures = Concurrent::Array.new
end
def submit(item)
future = Concurrent::Promises.future_on(@pool) { yield item }
@futures << future
future
end
def results
Concurrent::Promises.zip(*@futures).value!
end
def shutdown = @pool.shutdown && @pool.wait_for_termination
end
Producer-Consumer Queue
require 'thread'
queue = SizedQueue.new(100)
producer = Thread.new do
items.each { |item| queue << item }
queue << :done
end
consumer = Thread.new do
loop do
item = queue.pop
break if item == :done
process(item)
end
end
[producer, consumer].each(&:join)
Parallel HTTP Requests
require 'net/http'
urls = %w[https://api1.com https://api2.com https://api3.com]
threads = urls.map do |url|
Thread.new { Net::HTTP.get(URI(url)) }
end
responses = threads.map(&:value)
require 'async'
require 'async/http/internet'
responses = Async do |task|
internet = Async::HTTP::Internet.new
tasks = urls.map { |url| task.async { internet.get(url).read } }
tasks.map(&:wait)
end
Diagnosing Issues
Detecting Race Conditions
100.times.map { Thread.new { shared_operation } }.each(&:join)
Deadlock Symptoms
- All threads blocked on
join or mutex.lock
- Ruby prints "deadlock detected" and exits
- Fix: consistent lock ordering, use
Mutex#try_lock with backoff, or use Monitor
Thread Dump
Signal.trap('USR1') do
Thread.list.each do |t|
STDERR.puts "--- Thread #{t.object_id} (#{t.status}) ---"
STDERR.puts t.backtrace.join("\n")
end
end
GVL Profiling
require 'gvl-tracing'
GvlTracing.start('trace.json') { your_concurrent_code }
Decision Guide
I/O-bound work (HTTP, DB, files): Use threads or the async gem (fibers). Threads are simpler; async is more efficient at high concurrency.
CPU-bound work, need parallelism: Use Ractors (if object isolation feasible), processes (safest, highest overhead), or switch to JRuby/TruffleRuby.
Cooperative sequencing / generators: Use Fibers.
High-level abstractions: Use concurrent-ruby (Promises, FixedThreadPool, Atom).
Shared mutable state: Wrap with Mutex, use Monitor if re-entrant, or eliminate sharing (prefer message-passing or immutable data).
Reference Docs
- Synchronization Primitives — Mutex, ConditionVariable, Monitor, Semaphore
- Fibers & Fiber Scheduler — Cooperative concurrency, async gem, scheduler interface
- Ractors — Shareable objects, messaging, design patterns, limitations
- concurrent-ruby — Promises, thread pools, atomic types, actors