Polyglot
Languages Ruby concurrency
Ruby § concurrency

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/receive and yield/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 async for substantial cooperative concurrency.
  • Use Mutex/Queue for thread-safe state.
  • Use concurrent-ruby for 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 Open3 for 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.