Multithreading #

Concurrency and parallelism are two concepts that are often confused, and Ruby holds a unique position between them. Threads in Ruby do run concurrently — taking turns using the CPU — but they aren’t fully parallel on CPU-bound tasks, because the GIL (Global Interpreter Lock) in CRuby ensures only one thread executes Ruby code at a time. But this doesn’t mean threading in Ruby is useless: for I/O-bound tasks like HTTP requests, database queries, and file reads/writes, threading provides real benefits because the GIL is released while waiting on I/O. Since Ruby 3.0, Ractor enables true parallelism. This article covers all of Ruby’s concurrency mechanisms, from basic Threads to modern Ractor.

GIL — Understanding Ruby’s Fundamental Limitation #

Before writing threading code, it’s important to understand the GIL (Global Interpreter Lock) — also called the GVL (Global VM Lock) in modern CRuby documentation.

flowchart TD
    A[Ruby program with 4 Threads] --> B{Task type?}
    B --> C["CPU-bound\n(heavy computation)"]
    B --> D["I/O-bound\n(network, file, DB)"]
    C --> E["GIL prevents parallel execution\nOnly 1 thread runs at a time\nPerformance = single-threaded"]
    D --> F["GIL is released while waiting on I/O\nOther threads can run\nSignificant performance gain"]
    E --> G["Solution: Ractor or fork\nfor true parallelism"]
    F --> H["Threading is enough\nfor I/O concurrency"]
require 'benchmark'

# Demonstrating the GIL on CPU-bound tasks
def intensive_calc(n)
  n.times { Math.sqrt(rand) }
end

n = 5_000_000

# Sequential — one at a time
sequential_time = Benchmark.realtime do
  4.times { intensive_calc(n) }
end

# Multi-threaded — 4 threads at once
threaded_time = Benchmark.realtime do
  threads = 4.times.map { Thread.new { intensive_calc(n) } }
  threads.each(&:join)
end

puts "Sequential: #{sequential_time.round(2)}s"
puts "4 threads:  #{threaded_time.round(2)}s"
# The results are almost the same — the GIL prevents true parallelism for CPU-bound work!
When threading in Ruby is beneficial:
  ✓ Making HTTP requests to many APIs simultaneously
  ✓ Concurrent database queries
  ✓ Reading/writing many files
  ✓ Web servers serving many requests at once (Puma!)
  ✓ Producer-consumer pipelines with I/O at every stage

  ✗ Heavy mathematical computation
  ✗ Image or video processing (CPU-intensive)
  ✗ Intensive encryption or hashing
  → For these: use Ractor, fork, or separate processes

Creating and Managing Threads #

Thread.new — Creating a New Thread #

# The simplest thread
t = Thread.new { puts "Hello from a new thread!" }
t.join   # wait for the thread to finish before continuing

# Thread with a multi-line block
thread = Thread.new do
  puts "Thread started"
  sleep 1
  puts "Thread finished"
end

puts "Main thread keeps running"
thread.join
puts "Main thread waited for completion"

join and value — Waiting and Getting Results #

join waits for a thread to finish. value waits and also returns the value of the block’s last expression:

# value — like join but returns the calculation result
thread = Thread.new { (1..100).sum }
result = thread.value   # wait and get the result
puts result   # => 5050

# join with a timeout — don't wait forever
t = Thread.new { sleep 10 }
finished = t.join(2)   # wait at most 2 seconds
if finished.nil?
  puts "Thread timed out — still running"
  t.kill   # force-stop it
else
  puts "Thread finished on time"
end

# Running many threads and collecting results
urls = ["https://api1.com", "https://api2.com", "https://api3.com"]

# ANTI-PATTERN: sequential — slow
# results = urls.map { |url| fetch_data(url) }

# CORRECT: parallel I/O with threads
results = urls.map { |url| Thread.new { fetch_data(url) } }
            .map(&:value)   # collect all results

puts results.inspect

Thread Lifecycle and Status #

t = Thread.new { sleep 2 }

puts t.status    # => "sleep"  (currently sleeping)
puts t.alive?    # => true
puts t.stop?     # => true  (stop is also true while sleeping!)

sleep 1
puts t.status    # => "sleep"

t.join
puts t.status    # => false  (finished normally)
puts t.alive?    # => false

# A thread that crashes
t_crash = Thread.new { raise "Intentional crash" }
t_crash.join rescue nil
puts t_crash.status   # => nil  (finished because of an exception)

# Possible statuses: "run", "sleep", "aborting", false, nil

Thread Variables — Per-Thread Local Variables #

Regular instance variables inside a thread are not thread-safe because they’re shared with other objects. Thread-local variables are stored per-thread:

# Thread.current[] — storing values per thread
Thread.new do
  Thread.current[:name] = "Thread A"
  sleep 0.1
  puts Thread.current[:name]   # => "Thread A"
end

Thread.new do
  Thread.current[:name] = "Thread B"
  sleep 0.1
  puts Thread.current[:name]   # => "Thread B"  (unaffected by Thread A)
end.join

# Thread.current[] is useful for storing per-request context
# in web frameworks like Rails (e.g. current_user)

Synchronization — Preventing Race Conditions #

A race condition happens when two or more threads access and modify the same data simultaneously without coordination, producing unpredictable output.

# ANTI-PATTERN: the classic race condition
counter = 0
threads = 10.times.map do
  Thread.new do
    1000.times { counter += 1 }   # counter += 1 is not atomic!
  end
end
threads.each(&:join)
puts counter   # Should be 10000, but might be less!
# counter += 1 is actually: read the value, add 1, write it back
# another thread can slip in between these steps

Mutex — Mutual Exclusion #

A Mutex ensures only one thread executes the critical block at a time:

counter = 0
mutex   = Mutex.new

threads = 10.times.map do
  Thread.new do
    1000.times do
      mutex.synchronize do
        counter += 1   # now safe — only one thread enters this block
      end
    end
  end
end
threads.each(&:join)
puts counter   # => always 10000

# Mutex also has try_lock — doesn't block if already locked
mutex = Mutex.new
acquired = mutex.try_lock
if acquired
  begin
    # do something
  ensure
    mutex.unlock
  end
else
  puts "Mutex is being used by another thread, skipping"
end

Deadlock — The Most Dangerous Trap #

A deadlock happens when two threads wait on each other to release a lock:

mutex_a = Mutex.new
mutex_b = Mutex.new

# Thread 1: locks A first, then B
thread1 = Thread.new do
  mutex_a.synchronize do
    sleep 0.1   # pause so thread 2 has time to lock B
    mutex_b.synchronize do
      puts "Thread 1 finished"
    end
  end
end

# Thread 2: locks B first, then A — DEADLOCK!
thread2 = Thread.new do
  mutex_b.synchronize do
    sleep 0.1
    mutex_a.synchronize do   # ← waits for thread 1 to release A, but thread 1 waits for B
      puts "Thread 2 finished"
    end
  end
end

[thread1, thread2].each(&:join)
# The program hangs forever — deadlock!
# Preventing deadlocks: always lock mutexes in the same order
mutex_a = Mutex.new
mutex_b = Mutex.new

# Both threads lock A first, then B — no deadlock
thread1 = Thread.new do
  mutex_a.synchronize do
    mutex_b.synchronize do
      puts "Thread 1 finished"
    end
  end
end

thread2 = Thread.new do
  mutex_a.synchronize do   # ← same order: A first, then B
    mutex_b.synchronize do
      puts "Thread 2 finished"
    end
  end
end

[thread1, thread2].each(&:join)

ConditionVariable — Coordinating Between Threads #

ConditionVariable is used together with a Mutex to make threads wait for a specific condition to be met:

mutex  = Mutex.new
cond   = ConditionVariable.new
ready  = false

# Consumer — waits for the producer to be ready
consumer = Thread.new do
  mutex.synchronize do
    cond.wait(mutex) until ready   # wait until ready = true
    puts "Consumer: data received!"
  end
end

# Producer — prepares data then signals
producer = Thread.new do
  sleep 1
  mutex.synchronize do
    ready = true
    cond.signal   # wake up the waiting consumer
    puts "Producer: data sent!"
  end
end

[producer, consumer].each(&:join)

Queue — Thread-Safe Communication #

Queue is a data structure designed specifically for inter-thread communication — all operations are already thread-safe without needing a manual mutex:

require 'thread'   # not needed in Ruby >= 3.2, already built-in

queue = Queue.new

# Producer — generates data
producer = Thread.new do
  10.times do |i|
    sleep rand(0.1..0.3)
    queue << "item-#{i}"
    puts "Produced: item-#{i}"
  end
  queue << :done   # sentinel value
end

# Consumer — processes data
consumer = Thread.new do
  loop do
    item = queue.pop   # blocks until there's an item
    break if item == :done
    puts "Processed: #{item}"
    sleep rand(0.1..0.2)
  end
end

[producer, consumer].each(&:join)

SizedQueue — A Queue with a Capacity Limit #

SizedQueue limits the number of items in the queue — the producer automatically waits when the queue is full:

queue = SizedQueue.new(5)   # at most 5 items

# Producer — will pause when the queue is full
producer = Thread.new do
  20.times do |i|
    queue.push("item-#{i}")   # blocks when the queue already has 5 items
    puts "Produced: item-#{i} | Queue: #{queue.size}/5"
  end
end

# Consumer — processes slower than the producer
consumer = Thread.new do
  20.times do
    item = queue.pop
    sleep 0.2   # slower than the producer
    puts "Processed: #{item}"
  end
end

[producer, consumer].each(&:join)

Manual Thread Pools with Queue #

A thread pool limits the number of concurrently running threads — preventing the creation of too many threads that would waste memory:

class ThreadPool
  def initialize(size)
    @size   = size
    @queue  = Queue.new
    @workers = size.times.map do
      Thread.new do
        loop do
          task = @queue.pop
          break if task == :shutdown
          begin
            task.call
          rescue => e
            puts "Worker error: #{e.message}"
          end
        end
      end
    end
  end

  def submit(&block)
    @queue << block
  end

  def shutdown
    @size.times { @queue << :shutdown }
    @workers.each(&:join)
  end
end

# Usage
pool = ThreadPool.new(4)   # 4 worker threads

20.times do |i|
  pool.submit do
    sleep rand(0.1..0.5)
    puts "Task #{i} finished by #{Thread.current.object_id}"
  end
end

pool.shutdown
puts "All tasks finished"

Fiber — Cooperative Concurrency #

Fiber is a lightweight cooperative coroutine — unlike threads that are scheduled by the OS, Fibers are scheduled manually by the developer using Fiber.yield and resume:

# Basic fiber — explicit control of when to switch
fiber = Fiber.new do
  puts "Fiber: step 1"
  Fiber.yield   # hand control back to the caller
  puts "Fiber: step 2"
  Fiber.yield
  puts "Fiber: step 3"
end

puts "Main: start"
fiber.resume   # => "Fiber: step 1"
puts "Main: back"
fiber.resume   # => "Fiber: step 2"
puts "Main: back again"
fiber.resume   # => "Fiber: step 3"

# Fiber with values passed back and forth
generator = Fiber.new do
  value = 0
  loop do
    value += 1
    Fiber.yield value   # send the value to the caller
  end
end

puts generator.resume   # => 1
puts generator.resume   # => 2
puts generator.resume   # => 3

Fiber vs Thread #

Fiber:
  ✓ Very lightweight — you can create millions of Fibers
  ✓ No race conditions — one Fiber runs at a time
  ✓ Explicit control of when to switch (cooperative)
  ✓ Great for generators, lazy sequences, state machines
  ✗ No parallelism — must yield manually
  ✗ One blocking Fiber blocks everything

Thread:
  ✓ Preemptive scheduling — the OS manages switching
  ✓ Useful for real concurrent I/O
  ✗ Heavier — each thread uses more memory
  ✗ Needs synchronization for shared data

Ractor — True Parallelism in Ruby 3.x #

Ractor (introduced in Ruby 3.0) is the way to achieve true parallelism in CRuby without the GIL. Each Ractor has isolated memory — there’s no shared mutable state between Ractors.

# Ractor — true parallelism
# Each Ractor has its own GIL → runs truly in parallel

# Heavy computation that can be parallelized with Ractor
def heavy_computation(n)
  (1..n).sum { |i| Math.sqrt(i) }
end

# Sequential — slow
seq_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
seq_results = 4.times.map { heavy_computation(1_000_000) }
seq_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - seq_time

# Parallel with Ractor — faster on multi-core machines!
ractor_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
ractors = 4.times.map do
  Ractor.new { heavy_computation(1_000_000) }
end
ractor_results = ractors.map(&:take)
ractor_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - ractor_time

puts "Sequential: #{seq_time.round(2)}s"
puts "Ractor:     #{ractor_time.round(2)}s"
# On a 4-core machine: Ractor ~4x faster!

# Communication between Ractors through message passing
r = Ractor.new do
  message = Ractor.receive   # wait for an incoming message
  "Processed: #{message}"
end

r.send("Hello from main!")
puts r.take   # => "Processed: Hello from main!"
# Ractor Pipeline — a series of Ractors processing data
pipeline = (1..5).map do |stage|
  Ractor.new(stage) do |n|
    loop do
      data = Ractor.receive
      result = data * n   # different transformation at each stage
      Ractor.yield result
    end
  end
end

# Send data to the first pipeline stage
pipeline[0].send(10)
# Data flows through every Ractor
Ractor is still experimental in Ruby 3.x — some gems aren’t compatible because they use shared mutable state. Check the compatibility of the gems you use before adopting Ractor in production.

Overcoming the GIL — Complete Strategies #

flowchart TD
    A[Need true parallelism?] --> B{Workload type?}
    B --> C["I/O-bound\nnetwork, file, DB"]
    B --> D["CPU-bound\nheavy computation"]
    C --> E["Threads are enough\nGIL is released during I/O"]
    D --> F{Choose a strategy}
    F --> G["Ractor\nRuby 3.0+\nTrue parallelism\nwithout fork"]
    F --> H["fork + Process\nFull isolation\nsuitable for batch jobs"]
    F --> I["JRuby / TruffleRuby\nImplementations without GIL\nCompatible with MRI gems"]
    F --> J["Parallel gem\nfork/thread wrapper\nEasy to use"]
# Approach 1: fork for CPU-bound tasks
pids = 4.times.map do |i|
  fork do
    result = heavy_computation(1_000_000)
    # Send the result to the parent process via a pipe or file
    puts "Worker #{i}: #{result}"
    exit
  end
end
pids.each { |pid| Process.wait(pid) }

# Approach 2: the Parallel gem — a convenient abstraction
# gem install parallel
require 'parallel'

data = (1..20).to_a

# Parallel.map — uses fork automatically
results = Parallel.map(data, in_processes: 4) do |n|
  heavy_computation(n * 100_000)
end

# Parallel with threads (for I/O-bound work)
results = Parallel.map(urls, in_threads: 10) do |url|
  HTTP.get(url).body
end

Threading Best Practices in Ruby #

# 1. Use thread-safe objects from the standard library
require 'thread'

# Queue, SizedQueue — already thread-safe
queue = Queue.new

# Regular Hash and Array are NOT thread-safe for writes
# ANTI-PATTERN:
results = {}
threads = urls.map do |url|
  Thread.new { results[url] = fetch(url) }   # ← race condition!
end
threads.each(&:join)

# CORRECT: collect with join/value and assign after everything finishes
results = urls.map { |url| Thread.new { [url, fetch(url)] } }
            .map(&:value)
            .to_h

# 2. Minimize shared mutable state
# The less data shared between threads, the fewer
# race conditions and synchronization needs

# 3. Handle exceptions inside threads
thread = Thread.new do
  begin
    # code that might fail
    result = risky_process()
  rescue => e
    puts "Thread error: #{e.message}"
    nil   # return nil as a fallback
  end
end

# Without a rescue inside the thread, exceptions are silently ignored!
# Thread.abort_on_exception = true  ← enable for debugging

# 4. Set abort_on_exception for development
Thread.abort_on_exception = true   # the program crashes if any thread raises

# 5. Use timeouts for threads that might hang
require 'timeout'
begin
  Timeout.timeout(5) do
    thread.join   # wait at most 5 seconds
  end
rescue Timeout::Error
  thread.kill
  puts "Thread timed out!"
end

Summary #

  • The GIL limits CPU-bound parallelism in CRuby — for I/O-bound tasks (network, file, database), threading still provides real benefits because the GIL is released while waiting on I/O.
  • Thread.value for collecting results — more idiomatic than storing into a shared array; threads.map(&:value) waits for all threads and returns their results.
  • Mutex.synchronize for critical sections — always use synchronize rather than manual lock/unlock to ensure the lock is always released even when an exception occurs.
  • Always lock mutexes in the same order — to prevent deadlocks when multiple mutexes are used together.
  • Queue and SizedQueue are already thread-safe — use them for inter-thread communication instead of a regular shared Array or Hash.
  • Thread pools to limit thread count — creating too many threads wastes memory; a pool limits concurrency to a controlled number.
  • Handle exceptions inside threads — un-rescued exceptions inside a thread are silently ignored; use Thread.abort_on_exception = true during development.
  • Fiber for cooperative multitasking — very lightweight, great for generators and state machines, but requires manual yielding.
  • Ractor for true parallelism — each Ractor has isolated memory and isn’t bound by the GIL; suitable for heavy computation in Ruby 3.x, though still experimental.
  • Consider the Parallel gem or fork for mature, production-ready CPU-bound parallelism.

← Previous: RubyGems   Next: I/O →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact