Skip to content

Ractor Patterns

Common patterns for using RactorQueue across Ractor boundaries.


1. Single Producer / Single Consumer (1P1C)

The baseline pattern — one Ractor feeds another through a shared queue. Use a sentinel value (:done, :stop, etc.) to signal end-of-stream.

q = RactorQueue.new(capacity: 1024)

producer = Ractor.new(q) do |queue|
  100.times { |i| queue.push(i * i) }
  queue.push(:done)
end

consumer = Ractor.new(q) do |queue|
  results = []
  loop do
    v = queue.pop
    break if v == :done
    results << v
  end
  results
end

producer.value
puts consumer.value.inspect  # [0, 1, 4, 9, 16, ...]

Queue sizing: With a single producer and consumer, a modest queue (1024–4096) is sufficient. The producer can run ahead while the consumer processes, smoothing out bursts.


2. Worker Pool (MPMC)

A shared job queue drained by N Ractor workers. One sentinel per worker signals shutdown:

WORKERS = 8
jobs    = RactorQueue.new(capacity: 10_000)
results = RactorQueue.new(capacity: 10_000)

workers = WORKERS.times.map do
  Ractor.new(jobs, results) do |jq, rq|
    loop do
      job = jq.pop(timeout: 30)
      break if job == :stop
      rq.push(job * job)  # do work
    end
  end
end

1000.times { |i| jobs.push(i) }
WORKERS.times { jobs.push(:stop) }  # one per worker

results_list = 1000.times.map { results.pop }
workers.each(&:value)

Two-Queue Deadlock

Chaining two small bounded queues can deadlock (see Concurrency Notes). Size the queues large enough that producers never block while consumers are blocked on the other queue, or drain the result queue asynchronously in a separate Ractor.

Queue sizing for the worker pool pattern:

  • jobs capacity: at least job_count + worker_count (so main can push all jobs without blocking)
  • results capacity: at least job_count (so workers can push all results without blocking)

3. Queue Pool (High Ractor Counts)

When many Ractors share a single bounded queue, the spin-wait backoff keeps things moving, but beyond roughly 2 × CPU cores Ractors doing pure queue operations, cache-line contention causes diminishing returns.

The queue pool pattern gives each producer/consumer pair its own queue — zero cross-pair contention, linear scaling to core count:

PAIRS = 16   # 32 Ractors total

pairs = PAIRS.times.map do
  q = RactorQueue.new(capacity: 1024)
  p = Ractor.new(q) { |queue| 1000.times { |i| queue.push(i) } }
  c = Ractor.new(q) { |queue| 1000.times { queue.pop } }
  [p, c]
end

pairs.each { |p, c| p.value; c.value }

Trade-off: Work is statically partitioned — each producer feeds only its paired consumer. For dynamic load balancing across many workers, use a small number of shared queues (e.g., 4 queues for 16 Ractors) with jobs chunked large enough that producers rarely hit the capacity limit.


4. Two-Stage Pipeline

Ractors chained in stages, each transforming values and passing them downstream:

raw    = RactorQueue.new(capacity: 64)
middle = RactorQueue.new(capacity: 64)

stage1 = Ractor.new(raw, middle) do |src, dst|
  loop do
    v = src.pop(timeout: 5)
    break if v == :done
    dst.push(v * 2)
  end
  dst.push(:done)
end

stage2 = Ractor.new(middle) do |src|
  output = []
  loop do
    v = src.pop(timeout: 5)
    break if v == :done
    output << v * 3
  end
  output
end

5.times { |i| raw.push(i + 1) }  # push 1..5
raw.push(:done)

stage1.value
puts stage2.value.inspect  # [6, 12, 18, 24, 30]

Pipeline Queue Sizing

In a pipeline, each stage's output queue should be large enough to buffer a burst from the upstream stage. If stages process at different speeds, size the intermediate queues to the slower stage's burst capacity.


5. Validate-Shareable Guard

Use validate_shareable: true on queues that feed Ractors to catch bad producers at the push site rather than at the Ractor boundary:

safe_q = RactorQueue.new(capacity: 64, validate_shareable: true)

safe_q.push(42)             # ok
safe_q.push("hello".freeze) # ok
safe_q.push(:symbol)        # ok

safe_q.push([1, 2, 3])      # raises RactorQueue::NotShareableError immediately

The guard also fires inside a Ractor block:

bad_producer = Ractor.new(safe_q) do |queue|
  queue.push([4, 5, 6])
rescue RactorQueue::NotShareableError => err
  "caught: #{err.message}"
end

puts bad_producer.value  # "caught: [4, 5, 6] is not Ractor-shareable"

Choosing a Pattern

Situation Pattern
Simple producer → consumer handoff 1P1C
Dynamic job dispatch to N workers Worker Pool
Ractor count > 2× CPU cores Queue Pool
Multi-stage data transformation Pipeline
Need to enforce Ractor-safe payloads validate_shareable