0.0
No release in over 3 years
A lock-free MPMC queue that can be shared across Ruby Ractors — the only Ractor-safe bounded queue option since Ruby's built-in Queue uses Mutex and cannot cross Ractor boundaries.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 5.0
~> 13.0

Runtime

~> 4.0
 Project Readme

ractor_queue

A lock-free, bounded, MPMC queue that can be shared across Ruby Ractors.

Ruby's built-in Queue uses a Mutex internally and cannot be passed as a shared reference across Ractor boundaries. RactorQueue has no mutex — it is always Ractor.shareable? and can be handed to any number of Ractors simultaneously.

q = RactorQueue.new(capacity: 1024)

producer = Ractor.new(q) { |queue| 1000.times { |i| queue.push(i) } }
consumer = Ractor.new(q) { |queue| 1000.times { queue.pop } }

producer.value
consumer.value

Backed by the max0x7ba/atomic_queue C++14 header-only library via Rice 4.x bindings.


Installation

Add to your Gemfile:

gem "ractor_queue"

Or install directly:

gem install ractor_queue

Requires MRI Ruby 3.2+ and a C++17 compiler. The native extension is built automatically on gem install.


Quick Start

require "ractor_queue"

# Create a bounded queue (capacity rounds up to the next power of two, minimum 4096)
q = RactorQueue.new(capacity: 256)

# Non-blocking
q.try_push(42)      # => true  (enqueued)
q.try_push(:hello)  # => true
q.try_pop           # => 42
q.try_pop           # => :hello
q.try_pop           # => RactorQueue::EMPTY  (queue was empty)

# Check for empty with identity comparison (never use ==)
v = q.try_pop
process(v) unless v.equal?(RactorQueue::EMPTY)

# Blocking — spin-waits until space / item is available
q.push(99)          # => self  (chainable)
q.pop               # => 99

# Blocking with timeout
q.pop(timeout: 0.5) # raises RactorQueue::TimeoutError after 500 ms if still empty

# State (approximate under concurrency)
q.size              # => Integer
q.empty?            # => true / false
q.full?             # => true / false
q.capacity          # => Integer (exact)

# Always true — the queue itself is Ractor-shareable
Ractor.shareable?(q) # => true

API

Method Returns Notes
RactorQueue.new(capacity:, validate_shareable: false) RactorQueue instance Capacity rounded up to power-of-two minimum
try_push(obj) true / false Non-blocking; false if full
try_pop obj or RactorQueue::EMPTY Non-blocking; EMPTY sentinel if queue was empty; nil if nil was pushed
push(obj, timeout: nil) self Blocks until space; raises TimeoutError if timeout expires
pop(timeout: nil) obj Blocks until item; raises TimeoutError if timeout expires
size Integer Approximate element count
empty? Boolean Approximate
full? Boolean Approximate
capacity Integer Exact allocated capacity

Errors

Class / Constant Meaning
RactorQueue::EMPTY Sentinel returned by try_pop when the queue is empty. Check with equal?, never ==.
RactorQueue::TimeoutError Raised by push or pop when the timeout: deadline expires.
RactorQueue::NotShareableError Raised by push/try_push when validate_shareable: true and the object is not Ractor-shareable.

validate_shareable

With validate_shareable: true, the queue raises NotShareableError at push time for any non-shareable object, catching mistakes before they reach a Ractor boundary:

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

safe_q.push(42)             # ok — Integer is shareable
safe_q.push("hello".freeze) # ok — frozen String is shareable
safe_q.push([1, 2, 3])      # raises RactorQueue::NotShareableError

Ractor Patterns

1. Single Producer / Single Consumer (1P1C)

The baseline pattern — one Ractor feeds another through a shared queue.

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

2. Worker Pool (MPMC)

A shared job queue drained by N Ractor workers. Size the queues large enough to hold all in-flight items — chaining two small bounded queues risks deadlock (see Concurrency Notes).

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) }

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

3. Queue Pool (High Ractor Counts)

When many Ractors share a single bounded queue, the spin-wait backoff keeps things moving, but beyond ~2× core count you get diminishing returns from cache-line contention. Use one queue per producer/consumer pair — 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 }

Concurrency Notes

Spin-wait backoff

push and pop use an exponential backoff spin loop: the first 16 retries call Thread.pass; subsequent retries call sleep(0.0001). The sleep actually suspends the OS thread, preventing scheduler thrashing when many Ractors are blocked on the same queue.

This means Thread#raise and Ctrl-C can interrupt a blocked push or pop at any point.

Two-queue deadlock

Chaining two bounded queues in a pipeline can deadlock when both queues are full simultaneously:

main blocks pushing to jobs (full)
  ↓
workers block pushing to results (full)
  ↓
main cannot drain results (it is blocked)
  ↓  deadlock

Fix: size at least one queue large enough that its producer never blocks, or drain results asynchronously in a separate Ractor.

Spin-wait storm

When more Ractors are actively spinning (blocked on push/pop) than there are idle cores, the OS scheduler can thrash. The sleep-based backoff mitigates this, but the practical ceiling for a single shared queue is roughly 2 × CPU cores Ractors doing nothing but queue operations. For higher Ractor counts, use the queue pool pattern.

nil as a payload

try_pop returns RactorQueue::EMPTY when the queue is empty and nil when nil was the pushed value — the two are unambiguous. Always check for empty with identity comparison:

v = q.try_pop
return if v.equal?(RactorQueue::EMPTY)
process(v)   # v may be nil — that's fine, it's a real payload

Do not use == to check for EMPTY — use equal?.


Performance

Measured on Apple M2 Max (12 cores), Ruby 4.0.2:

Configuration Throughput
1 producer / 1 consumer Ractor ~470K ops/s
2P / 2C shared queue ~855K ops/s
4P / 4C shared queue ~1.25M ops/s
8P / 8C shared queue ~1.53M ops/s
8P / 8C queue pool (8 queues) ~1.66M ops/s
50P / 50C queue pool ~1.60M ops/s

Ruby's built-in Queue is not included — it cannot participate in Ractor benchmarks.

Under MRI threads (no Ractors), Ruby's Queue is faster because the GVL makes lock-free atomics unnecessary. RactorQueue's advantage is exclusive to Ractor workloads.


Running the Examples

bundle exec ruby examples/01_basic_usage.rb   # Ractor usage patterns
bundle exec ruby examples/02_performance.rb   # Throughput benchmarks

Development

bundle install
bundle exec rake compile   # build the native extension
bundle exec rake test      # run the test suite

Documentation

Document Description
examples/01_basic_usage.rb Annotated Ractor usage patterns (1P1C, timeout, worker pool, pipeline, validate_shareable)
examples/02_performance.rb Throughput benchmarks across queue topologies and Ractor counts
docs/superpowers/specs/2026-04-10-atomic-queue-design.md Original design specification (C extension architecture, Rice bindings, API design decisions)
docs/superpowers/plans/ Implementation plans for each development phase

License

MIT. The vendored max0x7ba/atomic_queue C++ library is also MIT licensed.