Ruby Concurrency Mastery: From Thread Pools to Ractors for High-Performance Applications
Learn Ruby concurrency patterns: thread pools, Fibers, Ractors & work-stealing for scalable applications. Master GIL, async I/O, and parallel processing.
Let’s talk about making Ruby applications handle many tasks at once. When I first started working with Ruby, I thought concurrency was just about using threads. I quickly learned it’s more nuanced than that. Ruby, especially the standard MRI version, has a unique way of handling simultaneous operations because of something called the Global Interpreter Lock, or GIL. This means that, in MRI, only one thread can execute Ruby code at a time, even if you have multiple CPU cores. It sounds limiting, but it simplifies things by preventing a whole class of complicated bugs. However, it also means we have to be smart about how we manage work to keep our applications fast and responsive.
The goal isn’t just to run things in parallel; it’s to manage resources wisely, whether we’re waiting on a database, processing a file, or handling a web request. Over time, I’ve learned that different problems call for different tools. Let me walk you through some of the most effective ways I’ve found to manage this.
Imagine you have a hundred tasks to complete. Starting a new Ruby thread for each one seems logical, but creating threads isn’t free. Each one consumes memory, and switching between them adds overhead. A thread pool solves this by creating a team of workers upfront. This team waits for jobs to appear in a shared to-do list, a queue.
Here’s a basic version of a thread pool I might build. It controls how many workers exist and how many jobs can be waiting.
class SimplePool
def initialize(size: 5)
@size = size
@queue = Queue.new
@workers = []
size.times do
@workers << Thread.new do
loop do
job = @queue.pop
break if job == :stop
job.call
end
end
end
end
def perform(&block)
@queue << block
end
def shutdown
@size.times { @queue << :stop }
@workers.each(&:join)
end
end
# Using it
pool = SimplePool.new(size: 3)
10.times do |i|
pool.perform do
sleep(1) # Simulating work
puts "Job #{i} done by #{Thread.current.object_id}"
end
end
sleep(4) # Let jobs finish
pool.shutdown
In this setup, only three threads ever exist. They work through the ten jobs. If all three are busy, new jobs simply wait in the queue. This prevents your system from being overwhelmed. For a production system, you’d add error handling, ways to gracefully stop, and perhaps a way to limit the queue size so it doesn’t grow forever. The key idea is resource management: you control the maximum impact on your system.
Sometimes, you need to manage thousands of lightweight tasks, like handling network connections for a chat server. Using a thread for each connection would be heavy. This is where Fibers come in. Think of a Fiber as a task that can pause itself and let another task run, all within a single thread. It’s cooperative, not preemptive. The task says, “I’m waiting for data now, someone else can go.”
This allows you to manage massive concurrency with very little overhead. Here’s a conceptual look at how a Fiber-based scheduler might work.
require 'socket'
# A very basic fiber scheduler for reads
scheduler = Thread.new do
fibers_waiting_on_io = {}
loop do
# Check which sockets are ready
readable_sockets, = IO.select(fibers_waiting_on_io.keys, [], [], 0.1)
if readable_sockets
readable_sockets.each do |socket|
waiting_fiber = fibers_waiting_on_io.delete(socket)
waiting_fiber.resume(socket) if waiting_fiber
end
end
sleep 0.01
end
end
# A method to 'await' a socket read
def await_read(socket)
# This fiber yields control back to the scheduler thread
Fiber.yield(socket)
# When resumed, the socket should be ready to read
socket.read_nonblock(1024, exception: false)
end
# In your main application fiber
Fiber.new do
server = TCPServer.new(1234)
puts "Server started on port 1234"
loop do
client = server.accept
puts "Accepted connection"
# Handle each client in its own fiber, but all in one thread
Fiber.new do
begin
loop do
# This will pause this fiber, not the whole thread
data = await_read(client)
break if data.nil? # Connection closed
if data != :wait_readable
client.puts "Echo: #{data}"
end
end
ensure
client.close
puts "Connection closed"
end
end.resume
end
end.resume
This is a simplified view. Ruby 3 introduced a more formal Fiber.scheduler interface. The power here is handling ten thousand connections in a single thread, because you’re only actively working on the ones that have data ready. It’s perfect for I/O-heavy applications.
For truly parallel execution—making full use of multiple CPU cores for heavy calculations—Ruby 3 introduced Ractors. A Ractor is like a separate mini-Ruby process with its own interpreter, but it lives within your main process. The critical rule is that Ractors cannot share mutable objects. They must pass copies of data or send messages. This “share-nothing” design prevents concurrency bugs by making them impossible.
Let’s say you need to process a large array of numbers with a complex calculation.
# Without Ractors (sequential)
data = (1..10_000).to_a
result = data.map { |n| Math.sqrt(n) * Math.log(n) }
# With Ractors (parallel)
def parallel_process(data, worker_count: 4)
# Split the work
chunks = data.each_slice((data.size / worker_count.to_f).ceil).to_a
# Create Ractors, each working on a chunk
ractors = chunks.map do |chunk|
Ractor.new(chunk) do |sub_array|
sub_array.map { |n| Math.sqrt(n) * Math.log(n) }
end
end
# Collect and combine results
ractors.flat_map(&:take)
end
result = parallel_process(data, worker_count: 4)
In the Ractor example, each chunk is processed in a truly parallel fashion on a separate CPU core, if available. The Ractor.new creates the isolated worker, and take waits for its result. Remember, the block you give to Ractor.new and the data you send it are isolated. You can’t directly modify a variable from the outside. This forces clean, safe design.
When threads do need to share some state, like a counter or a cache, you must coordinate access. The simplest way is a Mutex, a lock that ensures only one thread enters a piece of code at a time.
class SharedCounter
def initialize
@value = 0
@mutex = Mutex.new
end
def increment
@mutex.synchronize do
@value += 1
end
end
def value
@mutex.synchronize do
@value
end
end
end
counter = SharedCounter.new
threads = 10.times.map do
Thread.new do
1000.times { counter.increment }
end
end
threads.each(&:join)
puts "Counter is: #{counter.value}" # Correctly 10000
Without the mutex.synchronize, two threads could read the old value (say, 5) at the same time, both add one, and both write back 6, losing one increment. This is a race condition. The mutex prevents it.
For more complex structures, like a hash shared across many threads, you can use finer-grained locking or thread-safe classes from the concurrent-ruby gem, which is an invaluable library for real-world applications.
require 'concurrent'
# A thread-safe map
cache = Concurrent::Map.new
# This is safe from race conditions
cache.compute_if_absent(:user_123) do
fetch_user_from_database(123) # Expensive operation
end
The compute_if_absent method ensures the expensive fetch_user_from_database is called only once, even if a hundred threads ask for :user_123 at the same moment. It handles the locking internally.
The Reactor pattern is the engine behind high-performance network servers. At its core is an event loop that watches many I/O streams (sockets, files). When a stream becomes ready for reading or writing, the loop fires a callback. This means a single thread can manage thousands of network connections. Libraries like nio4r and servers like Falcon use this pattern.
While building a full reactor is complex, you can see the idea in this loop:
require 'socket'
connections = []
server = TCPServer.new('localhost', 3000)
loop do
# Check for new connections AND ready sockets, wait up to 0.1 sec
readable, = IO.select([server] + connections, [], [], 0.1)
if readable
readable.each do |socket|
if socket == server
# New client
conn = server.accept
connections << conn
puts "New client connected."
else
# Existing client has data
begin
data = socket.read_nonblock(1024)
if data
socket.puts "You said: #{data.chomp}"
end
rescue IO::WaitReadable
# Not enough data yet, skip for now
rescue EOFError
# Client disconnected
connections.delete(socket)
socket.close
puts "Client disconnected."
end
end
end
end
end
This single loop accepts new clients and reads data from existing ones. It never blocks for long because read_nonblock returns immediately. It’s efficient because the thread is only busy when there’s actual data to process.
In a work-stealing system, each worker thread has its own queue of tasks. When a worker finishes its own tasks, it doesn’t sit idle. Instead, it looks at other workers’ queues and “steals” a task from the end. This is a fantastic way to balance load automatically.
You can find this pattern in parallel task frameworks. Here’s a conceptual sketch.
class Worker
def initialize(id, all_queues)
@id = id
@my_queue = Queue.new
@all_queues = all_queues
@thread = Thread.new { run }
end
def run
loop do
task = get_task
break if task == :stop
task.call
end
end
def get_task
# 1. Try my own queue first
return @my_queue.pop unless @my_queue.empty?
# 2. Try to steal from others
@all_queues.each do |q|
next if q == @my_queue || q.empty?
begin
return q.pop(true) # non-blocking pop
rescue ThreadError
# Someone else stole it, try next
end
end
# 3. Nothing to do, wait on my own queue
@my_queue.pop
end
def add_task(task)
@my_queue << task
end
def stop
add_task(:stop)
@thread.join
end
end
# Create workers sharing access to each other's queues
queues = []
workers = 4.times.map do |i|
Worker.new(i, queues).tap { |w| queues << w.instance_variable_get(:@my_queue) }
end
# Distribute tasks unevenly
workers[0].add_task(-> { puts "Heavy task A" })
workers[0].add_task(-> { puts "Heavy task B" })
# Workers 1, 2, 3 have no tasks initially
# The idle workers will steal from worker 0's queue
sleep(1)
workers.each(&:stop)
Even though we initially gave all tasks to worker 0, the idle workers will steal them, ensuring all CPU cores are utilized. This pattern is excellent for uneven, unpredictable workloads.
So, how do you choose? It depends on your problem. Ask yourself: Is my work mostly waiting on I/O (like database calls or HTTP requests)? Then a thread pool or Fiber-based concurrency is great. The thread pool is simpler and well-understood. Fibers offer higher scalability for specialized I/O-heavy apps.
Is my work pure, hard calculation? If you’re using MRI and the calculation is in Ruby code, threads won’t run in parallel due to the GIL. Here, you need Ractors to get true parallelism across CPU cores. Or, consider using JRuby or TruffleRuby, which don’t have a GIL, where threads can run Ruby code in parallel.
Are you building a high-performance network server like a web socket endpoint? Look closely at the Reactor pattern and libraries built on it.
Do you have a bag of many independent tasks? A work-stealing pool is often the most efficient.
The most common mistake I see is reaching for threads for everything. Start simple. Use a thread pool from concurrent-ruby for background jobs. Use a mutex to protect simple shared state. Profile your application. If you hit a limit, then explore the more advanced patterns. Concurrency is a tool, and the best tool is the one that makes your application correct, understandable, and fast enough for your needs. Ruby provides the options; it’s up to us to apply them wisely.