Concurrency
Intuition
Section titled “Intuition”Concurrency in Ruby has evolved from green threads through GIL-bound native threads to Ractor-based parallelism. The Global Interpreter Lock ensures only one thread executes Ruby code at a time, meaning CPU-bound tasks do not benefit from threading. However, I/O operations release the GIL, making threads useful for concurrent network requests. Ractors provide true parallelism by isolating objects, and the Fiber Scheduler enables non-blocking I/O without manual callback management.
Concurrency in Ruby
Section titled “Concurrency in Ruby”Ruby supports both concurrency (dealing with multiple tasks at once) and parallelism (executing multiple tasks simultaneously). The concurrency model has evolved significantly across Ruby versions:
- Ruby 1.8: Green threads (managed by the interpreter, no true parallelism)
- Ruby 1.9—2.x: Native threads with the GIL (Global Interpreter Lock) — only one thread executes Ruby code at a time, but I/O operations release the GIL
- Ruby 3.0+: Ractor for true parallelism, Fiber Scheduler for non-blocking I/O
Threads
Section titled “Threads”Creating Threads
Section titled “Creating Threads”## Basic thread creationthread = Thread.new { puts "Hello from thread" }thread.join # wait for completion
## Thread with argumentthread = Thread.new("Alice") do |name| 3.times { |i| puts "#{name}: iteration #{i}"; sleep(0.1) }endthread.join
# Multiple threadsthreads = 5.times.map do |i| Thread.new(i) do |id| result = heavy_computation(id) puts "Thread #{id} completed with result #{result}" endend
threads.each(&:join)puts "All threads completed"Thread Lifecycle
Section titled “Thread Lifecycle”# Thread statest = Thread.new { sleep(1) }
t.status # => "run" or "sleep" or false (dead)t.alive? # => true while running or sleepingt.stop? # => true if sleeping or dead
# Wait for completiont.join # blocks until thread finishest.join(2) # wait at most 2 secondst.join(2) { puts "Timed out!" }
t.value # returns the thread"s return value (blocks until completion)
# Thread return valuet = Thread.new { 2 + 3 }t.value # => 5
# Thread abort on exceptiont = Thread.new { raise "Error!" }# t dies silently unless you join
t = Thread.new { raise "Error!" }t.join # => RuntimeError: Error! (re-raised in the joining thread)
# Thread abort on exception settingThread.abort_on_exception = true # global setting
t = Thread.new do Thread.current.abort_on_exception = true raise "Error!"end# Error is raised immediately and kills the main threadThread-Local Variables
Section titled “Thread-Local Variables”# Thread-local storage via thread-local variablescount = 0
threads = 10.times.map do Thread.new do # This is NOT thread-safe -- shared mutable state count += 1 endendthreads.each(&:join)puts count # unpredictable result (race condition)
# Thread-safe using thread-local variablesThread.new do Thread.current[:user_id] = 42 Thread.current[:request_id] = "abc-123" puts Thread.current[:user_id]end.join
# Thread-local via thread keyst = Thread.new do Thread.current[:data] = "local to this thread" sleep(1) puts Thread.current[:data]end
Thread.current[:data] # => nil (different thread)
t.joinA Mutex (mutual exclusion lock) protects shared resources from concurrent access:
require 'thread'
mutex = Mutex.newcounter = 0
threads = 10.times.map do Thread.new do 100.times do mutex.synchronize do counter += 1 end end endend
threads.each(&:join)puts counter # => 1000 (always correct)Mutex Methods
Section titled “Mutex Methods”mutex = Mutex.new
# synchronize -- acquire lock, execute block, release lockmutex.synchronize do shared_resource.updateend
# Manual lock/unlock (prefer synchronize)mutex.lockbegin shared_resource.updateensure mutex.unlockend
# try_lock -- non-blockingif mutex.try_lock begin shared_resource.update ensure mutex.unlock endelse puts "Could not acquire lock"end
# locked? -- check if mutex is heldmutex.locked? # => true or false
# owned? -- check if current thread owns the lockmutex.owned? # => true or false
# sleep -- release lock, sleep, reacquiremutex.synchronize do result = compute if result.nil? mutex.sleep(1) # releases lock during sleep endendDeadlock
Section titled “Deadlock”mutex_a = Mutex.newmutex_b = Mutex.new
# Deadlock scenariothread1 = Thread.new do mutex_a.synchronize do sleep(0.1) mutex_b.synchronize do puts "Thread 1" end endend
thread2 = Thread.new do mutex_b.synchronize do sleep(0.1) mutex_a.synchronize do puts "Thread 2" end endend
# Both threads wait forever -- deadlock!# Prevention: always acquire locks in the same orderQueue and SizedQueue
Section titled “Queue and SizedQueue”Thread-safe data structures for inter-thread communication:
require 'thread'
queue = Queue.new
# Producer threadsproducers = 3.times.map do Thread.new do 5.times do |i| item = "item-#{Thread.current.object_id}-#{i}" queue.push(item) puts "Produced: #{item}" end endend
# Consumer threadconsumer = Thread.new do loop do item = queue.pop puts "Consumed: #{item}" endend
producers.each(&:join)
# Send poison pill3.times { queue.push(:stop) }
consumer.joinSizedQueue
Section titled “SizedQueue”require 'thread'
# Bounded queue -- blocks when fullbuffer = SizedQueue.new(5)
producer = Thread.new do 20.times do |i| buffer.push("item-#{i}") puts "Produced: item-#{i} (size: #{buffer.size})" end buffer.push(:done)end
consumer = Thread.new do loop do item = buffer.pop break if item == :done puts "Consumed: #{item}" sleep(0.1) # simulate processing time endend
producer.joinconsumer.joinQueue Methods
Section titled “Queue Methods”queue = Queue.new
queue.push("a") # add to queue (alias: enq, <<)queue.pop # remove from queue (alias: deq, shift)queue.pop(true) # non-blocking pop (raises ThreadError if empty)queue.empty? # => truequeue.size # => 0queue.clear # empty the queuequeue.num_waiting # number of threads waiting to pop
# SizedQueue additionallysq = SizedQueue.new(3)sq.max # => 3 (capacity)sq.push("a") # blocks when fullsq.push("a", true) # non-blocking, returns false if fullThreadSafe Patterns
Section titled “ThreadSafe Patterns”Read-Write Lock
Section titled “Read-Write Lock”class ReadWriteLock def initialize @readers = 0 @resource = Mutex.new @read_ready = ConditionVariable.new end
def read_shared @resource.synchronize do @readers += 1 end yield ensure @resource.synchronize do @readers -= 1 @read_ready.signal if @readers.zero? end end
def write_exclusive @resource.synchronize do @read_ready.wait(@resource) while @readers > 0 yield end endend
data_store = []lock = ReadWriteLock.new
# Multiple readersreaders = 5.times.map do Thread.new { lock.read_shared { puts data_store.size } }end
# Single writerwriters = 2.times.map do Thread.new { lock.write_exclusive { data_store << rand(100) } }end
(readers + writers).each(&:join)Thread Pool
Section titled “Thread Pool”require 'thread'
class ThreadPool def initialize(size:) @size = size @queue = Queue.new @mutex = Mutex.new @workers = []
@size.times do @workers << Thread.new { worker_loop } end end
def execute(&block) @queue.push(block) end
def shutdown @size.times { @queue.push(:shutdown) } @workers.each(&:join) end
private
def worker_loop loop do task = @queue.pop break if task == :shutdown task.call end endend
pool = ThreadPool.new(size: 4)
20.times do |i| pool.execute do result = (i ** 2) puts "Task #{i}: #{result}" endend
pool.shutdownProducer-Consumer Pattern
Section titled “Producer-Consumer Pattern”require 'thread'
class Pipeline def initialize(workers: 4, queue_size: 100) @queue = SizedQueue.new(queue_size) @results = [] @result_mutex = Mutex.new
@workers = workers.times.map do Thread.new { process_loop } end end
def submit(task) @queue.push(task) end
def results @result_mutex.synchronize { @results.dup } end
def await_completion @queue.push(:done) @workers.each(&:join) end
private
def process_loop loop do task = @queue.pop break if task == :done result = process(task) @result_mutex.synchronize { @results << result } end end
def process(task) task * task endend
pipeline = Pipeline.new(workers: 4)100.times { |i| pipeline.submit(i) }pipeline.await_completionputs pipeline.results.sort.first(10)Ractor (Ruby 3.0+)
Section titled “Ractor (Ruby 3.0+)”Ractor enables true parallelism by isolating objects between Ractors. Each Ractor has its own memory space and can only communicate through message passing.
Basic Ractor Usage
Section titled “Basic Ractor Usage”# Create a Ractorractor = Ractor.new do puts "Hello from Ractor #{Ractor.current}" 42end
ractor.take # => 42 (receives the return value)
# Ractor with argumentsractor = Ractor.new("hello") do |message| "#{message} from Ractor"end
ractor.take # => "hello from Ractor"
# Multiple argumentsractor = Ractor.new(10, 20) do |a, b| a + bend
ractor.take # => 30
# Named Ractorsr = Ractor.new(name: "worker") do compute_resultend
# Communication via send/receiveproducer = Ractor.new do Ractor.yield("message from producer")end
consumer = Ractor.new(producer) do |r| r.takeend
consumer.take # => "message from producer"Ractor Communication
Section titled “Ractor Communication”# Ractor.yield sends a value, Ractor#receive or Ractor#take receives itractor = Ractor.new do computation = expensive_task Ractor.yield(computation)end
result = ractor.take
# Send messages to a running Ractorworker = Ractor.new do loop do task = Ractor.receive break if task == :stop result = task * 2 Ractor.yield(result) endend
worker.send(5)worker.send(10)puts worker.take # => 10puts worker.take # => 20worker.send(:stop)Ractor Sharing Rules
Section titled “Ractor Sharing Rules”# Shareable objects: integers, floats, true, false, nil, symbols, frozen objects# Non-shareable: mutable strings, arrays, hashes, procs
# Frozen objects can be sharedFROZEN_CONFIG = { key: "value" }.freezer = Ractor.new(FROZEN_CONFIG) do |config| puts config[:key]endr.take
# Ractor.make_shareable to create shareable objectsshareable_data = Ractor.make_shareable([1, 2, 3])r = Ractor.new(shareable_data) { |data| data.sum }r.take # => 6
# Ractor::Hash for concurrent hash accessshared_hash = Ractor.make_shareable({ counter: 0 })Parallel Processing with Ractors
Section titled “Parallel Processing with Ractors”def parallel_map(array, &block) workers = array.map do |element| Ractor.new(element) { |elem| block.call(elem) } end workers.map(&:take)end
result = parallel_map(1..10) { |n| n ** 2 }# => [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
# Chunked parallel processingdef parallel_reduce(array, chunk_size: 100, &block) chunks = array.each_slice(chunk_size).to_a workers = chunks.map do |chunk| Ractor.new(chunk) { |c| block.call(c) } end workers.map(&:take).flattenend
large_data = (1..1000).to_aresults = parallel_reduce(large_data, chunk_size: 100) { |chunk| chunk.map { |n| n * 2 } }Fibers are lightweight cooperative concurrency primitives. Unlike threads, fibers are not preemptive — they explicitly yield control:
Basic Fiber Usage
Section titled “Basic Fiber Usage”fiber = Fiber.new do puts "Fiber started" Fiber.yield(1) puts "Fiber resumed" Fiber.yield(2) puts "Fiber resumed again" 42end
fiber.resume # => 1 (prints "Fiber started")fiber.resume # => 2 (prints "Fiber resumed")fiber.resume # => 42 (prints "Fiber resumed again")fiber.resume # => FiberError: dead fiber calledFiber as Generators
Section titled “Fiber as Generators”def fibonacci Fiber.new do a, b = 0, 1 loop do Fiber.yield(a) a, b = b, a + b end endend
fib = fibonacci10.times { puts fib.resume }# => 0, 1, 1, 2, 3, 5, 8, 13, 21, 34Fiber with Transfer
Section titled “Fiber with Transfer”# Fiber#transfer switches to a specific fiber (more control than yield/resume)ping = Fiber.new do loop do message = Fiber.yield("ping") puts "Ping received: #{message}" endend
pong = Fiber.new do loop do message = Fiber.yield("pong") puts "Pong received: #{message}" endend
puts ping.resume # => "ping"puts pong.transfer("hello") # => "pong", prints "Pong received: hello"puts ping.transfer("world") # => "ping", prints "Ping received: world"Fiber Scheduler (Ruby 3.0+)
Section titled “Fiber Scheduler (Ruby 3.0+)”The Fiber Scheduler enables non-blocking I/O operations. When a scheduler is active, blocking operations (like sleep, IO.select, socket reads) automatically yield control to other fibers:
# Example scheduler concept (actual implementations in gems like Async)scheduler = MyScheduler.newFiber.set_scheduler(scheduler)
Fiber.schedule do response = HTTP.get("https://example.com") # non-blocking puts response.bodyend
Fiber.schedule do result = Database.query("SELECT * FROM users") # non-blocking process(result)end
# Main loop runs until all fibers completescheduler.runAsync Gem
Section titled “Async Gem”The async gem provides a high-level API for asynchronous I/O in Ruby:
Basic Usage
Section titled “Basic Usage”require 'async'
Async do |task| task.async do response = Async::HTTP::Client.get("https://example.com") puts response.body end
task.async do sleep(1) # non-blocking puts "After 1 second" endendConcurrent Tasks
Section titled “Concurrent Tasks”require 'async'
Async do tasks = 5.times.map do |i| Async do result = fetch_data(i) puts "Task #{i}: #{result}" result end end
results = tasks.map(&:wait) puts "All done: #{results}"endAsync with I/O
Section titled “Async with I/O”require 'async'require 'async/io'
Async do endpoints = [ Async::IO::Endpoint.tcp("example.com", 80), Async::IO::Endpoint.tcp("ruby-lang.org", 80), ]
tasks = endpoints.map do |endpoint| Async do socket = endpoint.connect socket.write("GET / HTTP/1.0\r\nHost: #{endpoint.hostname}\r\n\r\n") response = socket.read(4096) puts "Received #{response.lines.first} from #{endpoint.hostname}" socket.close end end
tasks.each(&:wait)endSemaphore for Limiting Concurrency
Section titled “Semaphore for Limiting Concurrency”require 'async'
Async do semaphore = Async::Semaphore.new(3) # max 3 concurrent
10.times.map do |i| Async do semaphore.acquire do result = fetch_data(i) puts "Task #{i}: #{result}" end end end.each(&:wait)endBarrier for Synchronisation
Section titled “Barrier for Synchronisation”require 'async'
Async do barrier = Async::Barrier.new
5.times do |i| barrier.async do |task| sleep(rand(0.1..1.0)) puts "Worker #{i} done" end end
barrier.wait puts "All workers completed"endComparison of Concurrency Approaches
Section titled “Comparison of Concurrency Approaches”| Approach | Parallelism | Blocking I/O | Complexity | Use Case |
|---|---|---|---|---|
| Thread | Limited (GIL) | Releases GIL on I/O | Medium | I/O-bound tasks |
| Ractor | True parallelism | Full isolation | High | CPU-bound tasks |
| Fiber | Cooperative | Non-blocking with scheduler | Medium | Async I/O |
| Async gem | Cooperative | Non-blocking | Low | Web servers, APIs |
When to Use What
Section titled “When to Use What”# Use Threads for: I/O-bound concurrency (network requests, file I/O)threads = urls.map do |url| Thread.new { HTTP.get(url) }endresults = threads.map(&:value)
# Use Ractors for: CPU-bound parallelism (data processing, computation)ractors = large_datasets.map do |data| Ractor.new(Ractor.make_shareable(data)) { |d| heavy_processing(d) }endresults = ractors.map(&:take)
# Use Async/Fibers for: high-concurrency I/O (web servers, API clients)Async do requests.each do |req| Async { process_request(req) } endendThread Safety Best Practices
Section titled “Thread Safety Best Practices”# 1. Minimize shared mutable state# Badclass Counter def initialize; @count = 0; end def increment; @count += 1; end def count; @count; endend
# Good: use thread-safe constructsclass SafeCounter def initialize @mutex = Mutex.new @count = 0 end
def increment @mutex.synchronize { @count += 1 } end
def count @mutex.synchronize { @count } endend
# 2. Prefer immutable dataconfig = { host: "localhost", port: 8080 }.freeze
# 3. Use thread-safe data structuresqueue = Queue.newshared_array = Concurrent::Array.new # from concurrent-ruby gem
# 4. Avoid global variables in threaded code# 5. Be careful with class variables (@@) -- they are shared across threads# 6. Use Thread.current for thread-local stateconcurrent-ruby Gem
Section titled “concurrent-ruby Gem”The concurrent-ruby gem provides thread-safe data structures and abstractions:
require 'concurrent'
# Thread-safe arrayarray = Concurrent::Array.newarray << 1array << 2
# Thread-safe hashhash = Concurrent::Hash.newhash[:key] = "value"
# Atomic referencescounter = Concurrent::AtomicFixnum.new(0)counter.incrementcounter.value # => 1
# Futures: async computationfuture = Concurrent::Future.execute do expensive_computationendresult = future.value # blocks until result is ready
# Promisespromise = Concurrent::Promise.new do fetch_dataend.then do |data| transform(data)end.on_success do |result| store(result)end.on_error do |error| log_error(error)endpromise.executeCross-References
Section titled “Cross-References”- Object-Oriented Programming defines the objects and state that concurrent threads access and modify.
- Metaprogramming can introduce race conditions when dynamically modifying class definitions across threads.
- Methods and Blocks shows how method calls and blocks interact with Ruby’s thread scheduler.
Common Mistakes
Section titled “Common Mistakes”- Assuming the GIL eliminates all concurrency concerns: The GIL prevents parallel execution of Ruby code but not I/O waits. Threads are useful for I/O-bound tasks but not CPU-bound ones. Use processes or the
concurrent-rubygem for true parallelism. - Modifying shared state without synchronization: Even with the GIL, race conditions occur when multiple threads read and write shared data without locks. Always use
Mutex,Concurrent::AtomicFixnum, or other synchronization primitives for shared mutable state. - Using
Thread.newwithout managing the lifecycle: Threads that raise exceptions silently die. Always handle errors inside threads and consider using a thread pool (Concurrent::ThreadPoolExecutor) instead of spawning unbounded threads. - Confusing Fibers with threads: Fibers are cooperative (you must explicitly yield with
Fiber.yield), while threads are preemptive. Do not use Fibers for concurrent I/O unless you are using Ruby 3+ withFiber.scheduleand an async runtime.