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 a Queue.
  • Thread#value joins 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
PrimitiveUseNote
MutexGuard a critical sectionNon-reentrant: locking twice in one thread deadlocks
Thread::QueueHand work and results between threadsAlso the cleanest cancellation channel
ConditionVariableWait for a state changeMust be used inside the associated mutex
Thread::SizedQueueBounded work queueBlocks the producer, giving backpressure
Concurrent::MapShared lookup from the concurrent-ruby gemCheaper 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.

Metaprogramming and building DSLs Background jobs and the Ruby web stack

Last refreshed 2026-09-18.