Concurrency: threads, fibers and Ractors
Threads and mutexes, the global VM lock, fibers as cooperative coroutines, Ractors for real parallelism, and knowing when it pays off.
Threads and the GVL
require "net/http"
urls = %w[https://example.com https://example.org https://example.net]
threads = urls.map do |url|
Thread.new do
Thread.current[:url] = url
Net::HTTP.get_response(URI(url)).code
end
end
results = threads.map(&:value) # joins and re-raises any exception
p results # => ["200", "200", "200"]
# a bounded pool instead of one thread per item
require "etc"
queue = Queue.new
urls.each { |u| queue << u }
workers = [Etc.nprocessors, urls.size].min.times.map do
Thread.new do
while (url = queue.pop(true) rescue nil)
process(url)
end
end
end
workers.each(&:join)- The GVL allows only one thread to execute Ruby bytecode at a time; parallelism comes when a thread is blocked in I/O, so threads help network and disk work, not CPU work.
- Array, Hash and String are not thread-safe for concurrent mutation. Protect shared state with a
Mutex, or hand results back through aQueue. Thread#valuejoins and propagates exceptions; a thread that raised with nobody joining fails silently.- Every request-serving gem you use is already threaded - Puma and Sidekiq. The risk is your own shared mutable state, not the framework.
⚠️
Never call
Thread#raise on a thread to cancel work, and never kill a thread holding a lock. It can leave a mutex locked forever and the process deadlocked. Use a cooperative flag or a timeout.Coordination primitives
class Counter
def initialize = (@value = 0; @mutex = Mutex.new)
def increment
@mutex.synchronize { @value += 1 }
end
def value = @mutex.synchronize { @value }
end
# ConditionVariable: wait until work is available
mutex = Mutex.new
resource = ConditionVariable.new
ready = false
producer = Thread.new do
sleep 0.1
mutex.synchronize { ready = true; resource.signal }
end
mutex.synchronize { resource.wait(mutex) until ready }
producer.join
# a thread-safe queue is usually simpler than your own handshake
q = Thread::Queue.new
Thread.new { q << compute }
q.pop| Primitive | Use | Note |
|---|---|---|
Mutex | Guard a critical section | Non-reentrant: locking twice in one thread deadlocks |
Thread::Queue | Hand work and results between threads | Also the cleanest cancellation channel |
ConditionVariable | Wait for a state change | Must be used inside the associated mutex |
Thread::SizedQueue | Bounded work queue | Blocks the producer, giving backpressure |
Concurrent::Map | Shared lookup from the concurrent-ruby gem | Cheaper than a mutex around a Hash |
Fibers and Ractors
# Fiber: cooperative, you decide where to yield
fiber = Fiber.new do |first|
second = Fiber.yield(first * 2)
Fiber.yield(second + 1)
:done
end
fiber.resume(5) # => 10
fiber.resume(10) # => 11
fiber.resume # => :done
# an infinite generator without allocating an array
counter = Fiber.new { i = 0; loop { Fiber.yield(i += 1) } }
counter.resume # => 1
counter.resume # => 2
# Ractor: real parallel execution, no shared mutable state
ractor = Ractor.new([1, 2, 3, 4]) do |numbers|
numbers.sum { |n| n * n }
end
ractor.take # => 30
# communication only through messages
sender = Ractor.new { Ractor.yield "hello" }
sender.take # => "hello"- Fibers do not automatically switch on blocking I/O; the Fiber scheduler API lets an async gem hook in so a blocked read yields to another fiber.
- A Ractor cannot access another Ractor's objects unless they are shareable (frozen, or a supported class), which is what makes parallelism safe.
- In Ruby 3.x Ractors are still experimental and ecosystem support is partial - do not make them the foundation of a production service yet.
- Ask whether you need concurrency at all: for CPU-bound work, multiple processes are usually simpler and more robust than shared-memory threads.
FAQ
Do threads make my Ruby code faster?
Only for I/O-bound work. Ten threads waiting on ten HTTP responses finish in roughly one response time; ten threads hashing strings finish in roughly the same total time as one, because of the GVL. Use processes or a native extension for CPU parallelism.
How do I find a race condition?
Reproduce it under load with more threads, add a debug log around every shared-state access, and check whether the code mutates an object another thread can see. Prefer eliminating shared state over adding locks.
Related
Metaprogramming and building DSLs Background jobs and the Ruby web stack
Last refreshed 2026-09-18.