DEV Community

Cover image for Building a Wait-Free Queue in Rust
Derek Mwale
Derek Mwale

Posted on

Building a Wait-Free Queue in Rust

There is a point in systems programming where concurrency stops being about “running things at the same time” and starts becoming a conversation about guarantees.

Most programmers first learn concurrency through locks.

You have shared data.

You protect it with a mutex.

One thread enters.

Everyone else waits.

It works.

Then eventually you encounter a system where waiting is not acceptable.

Maybe you are building a low-latency event pipeline.

Maybe a telemetry collector receives millions of events per second.

Maybe a network runtime has producers generating work faster than consumers can process it.

Maybe you are building an operating-system component, a game engine subsystem, a high-frequency message pipeline, or a distributed systems primitive.

Suddenly, “just use a mutex” is no longer an interesting answer.

You start asking different questions.

Can a thread make progress without waiting for another thread?

Can a producer enqueue an item even if another producer has stalled?

Can a consumer continue operating if another consumer disappears halfway through an operation?

Can the data structure guarantee that the system keeps moving even when threads are delayed by the scheduler?

This is where wait-free algorithms enter the picture.

And this is where Rust becomes particularly interesting.

Rust gives us ownership, borrowing, atomics, memory ordering, and a type system designed to make many classes of concurrent memory bugs difficult to express.

But Rust does not magically make a concurrent queue wait-free.

You still have to understand the algorithm.

In this article, we are going to build one.

Not merely a queue with atomics.

A queue with a stronger progress guarantee.

We will explore:

  • what wait-free actually means,
  • how wait-free differs from lock-free and obstruction-free algorithms,
  • why queues are difficult,
  • how atomic operations become synchronization primitives,
  • the role of memory ordering,
  • how a bounded ring buffer works,
  • how sequence numbers solve the producer/consumer ownership problem,
  • how to implement a practical bounded wait-free queue in Rust,
  • what the algorithm guarantees,
  • where the implementation can still become subtle,
  • and why “wait-free” is really a statement about time bounds, not merely the absence of mutexes.

The Queue Looks Innocent

A queue seems like one of the simplest data structures ever invented.

You put things in one end.

You take them out of the other.

Conceptually:

Producer
   |
   v
+-----+-----+-----+-----+-----+
|  A  |  B  |  C  |  D  |  E |
+-----+-----+-----+-----+-----+
                              |
                              v
                           Consumer
Enter fullscreen mode Exit fullscreen mode

A sequential implementation is trivial.

use std::collections::VecDeque;

let mut queue = VecDeque::new();

queue.push_back(10);
queue.push_back(20);

let value = queue.pop_front();
Enter fullscreen mode Exit fullscreen mode

But concurrency changes the problem.

Imagine two producers:

Producer A ----\
                \
                 ---> Queue
                /
Producer B ----/
Enter fullscreen mode Exit fullscreen mode

Both may attempt:

queue.push_back(item);
Enter fullscreen mode Exit fullscreen mode

at approximately the same time.

Now imagine two consumers:

                 ---> Consumer A
                /
Queue ---------
                \
                 ---> Consumer B
Enter fullscreen mode Exit fullscreen mode

Both may attempt to remove the same element.

The problem is no longer:

“How do I store values?”

The problem becomes:

“How do multiple CPUs agree about ownership of memory without corrupting the queue?”

That is a much deeper question.


The Three Progress Guarantees

Before writing code, we need to understand what wait-free means.

Concurrent algorithms are often classified by progress guarantees.

There are three important levels.

Obstruction-Free

An operation completes if it eventually gets to execute without interference from other threads.

Imagine:

Thread A
   |
   |---- works
   |
Thread B stops interfering
   |
   v
operation completes
Enter fullscreen mode Exit fullscreen mode

But if other threads continuously interfere, progress is not guaranteed.

This is the weakest guarantee of the three.


Lock-Free

A lock-free algorithm guarantees that the system as a whole makes progress.

Even if one thread gets delayed, some other thread can complete an operation.

For example:

Thread A ----X
             |
             | stalled
             |
Thread B -----------> succeeds
Enter fullscreen mode Exit fullscreen mode

Thread A may starve forever.

But the system does not completely stop.

Someone makes progress.

This is already a powerful guarantee.


Wait-Free

Wait-free is stronger.

A wait-free algorithm guarantees that every operation completes in a bounded number of steps, regardless of what other threads are doing.

Imagine:

Thread A ----X
             |
             | stalled forever
             |
Thread B -----------------> succeeds
Thread C -----------------> succeeds
Thread D -----------------> succeeds
Enter fullscreen mode Exit fullscreen mode

Thread A's progress does not depend on Thread B, C, or D.

More importantly, Thread B, C, and D also have bounded completion.

The important phrase is:

bounded number of steps

Not:

“probably fast.”

Not:

“doesn't use locks.”

Not:

“uses atomic operations.”

Wait-free is a formal progress property.


Why Mutexes Are Not Enough

Consider:

use std::sync::Mutex;
use std::collections::VecDeque;

struct Queue<T> {
    inner: Mutex<VecDeque<T>>,
}
Enter fullscreen mode Exit fullscreen mode

This is perfectly valid concurrent Rust.

But it isn't wait-free.

Consider:

Thread A
   |
 acquire mutex
   |
   X
   |
 crashes / pauses
   |
   |
   v
 mutex remains unavailable

Thread B
   |
 waits
   |
 waits
   |
 waits
Enter fullscreen mode Exit fullscreen mode

Thread B's progress depends on Thread A.

That's precisely what wait-free algorithms attempt to eliminate.

The challenge is therefore:

How can multiple threads coordinate without requiring one another to release a lock?

The answer begins with atomics.


Atomics Are the Building Blocks

Modern CPUs provide atomic operations.

For example:

use std::sync::atomic::{AtomicUsize, Ordering};

let counter = AtomicUsize::new(0);

counter.fetch_add(1, Ordering::Relaxed);
Enter fullscreen mode Exit fullscreen mode

Multiple threads can modify counter concurrently.

The CPU guarantees that the operation itself is indivisible.

But there is a catch.

Atomicity is not enough.

You also need memory ordering.


Atomicity vs Ordering

Suppose a producer does:

buffer[index] = value;
ready.store(true, Ordering::Release);
Enter fullscreen mode Exit fullscreen mode

And the consumer does:

if ready.load(Ordering::Acquire) {
    let value = buffer[index];
}
Enter fullscreen mode Exit fullscreen mode

The important relationship is:

Producer                    Consumer

write value
    |
    v
Release store -----------> Acquire load
                                |
                                v
                          read value
Enter fullscreen mode Exit fullscreen mode

The release/acquire pair creates a synchronization relationship.

If the consumer observes the release operation, it can safely observe the preceding writes.

This is one of the most important ideas in lock-free programming.


The Queue We Will Build

We are going to build a bounded multi-producer, multi-consumer queue using a ring buffer.

Bounded means the queue has a fixed capacity.

For example:

capacity = 8

+---+---+---+---+---+---+---+---+
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
+---+---+---+---+---+---+---+---+
Enter fullscreen mode Exit fullscreen mode

When we reach the end, we wrap around:

0 -> 1 -> 2 -> ... -> 7 -> 0 -> 1
Enter fullscreen mode Exit fullscreen mode

This avoids allocating memory for every enqueue.

But there is an interesting problem.

Suppose position 3 contains an item.

After the consumer removes it, how does a future producer know that position 3 is available again?

We need more than a simple index.

We need sequence numbers.


Sequence Numbers

Each slot stores metadata describing which logical queue position currently owns it.

Conceptually:

Slot

+-----------------------+
| sequence              |
+-----------------------+
| value                 |
+-----------------------+
Enter fullscreen mode Exit fullscreen mode

Suppose capacity is 4.

Initially:

slot       sequence

0          0
1          1
2          2
3          3
Enter fullscreen mode Exit fullscreen mode

The producer reserves position 0.

It writes:

value = A
Enter fullscreen mode Exit fullscreen mode

and eventually advances the slot's state.

The consumer recognizes that position 0 contains a value.

After consuming it, the consumer changes the sequence so that the slot becomes available for the next generation.

Now position 4 maps back to physical slot 0.

logical position 4
        |
        v
physical slot 0
Enter fullscreen mode Exit fullscreen mode

But the sequence number tells us:

This isn't the old position 0. It is the next generation of position 0.

This is a beautiful trick.

The physical array stays fixed.

The logical positions keep increasing.


The Ring Buffer Model

We maintain two atomic counters:

enqueue_pos
dequeue_pos
Enter fullscreen mode Exit fullscreen mode

Think of them as monotonically increasing logical positions.

For example:

enqueue_pos = 17
dequeue_pos = 14
Enter fullscreen mode Exit fullscreen mode

means approximately three elements are currently in the queue.

The physical slot is determined by:

index = position % capacity
Enter fullscreen mode Exit fullscreen mode

For a power-of-two capacity, we can optimize this further using bit masking.

But modulo is easier to understand initially.


The Slot State

Each slot has:

struct Slot<T> {
    sequence: AtomicUsize,
    value: UnsafeCell<MaybeUninit<T>>,
}
Enter fullscreen mode Exit fullscreen mode

There are two interesting pieces.

AtomicUsize tracks the slot state.

MaybeUninit<T> gives us manually controlled storage for T.

Why MaybeUninit?

Because we don't want to construct every T ahead of time.

If the queue has capacity 1024, we want 1024 storage locations, not 1024 initialized values.


Why Unsafe Appears

Eventually we reach the uncomfortable part.

Concurrent queues often require some unsafe.

That doesn't mean:

“Rust failed.”

It means:

“We are stepping outside Rust's normal aliasing guarantees, and we must prove that our synchronization makes it safe.”

Consider:

UnsafeCell<MaybeUninit<T>>
Enter fullscreen mode Exit fullscreen mode

This allows us to mutate the slot without requiring ordinary Rust borrowing.

That is necessary because several threads may own references to the queue simultaneously.

But we must establish an invariant:

At most one thread accesses a slot's T value at any given time.

The atomic sequence number is what helps us enforce that invariant.


Designing the Queue

Our structure will look roughly like:

pub struct WaitFreeQueue<T> {
    buffer: Box<[Slot<T>]>,
    enqueue_pos: AtomicUsize,
    dequeue_pos: AtomicUsize,
    mask: usize,
}
Enter fullscreen mode Exit fullscreen mode

Each slot:

struct Slot<T> {
    sequence: AtomicUsize,
    value: UnsafeCell<MaybeUninit<T>>,
}
Enter fullscreen mode Exit fullscreen mode

We will use a power-of-two capacity.

For example:

8
16
32
64
128
Enter fullscreen mode Exit fullscreen mode

Then:

index = position & mask
Enter fullscreen mode Exit fullscreen mode

where:

mask = capacity - 1
Enter fullscreen mode Exit fullscreen mode

For capacity 8:

mask = 7

position   index

0          0
1          1
2          2
3          3
4          4
5          5
6          6
7          7
8          0
9          1
Enter fullscreen mode Exit fullscreen mode

Initialization

Each slot begins with a sequence corresponding to its initial position.

for i in 0..capacity {
    slots[i].sequence.store(i, Ordering::Relaxed);
}
Enter fullscreen mode Exit fullscreen mode

So:

slot       sequence

0          0
1          1
2          2
3          3
4          4
5          5
6          6
7          7
Enter fullscreen mode Exit fullscreen mode

The enqueue position starts at zero.

The dequeue position starts at zero.


Enqueue

Now we reach the heart of the algorithm.

A producer wants to enqueue an element.

First, it reserves a logical position.

Conceptually:

position = enqueue_pos.fetch_add(1)
Enter fullscreen mode Exit fullscreen mode

Suppose it gets:

position = 5
Enter fullscreen mode Exit fullscreen mode

The physical slot is:

index = position & mask;
Enter fullscreen mode Exit fullscreen mode

If the capacity is 8:

5 & 7 = 5
Enter fullscreen mode Exit fullscreen mode

So slot 5 is our target.

Now the producer waits for the slot's sequence to indicate that it belongs to this enqueue generation.

In a traditional lock-free algorithm, we may repeatedly retry.

That is important because retry loops can undermine wait-freedom.

So we need to distinguish two things:

atomic reservation
        +
bounded completion
Enter fullscreen mode Exit fullscreen mode

If the operation can repeatedly spin an unbounded number of times, it is not automatically wait-free.

This is where the engineering becomes more subtle.


The Strict Wait-Free Problem

Here's the uncomfortable truth:

A lot of things marketed as “wait-free queues” are actually lock-free queues.

Why?

Because their core algorithm looks like:

loop {
    let pos = tail.load(...);

    if try_reserve(pos) {
        break;
    }
}
Enter fullscreen mode Exit fullscreen mode

The operation may succeed quickly.

But there is no fixed upper bound on the number of failed attempts.

Therefore:

No locks
    ≠
Wait-free
Enter fullscreen mode Exit fullscreen mode

and:

Atomic operations
    ≠
Wait-free
Enter fullscreen mode Exit fullscreen mode

A strict wait-free implementation needs a bounded helping or bounded reservation strategy.

For educational purposes, we can build a bounded queue around atomic slot ownership where each operation performs a fixed amount of synchronization work.

That distinction matters enormously.


A Practical Bounded Design

One approach is to use a fixed number of slots and deterministic ticket positions.

Each producer obtains a ticket:

let position = self.enqueue_pos.fetch_add(1, Ordering::Relaxed);
Enter fullscreen mode Exit fullscreen mode

Because fetch_add is atomic, every producer gets a unique position.

Then:

let index = position & self.mask;
Enter fullscreen mode Exit fullscreen mode

The sequence number tells the producer whether that slot belongs to its generation.

The consumer does the same thing with dequeue_pos.

The key idea is:

logical position
        |
        v
sequence number
        |
        v
ownership of physical slot
Enter fullscreen mode Exit fullscreen mode

The Implementation

Here is a compact implementation of the core bounded queue.

use std::cell::UnsafeCell;
use std::mem::MaybeUninit;
use std::sync::atomic::{AtomicUsize, Ordering};

struct Slot<T> {
    sequence: AtomicUsize,
    value: UnsafeCell<MaybeUninit<T>>,
}

unsafe impl<T: Send> Send for Slot<T> {}
unsafe impl<T: Send> Sync for Slot<T> {}

pub struct WaitFreeQueue<T> {
    buffer: Box<[Slot<T>]>,
    enqueue_pos: AtomicUsize,
    dequeue_pos: AtomicUsize,
    mask: usize,
}

unsafe impl<T: Send> Send for WaitFreeQueue<T> {}
unsafe impl<T: Send> Sync for WaitFreeQueue<T> {}
Enter fullscreen mode Exit fullscreen mode

The Send and Sync implementations deserve attention.

We require:

T: Send
Enter fullscreen mode Exit fullscreen mode

because values may cross thread boundaries.

The queue itself can then be shared between threads.


Constructing the Queue

We need a power-of-two capacity.

impl<T> WaitFreeQueue<T> {
    pub fn with_capacity(capacity: usize) -> Self {
        assert!(capacity.is_power_of_two());
        assert!(capacity > 0);

        let mut slots = Vec::with_capacity(capacity);

        for i in 0..capacity {
            slots.push(Slot {
                sequence: AtomicUsize::new(i),
                value: UnsafeCell::new(MaybeUninit::uninit()),
            });
        }

        Self {
            buffer: slots.into_boxed_slice(),
            enqueue_pos: AtomicUsize::new(0),
            dequeue_pos: AtomicUsize::new(0),
            mask: capacity - 1,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the queue starts empty.


Writing an Element

The producer obtains a ticket:

let position =
    self.enqueue_pos.fetch_add(1, Ordering::Relaxed);
Enter fullscreen mode Exit fullscreen mode

Then:

let index = position & self.mask;
let slot = &self.buffer[index];
Enter fullscreen mode Exit fullscreen mode

We need to ensure the slot belongs to this generation before writing.

Conceptually:

let sequence =
    slot.sequence.load(Ordering::Acquire);
Enter fullscreen mode Exit fullscreen mode

If:

sequence == position
Enter fullscreen mode Exit fullscreen mode

the slot is ready for the producer.

Then:

unsafe {
    (*slot.value.get()).write(value);
}
Enter fullscreen mode Exit fullscreen mode

After writing the value:

slot.sequence.store(
    position + 1,
    Ordering::Release,
);
Enter fullscreen mode Exit fullscreen mode

That release store publishes the value.


Why Release Matters

Imagine:

Producer

write(T)
   |
   v
Release sequence store
   |
   |
   v
Consumer
Acquire sequence load
   |
   v
read(T)
Enter fullscreen mode Exit fullscreen mode

The release operation tells the compiler and CPU:

All writes before this operation must become visible before this synchronization state is published.

The acquire tells the consumer:

Once I observe that published state, I can safely observe the preceding writes.

Without correct ordering, a CPU could theoretically observe the synchronization flag before observing the data write.

That's the kind of bug that makes concurrent programming terrifying.

Your code can look perfectly logical.

Your tests can pass.

And the CPU can still ruin your afternoon.


Reading an Element

The consumer obtains a dequeue position:

let position =
    self.dequeue_pos.fetch_add(1, Ordering::Relaxed);
Enter fullscreen mode Exit fullscreen mode

Then calculates:

let index = position & self.mask;
Enter fullscreen mode Exit fullscreen mode

It checks:

let sequence =
    slot.sequence.load(Ordering::Acquire);
Enter fullscreen mode Exit fullscreen mode

When the sequence indicates that the producer has published the value, the consumer reads:

let value = unsafe {
    (*slot.value.get()).assume_init_read()
};
Enter fullscreen mode Exit fullscreen mode

After consuming the value, the consumer marks the slot available for the next cycle:

slot.sequence.store(
    position + self.mask + 1,
    Ordering::Release,
);
Enter fullscreen mode Exit fullscreen mode

Because:

mask + 1 = capacity
Enter fullscreen mode Exit fullscreen mode

this effectively advances the generation.


The Full Core

A simplified educational version looks like this:

impl<T> WaitFreeQueue<T> {
    pub fn enqueue(&self, value: T) {
        let position =
            self.enqueue_pos.fetch_add(1, Ordering::Relaxed);

        let index = position & self.mask;
        let slot = &self.buffer[index];

        while slot.sequence.load(Ordering::Acquire) != position {
            std::hint::spin_loop();
        }

        unsafe {
            (*slot.value.get()).write(value);
        }

        slot.sequence.store(
            position + 1,
            Ordering::Release,
        );
    }

    pub fn dequeue(&self) -> T {
        let position =
            self.dequeue_pos.fetch_add(1, Ordering::Relaxed);

        let index = position & self.mask;
        let slot = &self.buffer[index];

        while slot.sequence.load(Ordering::Acquire)
            != position + 1
        {
            std::hint::spin_loop();
        }

        let value = unsafe {
            (*slot.value.get()).assume_init_read()
        };

        slot.sequence.store(
            position + self.mask + 1,
            Ordering::Release,
        );

        value
    }
}
Enter fullscreen mode Exit fullscreen mode

This is an important point.

This implementation contains spinning.

Therefore, under a strict formal definition, it should not automatically be called wait-free.

It is much closer to a bounded ring-buffer algorithm with lock-free-style atomic coordination.

And that distinction is not academic.

It is the difference between:

“The queue doesn't use a mutex.”

and:

“Every operation has a mathematically bounded completion time.”

The latter is much harder.


So How Do We Make It Actually Wait-Free?

Now we reach the interesting part.

To get strict wait-freedom, we need to remove unbounded retrying.

There are several strategies.

One is helping.

Suppose Thread A starts an operation and gets descheduled.

Thread B encounters Thread A's unfinished operation.

Instead of waiting for A, B completes A's operation.

This changes the model from:

A must finish
    |
    v
B can continue
Enter fullscreen mode Exit fullscreen mode

to:

A starts
 |
 X
 |
B notices incomplete work
 |
v
B helps A
 |
v
A's operation completes
Enter fullscreen mode Exit fullscreen mode

Now a stalled thread cannot indefinitely block everyone else.


Operation Descriptors

Helping often requires an operation descriptor.

Instead of immediately performing an operation, a thread publishes its intent:

struct Operation<T> {
    state: AtomicUsize,
    value: MaybeUninit<T>,
}
Enter fullscreen mode Exit fullscreen mode

The operation might have states:

PENDING
CLAIMED
COMPLETED
Enter fullscreen mode Exit fullscreen mode

Another thread can inspect it.

If it sees:

PENDING
Enter fullscreen mode Exit fullscreen mode

it can help.

This transforms concurrency from:

threads competing
Enter fullscreen mode Exit fullscreen mode

into:

threads cooperating
Enter fullscreen mode Exit fullscreen mode

That is one of the deepest ideas in non-blocking algorithms.


The Universal Pattern

Many wait-free algorithms follow a pattern like:

1. Publish intention.
2. Attempt progress.
3. If another operation is incomplete, help it.
4. Complete your own operation.
Enter fullscreen mode Exit fullscreen mode

The helping mechanism creates bounded progress.

If every operation can help another operation finish, then a stalled thread is no longer a permanent obstacle.


But There Is a Cost

Wait-free does not mean free.

You are exchanging one resource for another.

Locks give you:

simple reasoning
+
potential blocking
Enter fullscreen mode Exit fullscreen mode

Lock-free algorithms give:

more complexity
+
system-wide progress
Enter fullscreen mode Exit fullscreen mode

Wait-free algorithms give:

even more complexity
+
per-operation progress guarantees
Enter fullscreen mode Exit fullscreen mode

You are paying for the guarantee.

And sometimes the cost is not worth it.


Why Wait-Free Queues Are Difficult

The difficult part isn't:

AtomicUsize
Enter fullscreen mode Exit fullscreen mode

The difficult part is the interaction between:

ownership
+
memory reclamation
+
atomic ordering
+
ABA prevention
+
progress guarantees
+
cache coherence
+
compiler reordering
Enter fullscreen mode Exit fullscreen mode

Each is a separate problem.

Together they become a small operating system hiding inside a data structure.


The ABA Problem

Consider an atomic pointer:

A -> B
Enter fullscreen mode Exit fullscreen mode

Thread 1 reads:

A
Enter fullscreen mode Exit fullscreen mode

Thread 2 changes:

A -> C
Enter fullscreen mode Exit fullscreen mode

then:

C -> A
Enter fullscreen mode Exit fullscreen mode

Thread 1 wakes up.

It sees:

A
Enter fullscreen mode Exit fullscreen mode

and thinks:

“Nothing changed.”

But something absolutely changed.

The state went:

A -> C -> A
Enter fullscreen mode Exit fullscreen mode

This is the ABA problem.

Sequence numbers are one technique for detecting generations of reuse.

Instead of:

pointer = A
Enter fullscreen mode Exit fullscreen mode

we can conceptually use:

(A, generation=42)
Enter fullscreen mode Exit fullscreen mode

Then:

(A, generation=43)
Enter fullscreen mode Exit fullscreen mode

is visibly different.

That is another reason sequence counters are so powerful.


False Sharing

Correctness is not the only concern.

Performance matters.

Suppose:

struct Queue {
    enqueue_pos: AtomicUsize,
    dequeue_pos: AtomicUsize,
}
Enter fullscreen mode Exit fullscreen mode

These fields may occupy the same CPU cache line.

Now:

CPU 1 -> writes enqueue_pos
CPU 2 -> writes dequeue_pos
Enter fullscreen mode Exit fullscreen mode

Even though they are logically unrelated, the CPUs may continuously invalidate each other's cache lines.

This is called false sharing.

The code is correct.

The algorithm is correct.

And the performance can still collapse.

A production implementation may use cache-line padding:

#[repr(align(64))]
struct Padded<T> {
    value: T,
}
Enter fullscreen mode Exit fullscreen mode

Although the correct alignment depends on the target architecture and workload.


Memory Ordering Is a Performance Dial

Rust gives us:

Ordering::Relaxed
Ordering::Acquire
Ordering::Release
Ordering::AcqRel
Ordering::SeqCst
Enter fullscreen mode Exit fullscreen mode

A beginner often reaches for:

Ordering::SeqCst
Enter fullscreen mode Exit fullscreen mode

everywhere.

It is understandable.

It provides the strongest ordering model.

But stronger ordering can introduce unnecessary synchronization constraints.

A better approach is:

Use the weakest ordering that preserves the algorithm's proof.

For example:

fetch_add(Ordering::Relaxed)
Enter fullscreen mode Exit fullscreen mode

can be appropriate for allocating unique positions when the operation's synchronization happens elsewhere.

Meanwhile:

sequence.load(Ordering::Acquire)
Enter fullscreen mode Exit fullscreen mode

and:

sequence.store(Ordering::Release)
Enter fullscreen mode Exit fullscreen mode

can establish the producer/consumer publication relationship.

The important question isn't:

“Which ordering is safest?”

The important question is:

“What ordering does the proof require?”


The Queue Is Really a State Machine

One way to understand the algorithm is to stop thinking about it as a queue.

Think of every slot as a tiny state machine.

For example:

          producer
              |
              v
        +-------------+
        |   EMPTY     |
        +-------------+
              |
              | write
              v
        +-------------+
        |    FULL     |
        +-------------+
              |
              | read
              v
        +-------------+
        |   EMPTY     |
        +-------------+
Enter fullscreen mode Exit fullscreen mode

But because the ring buffer reuses physical slots, we need generations.

So the real state is closer to:

EMPTY generation 0
        |
        v
FULL generation 0
        |
        v
EMPTY generation 1
        |
        v
FULL generation 1
Enter fullscreen mode Exit fullscreen mode

The sequence number encodes this state.


Why Bounded Queues Are Attractive

A dynamically growing concurrent queue has another problem.

Memory allocation.

Suppose an enqueue requires:

allocate node
initialize node
link node
publish node
Enter fullscreen mode Exit fullscreen mode

Now allocation itself becomes part of the concurrency story.

A bounded ring buffer avoids this.

Memory is allocated once:

startup
   |
   v
+-----------------------+
| fixed-size allocation |
+-----------------------+
Enter fullscreen mode Exit fullscreen mode

Then operations reuse the same storage.

This makes bounded queues attractive for:

  • network systems,
  • telemetry pipelines,
  • embedded systems,
  • real-time systems,
  • game engines,
  • audio processing,
  • kernel-style components,
  • high-performance logging.

Testing the Queue

Concurrent code cannot be tested like ordinary code.

This:

#[test]
fn test_queue() {
    let queue = WaitFreeQueue::with_capacity(1024);

    queue.enqueue(10);
    assert_eq!(queue.dequeue(), 10);
}
Enter fullscreen mode Exit fullscreen mode

proves almost nothing about concurrency.

We need multiple producers and consumers.

For example:

use std::sync::Arc;
use std::thread;

let queue = Arc::new(
    WaitFreeQueue::with_capacity(1024)
);

let mut handles = Vec::new();

for producer_id in 0..4 {
    let queue = Arc::clone(&queue);

    handles.push(thread::spawn(move || {
        for i in 0..10_000 {
            queue.enqueue(
                producer_id * 10_000 + i
            );
        }
    }));
}
Enter fullscreen mode Exit fullscreen mode

Then consumers drain the queue.

We can verify:

number produced == number consumed
Enter fullscreen mode Exit fullscreen mode

and:

no values duplicated
Enter fullscreen mode Exit fullscreen mode

and:

no values lost
Enter fullscreen mode Exit fullscreen mode

Stress Testing

Concurrency bugs are often probabilistic.

The code may pass:

10 runs
Enter fullscreen mode Exit fullscreen mode

and fail:

run 11,482
Enter fullscreen mode Exit fullscreen mode

You want tests that create different scheduling conditions.

For example:

Producer A
    |
    | sleep
    v
Producer B
    |
    v
Consumer A
    |
    v
Producer A resumes
Enter fullscreen mode Exit fullscreen mode

The scheduler becomes part of the adversary.

A good concurrent test attempts to make timing unpredictable.


Loom and Model Checking

For serious Rust concurrency work, tools such as loom are extremely useful.

Instead of merely executing one scheduling order:

A -> B -> C
Enter fullscreen mode Exit fullscreen mode

a model checker can explore many possible interleavings:

A -> B -> C
A -> C -> B
B -> A -> C
B -> C -> A
C -> A -> B
C -> B -> A
Enter fullscreen mode Exit fullscreen mode

The number of schedules grows quickly.

That's precisely why concurrent algorithms are difficult to reason about manually.


The Bigger Lesson

Building this queue teaches something more important than queues.

It teaches that concurrency is fundamentally about ownership transitions.

At every moment, someone needs to answer:

Who owns this memory?

For an empty slot:

producer owns it
Enter fullscreen mode Exit fullscreen mode

For a published slot:

consumer owns it
Enter fullscreen mode Exit fullscreen mode

For a consumed slot:

producer owns it again
Enter fullscreen mode Exit fullscreen mode

The queue is essentially a machine for transferring ownership between threads.

Rust's ownership system operates at the language level.

Our queue's sequence numbers implement a second ownership system at runtime.

That's the beautiful part.


Rust Is Not Making the Algorithm Easy

Rust prevents many ordinary memory bugs.

It does not prove that your lock-free algorithm is correct.

This code:

unsafe {
    (*slot.value.get()).write(value);
}
Enter fullscreen mode Exit fullscreen mode

is not automatically safe because it compiles.

We need an invariant.

Something like:

A producer may write to a slot only after observing the sequence corresponding to its exclusive logical position.

And:

A consumer may read a slot only after observing the producer's release publication.

And:

A slot cannot be simultaneously owned by a producer and consumer.

Those statements form the beginning of the correctness proof.


A Useful Mental Model

When designing concurrent data structures, think in terms of:

STATE
  |
  v
WHO OWNS IT?
  |
  v
WHAT ATOMIC EVENT TRANSFERS OWNERSHIP?
  |
  v
WHAT MEMORY ORDERING MAKES THE DATA VISIBLE?
  |
  v
CAN ANOTHER THREAD GET STUCK?
  |
  v
IS THE NUMBER OF STEPS BOUNDED?
Enter fullscreen mode Exit fullscreen mode

That final question separates wait-free algorithms from many merely lock-free designs.


Wait-Free vs Lock-Free

Let's make the distinction painfully clear.

Mutex queue

Thread A
   |
 locks
   |
   X
   |
 Thread B waits
Enter fullscreen mode Exit fullscreen mode

Progress depends on lock ownership.

Lock-free queue

Thread A
   |
   X
   |
Thread B -----> succeeds
Enter fullscreen mode Exit fullscreen mode

The system makes progress.

Wait-free queue

Thread A
   |
   X

Thread B -----> succeeds

Thread C ----------> succeeds

Thread D ----------------> succeeds
Enter fullscreen mode Exit fullscreen mode

Every operation has a bounded progress guarantee.

The difference is not cosmetic.

It is mathematical.


When Should You Use Wait-Free Structures?

Almost never by default.

If you are writing an ordinary web application:

HTTP request
     |
     v
database
     |
     v
response
Enter fullscreen mode Exit fullscreen mode

you probably do not need a wait-free queue.

A mutex-backed queue may be simpler and perfectly adequate.

Wait-free algorithms become interesting when latency and contention matter enough to justify the complexity.

Examples include:

real-time systems
high-frequency messaging
network runtimes
telemetry
game engines
audio systems
embedded systems
low-latency trading infrastructure
operating-system components
Enter fullscreen mode Exit fullscreen mode

The key is not:

“Wait-free is faster.”

It isn't necessarily.

The key is:

“Wait-free gives a stronger bound on progress.”

That's a different property.


The Hidden Cost of Wait-Free

Imagine two systems.

System A:

average latency: 2 μs
worst case: 500 μs
Enter fullscreen mode Exit fullscreen mode

System B:

average latency: 3 μs
worst case: 5 μs
Enter fullscreen mode Exit fullscreen mode

If you care about real-time behavior, System B may be vastly more valuable.

This is why worst-case guarantees matter.

Average performance tells you what normally happens.

Wait-free reasoning asks:

What can happen in the worst case?

That is a much more demanding question.


What We Built

Our conceptual queue contains:

             WaitFreeQueue
                  |
       +----------+----------+
       |                     |
 enqueue_pos            dequeue_pos
       |                     |
       v                     v
   producers             consumers
       |                     |
       +----------+----------+
                  |
                  v
            ring buffer
                  |
          +-------+-------+
          |       |       |
          v       v       v
        Slot    Slot    Slot
          |       |       |
          +-------+-------+
                  |
                  v
           sequence numbers
Enter fullscreen mode Exit fullscreen mode

Each slot contains:

sequence
value
Enter fullscreen mode Exit fullscreen mode

The sequence identifies the logical generation of the slot.

The atomic positions allocate logical queue positions.

Release/acquire ordering transfers visibility.

And bounded capacity avoids allocation during ordinary operations.


The Most Important Caveat

If you take one thing from this article, make it this:

An atomic queue is not automatically a wait-free queue.

A queue can be:

mutex-based
lock-free
wait-free
Enter fullscreen mode Exit fullscreen mode

and all three can be implemented correctly.

The distinction is the progress guarantee.

A loop such as:

while !try_operation() {
    spin_loop();
}
Enter fullscreen mode Exit fullscreen mode

should immediately make you ask:

Is the number of iterations bounded?

If the answer is no, you should be extremely cautious about calling the algorithm wait-free.

This is one of the easiest mistakes to make when discussing concurrent data structures.


Where to Go Next

Once you understand this queue, there are several deeper directions.

You can explore:

Michael-Scott queues
Enter fullscreen mode Exit fullscreen mode

for dynamically allocated lock-free queues.

Then:

hazard pointers
Enter fullscreen mode Exit fullscreen mode

for safe memory reclamation.

Then:

epoch-based reclamation
Enter fullscreen mode Exit fullscreen mode

for another approach to managing retired nodes.

Then:

helping
Enter fullscreen mode Exit fullscreen mode

for constructing genuinely wait-free algorithms.

And eventually:

linearizability
Enter fullscreen mode Exit fullscreen mode

which gives you a formal way to reason about whether concurrent operations appear to occur atomically.

That's where concurrent programming starts looking less like ordinary application development and more like mathematics.


Final Thought

The first time you build a concurrent queue, you think the difficult part is storing the data.

It isn't.

The difficult part is convincing multiple CPUs that they agree about reality.

One CPU says:

"This slot is mine."
Enter fullscreen mode Exit fullscreen mode

Another says:

"I am reading it."
Enter fullscreen mode Exit fullscreen mode

A third may be delayed for milliseconds.

The cache coherence protocol is doing things underneath you.

The compiler is allowed to reorder operations unless synchronization prevents it.

The operating system may pause a thread at exactly the wrong moment.

And yet your data structure must remain correct.

That's what makes wait-free programming fascinating.

You aren't merely writing code.

You're defining a protocol between independent machines that happen to share memory.

Rust gives you powerful tools for expressing that protocol:

AtomicUsize
UnsafeCell
MaybeUninit
Acquire
Release
Enter fullscreen mode Exit fullscreen mode

But the real skill is learning what those tools mean together.

A wait-free queue is therefore more than a queue.

It is a lesson in distributed systems disguised as shared memory.

There are multiple participants.

They cannot assume another participant will respond.

They communicate through carefully defined state transitions.

They need explicit ownership.

They need ordering guarantees.

And the strongest algorithms make progress even when one participant disappears.

That sounds a lot like distributed computing.

Except the network is your CPU's memory subsystem.

And the latency is measured in nanoseconds.

That is the strange beauty of concurrent programming.

The moment you stop asking “does this code work?” and start asking “what is the worst thing another thread can do to me?” — you are beginning to think like a systems programmer.

Top comments (0)