Concurrency Notes¶
Spin-Wait Backoff¶
push and pop use a two-phase exponential backoff spin loop:
Phase 1 — Thread.pass (retries 0–15):
The first 16 retries call Thread.pass, which invokes sched_yield() at the OS level. This is cheap and yields to another thread at the same scheduler priority. It's fast when the queue clears quickly (light contention, brief full/empty states).
Thread.pass also triggers Ruby's interrupt-checking machinery, so Thread#raise and Ctrl-C can escape a blocked push or pop during this phase.
Phase 2 — sleep(0.0001) (retries 16+):
After 16 Thread.pass calls, subsequent retries sleep for 100 µs. sleep() actually suspends the OS thread (rather than spin-yielding it), freeing the core for consumers or other Ractors to make real progress.
This sleep phase is critical for Ractor workloads: each Ractor is its own OS thread. Under high contention, if all Ractors call sched_yield() in a tight loop, the OS rotates them at the same priority level without allowing any to advance. sleep breaks this cycle.
retry 0–15: Thread.pass → fast path, low latency
retry 16+: sleep(0.0001) → OS thread yields core, prevents scheduler storm
Choosing a Push/Pop Variant¶
RactorQueue provides three levels of push and pop, each suited to a different processing context:
| Variant | Blocking strategy | Returns when empty/full |
|---|---|---|
try_push / try_pop |
Never blocks | Immediately (false / EMPTY) |
push / pop |
Phase 1: sleep(0) × 16 → Phase 2: sleep(0.0001) |
After space / item available |
async_push / async_pop |
Always sleep(0) (never escalates) |
After space / item available |
try_push / try_pop — Non-blocking¶
Use when you cannot afford to wait: poll loops, event-driven code, or any place where "nothing ready yet" is a normal condition to handle yourself.
# Drain without blocking — caller decides what to do when empty
loop do
v = q.try_pop
break if v.equal?(RactorQueue::EMPTY)
process(v)
end
push / pop — OS-thread backoff¶
Use in Ractors and plain Threads (no fiber scheduler installed).
The two-phase backoff suspends the OS thread after 16 fast retries, freeing the core for other Ractors or threads to make progress:
retries 0–15: sleep(0) → fast spin, low first-item latency
retries 16+: sleep(0.0001) → core is yielded; scheduler can run other threads
The eventual sleep(0.0001) is what prevents spin-wait storms under high Ractor contention. async_push/async_pop never reach this phase, which makes them unsuitable for OS-thread contexts under sustained back-pressure — they burn more CPU without making faster progress.
# Correct for Ractor workers
Ractor.new(queue) do |q|
loop do
item = q.pop # OS thread parks after 16 retries
break if item == :done
process(item)
end
end
async_push / async_pop — Fiber-scheduler-aware¶
Use inside Async { } blocks (async-rb, Falcon, any fiber-scheduler environment).
In a fiber-scheduled context, sleep(0) does not sleep at all — it yields execution to the scheduler, which runs another ready fiber and resumes this one when the queue state may have changed. This enables cooperative waiting without blocking the OS thread.
require "async"
q = RactorQueue.new(capacity: 8)
Async do |task|
# Pusher and popper fibers cooperate — neither blocks an OS thread
task.async { 5.times { |i| sleep(0.01); q.async_push(i) } }
task.async { 5.times { puts q.async_pop } }
end
push/pop also work inside Async blocks, but their Phase 2 sleep(0.0001) holds the fiber suspended for 100 µs each retry — unnecessary when the scheduler can wake the fiber the moment another fiber pushes or pops. async_* avoids that artificial delay.
Outside a fiber scheduler, sleep(0) returns almost immediately (it is a real OS call with near-zero duration). This means async_push/async_pop become a tight spin loop in plain Thread or Ractor contexts — higher CPU usage than push/pop with no throughput benefit.
Summary¶
| Processing context | Recommended variant |
|---|---|
| Ractor worker | push / pop |
| Plain Thread (no scheduler) | push / pop |
Async { } fiber (async-rb / Falcon) |
async_push / async_pop |
| Poll loop / event-driven / non-blocking | try_push / try_pop |
Two-Queue Deadlock¶
Chaining two bounded queues in a pipeline with small capacities can deadlock:
main blocks pushing to jobs (full)
↓
workers block pushing to results (full)
↓
main cannot drain results (it is blocked on jobs)
↓ deadlock
Example of the broken pattern:
# DANGEROUS — both queues are small; deadlock is possible
jobs = RactorQueue.new(capacity: 64)
results = RactorQueue.new(capacity: 64)
# If main fills `jobs` and workers fill `results` simultaneously,
# the whole system locks up.
Fix 1: Size queues to hold all in-flight items
ITEMS = 10_000
WORKERS = 8
jobs = RactorQueue.new(capacity: ITEMS + WORKERS) # never fills up
results = RactorQueue.new(capacity: ITEMS) # never fills up
ITEMS.times { |i| jobs.push(i) } # non-blocking — queue is large enough
WORKERS.times { jobs.push(:stop) }
Fix 2: Drain results asynchronously
jobs = RactorQueue.new(capacity: 128)
results = RactorQueue.new(capacity: 128)
# A dedicated drain Ractor processes results while main keeps pushing jobs
drainer = Ractor.new(results) do |rq|
all = []
loop { v = rq.pop(timeout: 60); break if v == :stop; all << v }
all
end
# Now main can push jobs freely — it never needs to read results
ITEMS.times { |i| jobs.push(i) }
WORKERS.times { jobs.push(:stop) }
results.push(:stop)
drainer.value # collect everything after all workers are done
Spin-Wait Storm¶
When more Ractors are actively spinning (blocked on push/pop) than there are idle CPU cores, the OS scheduler can thrash. Each spinning Ractor calls sched_yield() in a tight loop, generating constant context-switch overhead without making progress.
The sleep-based backoff in Phase 2 mitigates this, but the practical ceiling for a single shared queue doing pure queue operations is roughly 2 × CPU cores Ractors. Beyond that, use the queue pool pattern.
| Ractor count | Recommended approach |
|---|---|
| ≤ 2× cores | Single shared queue is fine |
| > 2× cores | Queue pool (one queue per producer/consumer pair) |
| Very high (50+) | Queue pool; pairs share work via chunked batching |
nil as a Payload¶
nil is an unambiguous payload. try_pop returns RactorQueue::EMPTY (a unique frozen sentinel) when the queue is empty, and nil when nil was actually pushed:
q = RactorQueue.new(capacity: 8)
q.push(nil)
q.try_pop # => nil (the nil we pushed)
q.try_pop # => RactorQueue::EMPTY (queue is now empty — clearly distinct)
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 a real payload
The blocking pop has no empty case — it only returns when a value was actually dequeued.
Approximate State Queries¶
size, empty?, and full? read a snapshot from the underlying C++ atomic counter. Under concurrent pushes and pops, the snapshot may be stale by the time the Ruby call returns. This is inherent to lock-free data structures.
Do not use state queries for coordination logic. For example, spinning on empty? to wait for an item is wrong — use pop instead. The correct use of state queries is for monitoring, logging, or sizing decisions at startup.
Ruby 4.0 Ractor Semantics¶
In Ruby 4.0, non-shareable objects no longer raise Ractor::IsolationError when crossing Ractor boundaries via Ractor#value. RactorQueue's validate_shareable: false (default) allows pushing mutable objects without error — they can be consumed by the Ractor without isolation faults.
If you need strict enforcement that only shareable objects enter the queue, use validate_shareable: true.