RY 's Blog

A Lock-Free SPSC Queue in Swift

2026-08-12

Ring buffers are simple. Correct concurrent ring buffers are not.

This article builds the idea in three steps: first, the ring buffer as a data
structure; second, the single-producer/single-consumer (SPSC) specialization and
its Swift implementation; and finally, the ownership and memory-ordering
argument that makes the implementation lock-free and safe from data races when
its contract is respected.

The implementation discussed here is SPSCQueue.swift,
a bounded generic queue built with
ManagedAtomic and manually managed
Swift storage.

Part 1: What is a ring buffer?

A ring buffer, also called a circular buffer, is a fixed-size sequence of slots
whose end connects back to its beginning. Instead of moving existing elements
when one is removed, the buffer advances two indices:

  • the write index points to the slot where the next element will be inserted;
  • the read index points to the slot containing the next element to remove.

After either index reaches the end of the allocation, it wraps to zero:

1
2
3
4
5
6
7
8
9
physical slots       0     1     2     3     4
+-----+-----+-----+-----+-----+
| E | | B | C | D |
+-----+-----+-----+-----+-----+
^ ^
| |
write read

logical FIFO order: B, C, D, E

The logical queue crosses the physical end of the allocation, but no element
needs to move. Advancing an index is constant time:

1
2
3
4
private func incremented(_ index: Int) -> Int {
let next = index + 1
return next == ringCapacity ? 0 : next
}

This gives a ring buffer several useful properties:

  • bounded memory: the allocation size is chosen up front;
  • constant-time insertion and removal: both operations only touch an index
    and one slot;
  • storage reuse: removed slots are reused after the indices wrap;
  • good locality: adjacent elements live in one contiguous allocation;
  • stable hot-path behavior: the queue itself does not resize or allocate
    during push and pop.

The last point applies to the queue storage, not necessarily to Element.
Inserting a String, Array, or another reference-backed value may still do
work in that type’s own implementation.

Empty and full need different representations

If the read and write indices are equal, the most natural interpretation is
“empty.” But after the producer wraps around, equality could also mean “full.”
A ring buffer therefore needs another way to distinguish those states. Common
choices include a separate count, monotonically increasing counters, an extra
flag, or one unused slot.

This queue uses the unused-slot design. A queue exposed as capacity C
allocates C + 1 physical slots:

1
2
3
self.capacity = capacity
self.ringCapacity = capacity + 1
self.storage = .allocate(capacity: capacity + 1)

The state rules are then unambiguous:

1
2
empty: write == read
full: increment(write) == read

The extra slot is not a permanently wasted physical location. Every slot may be
used as the indices rotate; the rule only keeps one logical gap between the
producer and consumer at any instant.

Where ring buffers fit

Ring buffers work especially well when data moves continuously through a fixed
pipeline:

  • audio samples moving from a capture callback to a processing thread;
  • video frames or sensor readings moving between pipeline stages;
  • network packets or decoded messages passed from I/O to a worker;
  • telemetry events produced at a known point and drained elsewhere;
  • a rolling history that overwrites old values when full.

That final example uses a different full-buffer policy. Some rings overwrite
the oldest element; some reject the newest element; some block the producer.
This SPSCQueue never overwrites unread data. tryPush reports a full queue,
while push waits by spinning until the consumer releases a slot.

A ring buffer is a poor fit when the collection must grow without a bound, when
many unrelated producers or consumers need to share it, or when callers need
random insertion and removal. A channel, actor, mutex-protected collection, or
another queue topology will usually express those requirements more safely.

Part 2: From a ring buffer to an SPSC queue

SPSC means single producer, single consumer. Exactly one logical execution
context inserts elements, and exactly one removes them. The producer and
consumer may run concurrently, but two producer calls must never overlap, and
neither may two consumer calls.

That restriction is the main optimization. A general multi-producer queue must
arbitrate between writers competing for the same position, usually with a lock
or a compare-and-exchange loop. Here, index ownership is static:

State Written by Observed by
writeIndex producer consumer
readIndex consumer producer
cachedReadIndex producer only producer only
cachedWriteIndex consumer only consumer only

Each shared index is atomic because the other side reads it concurrently. Each
cached index is ordinary memory because it belongs to only one side.

The producer path

Before inserting an element, the producer finds a writable slot:

1
2
3
4
5
6
7
8
9
10
11
12
13
private func writableSlot() -> (slot: Int, nextWrite: Int)? {
let write = writeIndex.load(ordering: .relaxed)
let nextWrite = incremented(write)

if nextWrite == cachedReadIndex {
cachedReadIndex = readIndex.load(ordering: .acquiring)
if nextWrite == cachedReadIndex {
return nil
}
}

return (write, nextWrite)
}

The producer owns writeIndex, so its initial load can be relaxed: it does not
need information published by another writer. It first checks a cached copy of
the consumer’s position. Only when that cached value suggests the ring may be
full does it perform an acquiring load of the shared readIndex.

Once a slot is available, insertion has two deliberately ordered steps:

1
2
storage.advanced(by: slot).initialize(to: element)
writeIndex.store(nextWrite, ordering: .releasing)

The element is fully initialized before the release store publishes the new
write position. Moving the index is the commit point: before it, the consumer
must treat the slot as unavailable; after it, the consumer may read the slot.

The consumer path

The consumer is the mirror image:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public func pop() -> Element? {
let read = readIndex.load(ordering: .relaxed)

if read == cachedWriteIndex {
cachedWriteIndex = writeIndex.load(ordering: .acquiring)
if read == cachedWriteIndex {
return nil
}
}

let element = storage.advanced(by: read).move()
readIndex.store(incremented(read), ordering: .releasing)
return element
}

The consumer owns readIndex, so its own-position load is relaxed. It refreshes
the producer’s position with an acquiring load only when its cache suggests the
queue may be empty. It then uses move() to transfer the value out of the raw
slot, leaving that slot uninitialized, and publishes the newly available space
with a release store to readIndex.

Why cache the other side’s index?

The important cost is not merely the atomic instruction. It is the movement of
cache-line ownership between CPU cores. As Erik Rigtorp explains in
Optimizing a Ring Buffer for Throughput, the
consumer repeatedly writes readIndex, so its core wants the cache line that
contains that index in an exclusive state. If the producer reads readIndex on
every push, the producer’s core requests a shared copy. The consumer must then
regain exclusive ownership before its next update. The same cache-line bouncing
happens in the opposite direction when the consumer reads writeIndex and the
producer subsequently updates it.

The local snapshots reduce those ownership transitions. Suppose the consumer
refreshes cachedWriteIndex and learns that N elements are available. It can
consume those N elements without reading the producer’s atomic index again.
Likewise, after the producer learns that N slots are free, it can fill them
while consulting cachedReadIndex. One cross-core observation can therefore
cover a batch of operations instead of every operation.

A stale snapshot is conservative, which is why this optimization does not
weaken correctness. An old cachedReadIndex may make the producer think the
queue is still full after the consumer has freed space, but it cannot authorize
the producer to overwrite an unread slot. An old cachedWriteIndex may make the
consumer think the queue is still empty after an insertion, but it cannot make
the consumer read an unpublished element. The shared atomic is refreshed only
when the cached value reaches one of those boundaries.

Rigtorp’s C++ implementation also places producer-owned and consumer-owned
indices on separate cache lines. This Swift version uses separately allocated
ManagedAtomic instances, but Swift does not guarantee their cache-line
placement. Index caching still reduces how often the other core reads each
atomic; it just does not provide the C++ implementation’s explicit false-sharing
protection.

Swift object lifetimes inside raw storage

UnsafeMutablePointer<Element>.allocate returns uninitialized memory. This
implementation maintains a strict lifetime cycle for every occupied slot:

1
uninitialized --initialize(to:)--> initialized --move()--> uninitialized

If an emplace closure throws, no element is initialized and the write index is
not published, so the queue remains unchanged. When the queue itself is
destroyed, its deinit walks from the current read index to the write index and
deinitializes any values that remain buffered before deallocating the storage.
Destruction therefore must happen only after the producer and consumer have
stopped.

The class is declared @unchecked Sendable because the compiler cannot prove
these invariants through an unsafe pointer and caller-enforced SPSC ownership.
The annotation is a promise made jointly by the implementation and its users;
it does not turn unsupported access patterns into safe ones.

Part 3: Why the queue is lock-free and does not corrupt its slots

There are two separate questions here:

  1. Does the algorithm require a mutex or make one participant exclusively own
    the entire queue?
  2. Can the producer and consumer observe or modify a slot at the same time?

For this implementation, the answers are “no” and “not when the SPSC contract
is followed.”

The two acquire/release handoffs

Correctness comes from two directional handoffs:

Handoff Before the release Published state After the matching acquire
producer → consumer initialize the element advance writeIndex consumer may move the element
consumer → producer finish moving the element advance readIndex producer may initialize that slot again

A release store prevents the preceding slot access from being reordered after
the index publication. An acquiring load that observes the published index
prevents the following slot access from being reordered before it. Together,
they establish the required cross-thread ordering:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Producer                                Consumer

initialize slot
|
v
release-store writeIndex ----------> acquire-load writeIndex
|
v
move from slot
|
v
acquire-load readIndex <----------- release-store readIndex
|
v
initialize the reused slot

The first edge prevents the consumer from reading partially initialized or
stale storage. The second prevents the producer from reusing the storage before
the consumer has finished moving its value out.

The empty-slot rule supplies the spatial part of the proof: the producer cannot
advance into the consumer’s current slot. Index ownership supplies the update
part: no two threads compete to advance the same index. Acquire/release supplies
the visibility part: a slot crosses from one owner to the other only after its
previous owner’s access is complete.

What “lock-free” means here

No queue operation takes a mutex, and the indices use Swift Atomics, whose
published guarantee is that its atomic operations have lock-free
implementations. tryPush and pop contain bounded Swift-level control flow:
they either complete or report full/empty without waiting for the other side.
The Swift Atomics package does not promise that every underlying atomic is
wait-free on every target, so it is best not to strengthen that claim.

push and emplace need an additional qualification. They loop while the
queue is full:

1
2
3
4
5
6
while true {
if let (slot, nextWrite) = writableSlot() {
// Insert and publish.
return
}
}

If the consumer stops, these calls can spin forever and consume a CPU core.
They do not acquire a lock, but they are waiting operations. For latency-sensitive
or cooperative-concurrency code, prefer tryPush/tryEmplace and choose an
explicit retry, yield, drop, or backpressure policy.

The safety contract is not optional

The queue is race-free only while all of the following remain true:

  • one producer calls push, tryPush, emplace, or tryEmplace;
  • one consumer calls pop;
  • the queue outlives both sides;
  • an element is no longer mutated by the producer after ownership is handed to
    the queue, unless that mutation is independently synchronized;
  • the consumer does not share a popped non-Sendable value unsafely elsewhere.

Two producers can both read the same writeIndex, initialize the same raw
slot, and corrupt its lifetime before either publishes a new index. Atomics do
not repair that protocol violation. The same problem exists for two consumers
moving from one slot.

The generic parameter is not constrained to Sendable. That is useful for
low-level ownership-transfer designs, but it puts more responsibility on the
caller. Prefer Sendable value types. If an element contains a mutable class
reference, enqueuing the reference does not magically synchronize other code
that still holds and mutates it.

count and isEmpty are also observations, not reservations. Their atomic
loads are safe, but the result can become stale immediately. Do not write
“check-then-act” logic such as if !queue.isEmpty { queue.pop()! }; call pop()
and use its result.

How to use SPSCQueue

Until the package has a tagged release, add its main branch to Package.swift:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dependencies: [
.package(
url: "https://github.com/sueLan/swift-spsc.git",
branch: "main"
)
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "SPSCQueue", package: "swift-spsc")
]
)
]

The API has two full-buffer policies:

Operation If full or empty Element construction
push(element) spins while full argument already exists
tryPush(element) returns false if full argument already exists
emplace { ... } spins while full closure runs after space exists
tryEmplace { ... } returns false if full closure is skipped when full
pop() returns nil if empty moves out the oldest element

Here is one producer task and one consumer task using cooperative yielding
instead of a tight busy-spin. An explicit .finished message defines the
stream’s lifetime:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import SPSCQueue

enum Message: Sendable {
case value(Int)
case finished
}

func send(_ message: Message, to queue: SPSCQueue<Message>) async {
while !queue.tryPush(message) {
await Task.yield()
}
}

func produce(into queue: SPSCQueue<Message>) async {
for value in 0..<100_000 {
await send(.value(value), to: queue)
}
await send(.finished, to: queue)
}

func consume(from queue: SPSCQueue<Message>) async -> Int {
var sum = 0

while true {
guard let message = queue.pop() else {
await Task.yield()
continue
}

switch message {
case .value(let value):
sum += value
case .finished:
return sum
}
}
}

func runPipeline() async -> Int {
let queue = SPSCQueue<Message>(capacity: 1_024)

async let producer: Void = produce(into: queue)
async let result = consume(from: queue)

await producer
return await result
}

There is one logical producer and one logical consumer even though Swift may
resume either task on different worker threads. What matters is that calls on
each side never overlap. Task.yield() is a scheduling hint, not a notification
mechanism; workloads that should sleep until data or space arrives need an
external wake-up mechanism or an async channel instead.

Capacity is part of the design, not merely a tuning knob. Size the queue for
the burst the producer may create while the consumer is delayed, then decide
what full means for the application: retry, yield, drop, aggregate, or slow the
producer. A bounded queue makes that overload decision visible rather than
hiding it behind unbounded memory growth.

Conclusion

The ring buffer supplies fixed storage and constant-time movement. The SPSC
restriction gives every index and every slot a clear owner. The release/acquire
pairs transfer those slots between owners in the correct order. Together, those
ideas produce a small, fast queue without a mutex or queue-level per-element
allocation.

The price is a narrow contract: fixed capacity, exactly one producer, exactly
one consumer, deliberate lifecycle management, and an explicit policy for full
and empty states. Within that boundary, the simplicity is the advantage. There
is no contested shared cursor and no ambiguous slot lifetime—only two owners
passing initialized storage back and forth around a ring.

Further reading

scan qr code and share this article