Concurrency
Ruby’s concurrency story has multiple layers: threads (admitted but constrained by the Global VM Lock in CRuby — only one thread executes Ruby code at a time), fibers (lightweight cooperative coroutines), Ractors (Ruby 3.0+; admit true parallel execution with isolated state), and processes (full OS-level parallelism through fork or Process.spawn). The conventional contemporary forms: threads for I/O-bound work (where the GVL is released during I/O), Ractors for CPU-bound parallelism, the async gem for substantial fiber-based concurrency. The combination — threads with the GVL for I/O concurrency, Ractors for parallel CPU work, fibers for cooperative coroutines, processes for substantial parallelism — is the substance of Ruby’s concurrency surface.
This page covers CRuby (MRI) — alternative implementations (JRuby, TruffleRuby) admit substantial parallelism without the GVL.
Threads
The Thread class admits OS threads:
t = Thread.new do
puts "from thread"
sleep 1
"result"
end
t.join # wait for completion
puts t.value # "result"
Thread management:
threads = 5.times.map do |i|
Thread.new do
sleep rand
puts "thread #{i} done"
end
end
threads.each(&:join) # wait for all
The Global VM Lock (GVL)
CRuby’s Global VM Lock (also called GIL — Global Interpreter Lock) admits only one thread executing Ruby code at a time:
- During I/O — the GVL is released; other threads run.
- During substantial computation — the GVL is held; threads do not run in parallel.
- During C extensions — depends on the extension.
The mechanism admits substantial concurrency for I/O-bound workloads but not for CPU-bound ones. For genuine CPU parallelism, Ractors or processes are conventional.
Thread synchronisation
require "thread"
# Mutex:
mutex = Mutex.new
counter = 0
threads = 10.times.map do
Thread.new do
1000.times do
mutex.synchronize { counter += 1 }
end
end
end
threads.each(&:join)
puts counter # 10000
# Queue (thread-safe):
queue = Queue.new
producer = Thread.new do
10.times { |i| queue << i; sleep 0.1 }
queue << :done
end
consumer = Thread.new do
while (item = queue.pop) != :done
puts "got: #{item}"
end
end
[producer, consumer].each(&:join)
The Queue admits substantial blocking — pop waits if the queue is empty.
For bounded queues:
queue = SizedQueue.new(5)
queue.push(item) # blocks if at capacity
Thread-local variables
Thread.current[:name] = "main"
t = Thread.new do
Thread.current[:name] = "worker"
puts Thread.current[:name] # "worker"
end
t.join
puts Thread.current[:name] # "main"
Thread-local storage admits per-thread state without explicit parameter passing.
Thread pools
The standard library does not include a thread pool; the conventional form is the concurrent-ruby gem:
require "concurrent-ruby"
pool = Concurrent::FixedThreadPool.new(5)
10.times do |i|
pool.post do
sleep rand
puts "task #{i} done"
end
end
pool.shutdown
pool.wait_for_termination
Fibers
A fiber is a lightweight, cooperative coroutine — explicit suspension via Fiber.yield:
fiber = Fiber.new do
puts "first"
Fiber.yield 1
puts "second"
Fiber.yield 2
puts "third"
3
end
fiber.resume # "first"; returns 1
fiber.resume # "second"; returns 2
fiber.resume # "third"; returns 3
Fibers admit substantial generator-style patterns:
fibonacci = Fiber.new do
a, b = 0, 1
loop do
Fiber.yield a
a, b = b, a + b
end
end
10.times { puts fibonacci.resume }
# 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
For substantial concurrent fiber-based work, the Fiber Scheduler (Ruby 3.0+) and the async gem:
require "async"
Async do |task|
task.async { fetch_url("https://example.com/a") }
task.async { fetch_url("https://example.com/b") }
task.async { fetch_url("https://example.com/c") }
end
The async gem admits substantial concurrency through fiber scheduling — particularly effective for I/O-heavy workloads.
Ractors
Ruby 3.0+ admits Ractors (Ruby Actors) — concurrent execution units with isolated state:
r = Ractor.new do
puts "from Ractor"
loop do
msg = Ractor.receive
Ractor.yield msg.upcase
end
end
r.send "hello"
puts r.take # "HELLO"
Ractors:
- Have isolated state — values must be shareable or copied across the boundary.
- Admit true parallelism — multiple Ractors run on multiple cores simultaneously.
- Are experimental (as of Ruby 3.4); the API may evolve.
The principal restrictions:
- Shareable values: immutable objects (frozen),
Module/Class,Symbol,Numeric,nil,true,false. - Non-shareable: mutable objects (Strings, Arrays, Hashes — unless explicitly shared via
Ractor::Shareable). - Communication: via
send/receiveandyield/take.
# Parallel computation:
ractors = 4.times.map do |i|
Ractor.new(i) do |id|
1_000_000.times.sum
end
end
results = ractors.map(&:take)
The conventional contemporary advice is to reach for Ractors when CPU parallelism is genuinely needed; threads remain conventional for I/O concurrency.
Processes
The Process and Kernel#fork admit OS-level processes:
pid = fork do
# child process
puts "child running"
exit 0
end
Process.wait(pid)
fork is admitted on Unix-like systems (not Windows). For cross-platform spawning:
require "open3"
stdout, stderr, status = Open3.capture3("ls", "-la")
puts stdout
# Streaming:
Open3.popen3("long_running_command") do |stdin, stdout, stderr, wait_thr|
stdin.close
stdout.each_line { |line| puts line }
wait_thr.value # exit status
end
The conventional contemporary discipline reaches for processes when isolation is desirable (e.g., parallel tasks where one failure should not affect others) or when OS-level fork is acceptable.
Common patterns
Concurrent I/O
urls = ["https://a.com", "https://b.com", "https://c.com"]
threads = urls.map do |url|
Thread.new { Net::HTTP.get(URI(url)) }
end
results = threads.map(&:value)
The pattern admits substantial concurrent I/O — the GVL is released during HTTP requests.
Producer-consumer
require "thread"
queue = Queue.new
producer = Thread.new do
1.upto(10) do |n|
queue << n
sleep 0.1
end
queue << :done
end
consumers = 3.times.map do
Thread.new do
loop do
item = queue.pop
break if item == :done
process(item)
end
end
end
producer.join
consumers.each { |c| queue << :done } # signal each consumer
consumers.each(&:join)
Periodic work
worker = Thread.new do
loop do
do_work
sleep 60
end
end
# To stop:
worker.kill # forcibly terminates
# Or with a flag:
@running = true
worker = Thread.new do
while @running
do_work
sleep 60
end
end
@running = false # cooperative shutdown
worker.join
Mutex protection
class Counter
def initialize
@count = 0
@mutex = Mutex.new
end
def increment
@mutex.synchronize { @count += 1 }
end
def value
@mutex.synchronize { @count }
end
end
Thread::ConditionVariable for signalling
require "thread"
mutex = Mutex.new
cv = ConditionVariable.new
ready = false
producer = Thread.new do
sleep 1
mutex.synchronize do
ready = true
cv.signal
end
end
consumer = Thread.new do
mutex.synchronize do
cv.wait(mutex) until ready
puts "got signal"
end
end
[producer, consumer].each(&:join)
concurrent-ruby for substantial concurrency
require "concurrent-ruby"
# Future:
future = Concurrent::Future.execute { expensive_computation }
result = future.value # waits
# Promise:
promise = Concurrent::Promise.execute { fetch_data }
.then { |data| process(data) }
.then { |result| save(result) }
# Atomic:
counter = Concurrent::AtomicFixnum.new(0)
counter.increment
counter.value
Fiber-based async
require "async"
Async do |task|
task.async do
response = Net::HTTP.get(URI("https://a.com"))
process(response)
end
task.async do
response = Net::HTTP.get(URI("https://b.com"))
process(response)
end
end
The async gem admits substantial fiber-based concurrency with substantially less overhead than threads.
Parallel computation with Ractors
chunks = data.each_slice(1000).to_a
ractors = chunks.map.with_index do |chunk, i|
Ractor.new(chunk) do |c|
c.sum # parallel computation
end
end
total = ractors.map(&:take).sum
The pattern admits substantial CPU parallelism in CRuby; the chunks must be shareable (frozen).
Process.fork for parallel work
pids = chunks.map do |chunk|
fork do
process_chunk(chunk)
exit 0
end
end
pids.each { |pid| Process.wait(pid) }
The pattern admits OS-level parallelism without GVL constraints.
Thread pool with bounded concurrency
require "concurrent-ruby"
pool = Concurrent::FixedThreadPool.new(10)
results = items.map do |item|
Concurrent::Promise.execute(executor: pool) { process(item) }
end
# Wait for all:
final_results = results.map(&:value)
pool.shutdown
pool.wait_for_termination
Mutex#try_lock for non-blocking attempts
mutex = Mutex.new
if mutex.try_lock
begin
# ...
ensure
mutex.unlock
end
else
# couldn't get the lock; do something else
end
Timeout
require "timeout"
begin
Timeout.timeout(5) do
expensive_operation
end
rescue Timeout::Error
puts "timed out"
end
The Timeout.timeout admits aborting an operation after a time limit; conventionally avoided for substantial reliability concerns (the timeout interrupts arbitrary code, which may produce inconsistent state).
Thread.handle_interrupt for safe interruption
Thread.handle_interrupt(RuntimeError => :on_blocking) do
# interruption only on blocking operations (sleep, wait, IO)
long_running_work
end
The pattern admits substantial discipline around when threads may be interrupted.
A note on JRuby and TruffleRuby
Alternative Ruby implementations do not have a GVL:
- JRuby — runs on the JVM; admits true thread parallelism.
- TruffleRuby — runs on GraalVM; admits true thread parallelism and substantial JIT.
For substantial CPU parallelism on these implementations, threads suffice — Ractors are still admitted but not necessary.
The conventional discipline writes code that works on CRuby; the alternative implementations admit substantial deployments with the same code (with substantially better parallel performance).
A note on the conventional discipline
The contemporary Ruby concurrency advice:
- Use threads for I/O-bound concurrency (the GVL is released during I/O).
- Use Ractors (Ruby 3+) for CPU-bound parallelism in CRuby.
- Use processes for substantial isolation or to avoid the GVL.
- Use fibers and
asyncfor substantial cooperative concurrency. - Use
Mutex/Queuefor thread-safe state. - Use
concurrent-rubyfor substantial primitives (futures, promises, pools). - Use thread-local storage for per-thread state.
- Avoid
Timeout.timeout— substantial reliability pitfalls. - Avoid shared mutable state across threads — use queues or message passing.
- Use
Open3for spawning subprocesses cross-platform.
The combination — threads with the GVL for I/O concurrency, fibers for cooperative coroutines, Ractors for parallel computation, processes for substantial isolation, the substantial primitives (Mutex, Queue, ConditionVariable), the concurrent-ruby gem for higher-level abstractions — is the substance of Ruby’s concurrency surface. The discipline trades some of the substantive parallelism that GVL-free implementations admit for substantial simplicity in CRuby; the mechanisms admit substantial concurrency for the conventional I/O-bound workloads that Ruby is most commonly used for.