Leka
A low-latency C++20 price-time-priority limit order book and matching engine
▶ Replay viewer
Toggle main menu visibility
Loading...
Searching...
No Matches
spsc_event_queue.hpp
1
// include/lob/concurrency/spsc_event_queue.hpp
2
#ifndef SPSC_EVENT_QUEUE_HPP
3
#define SPSC_EVENT_QUEUE_HPP
4
5
#include "
lob/order/order_event.hpp
"
6
7
#include <array>
8
#include <atomic>
9
#include <cstddef>
10
#include <memory>
11
#include <new>
12
13
namespace
lob {
14
15
/**
16
* @brief Wait-free single-producer/single-consumer queue of OrderEvent.
17
*
18
* This is the boundary between a feed-handler thread (decoding or generating
19
* events) and the matching thread (calling MatchingEngine::processEvent),
20
* mirroring the two-thread split a real venue-facing system uses instead of
21
* doing both jobs on one thread. See ARCH_DECISIONS.md ADR-009 for why this
22
* shape, and measured cross-thread hand-off latency.
23
*
24
* Producer and consumer must each be called from exactly one thread apiece,
25
* always the SAME thread for the lifetime of the queue — tryPush() from more
26
* than one thread, or tryPop() from more than one thread, is undefined
27
* behavior. That restriction is what makes this SPSC rather than MPMC, and
28
* is also what makes it possible to implement with no compare-and-swap loop
29
* at all: each side has exactly one writer for its own cursor, so a plain
30
* atomic store, correctly ordered, is enough. No lock, no CAS retry loop,
31
* and no blocking: tryPush()/tryPop() either succeed immediately or report
32
* "not right now" and return, which is what "wait-free" means here.
33
*
34
* head_ and tail_ are each on their own 64-byte cache line
35
* (std::hardware_destructive_interference_size on most platforms actually
36
* targeted, but the exact value is not guaranteed portable, so this uses the
37
* conventional 64 directly). Without that padding, the producer's tail_
38
* store and the consumer's head_ store would share a cache line, and every
39
* single push and pop would force that line to bounce between the two
40
* cores' caches (MESI/MOESI invalidation) even though the two threads never
41
* touch the same logical field — "false sharing," and a real, measurable
42
* throughput cost specifically because there IS no lock here to hide it
43
* behind.
44
*
45
* Storage is raw, placement-constructed bytes rather than
46
* std::array<OrderEvent, Capacity>, the same technique OrderPool
47
* (include/lob/book/order_pool.hpp) already uses for Order: OrderEvent has
48
* no default constructor by design (see order_event.hpp), and a
49
* std::array of it would require one.
50
*/
51
template
<std::
size_t
Capacity>
52
class
SpscEventQueue {
53
static_assert
((Capacity & (Capacity - 1)) == 0,
"Capacity must be a power of two"
);
54
static_assert
(Capacity >= 2,
"Capacity must be at least 2"
);
55
56
public
:
57
SpscEventQueue() =
default
;
58
SpscEventQueue(
const
SpscEventQueue&) =
delete
;
59
SpscEventQueue& operator=(
const
SpscEventQueue&) =
delete
;
60
SpscEventQueue(SpscEventQueue&&) =
delete
;
61
SpscEventQueue& operator=(SpscEventQueue&&) =
delete
;
62
63
/** Destroys any events left in the queue at the time of destruction. */
64
~SpscEventQueue
() {
65
std::size_t head = head_.load(std::memory_order_relaxed);
66
const
std::size_t tail = tail_.load(std::memory_order_relaxed);
67
for
(; head != tail; ++head) {
68
std::destroy_at(slot(head));
69
}
70
}
71
72
/**
73
* @brief Producer-only. Appends @p event if the queue is not full.
74
* @return false if the queue was full; @p event is left untouched.
75
*/
76
bool
tryPush
(
const
OrderEvent
& event) {
77
const
std::size_t tail = tail_.load(std::memory_order_relaxed);
78
if
(tail - cachedHead_ >= Capacity) {
79
// Only re-read the consumer's cursor, an inter-core load,
80
// when our own stale copy says we might be full. Once
81
// refreshed, that copy is good for every push up to the
82
// point it was taken, so a producer draining into a
83
// consumer that is comfortably keeping up almost never
84
// pays this load at all.
85
cachedHead_ = head_.load(std::memory_order_acquire);
86
if
(tail - cachedHead_ >= Capacity) {
87
return
false
;
// genuinely full
88
}
89
}
90
std::construct_at(slot(tail), event);
91
// release: publishes both the new tail AND everything written
92
// above it (the constructed OrderEvent) to the consumer's
93
// subsequent acquire load of tail_.
94
tail_.store(tail + 1, std::memory_order_release);
95
return
true
;
96
}
97
98
/**
99
* @brief Consumer-only. Moves the next event into @p out if present.
100
* @return false if the queue was empty; @p out is left untouched.
101
*/
102
bool
tryPop
(
OrderEvent
& out) {
103
const
std::size_t head = head_.load(std::memory_order_relaxed);
104
if
(head == cachedTail_) {
105
cachedTail_ = tail_.load(std::memory_order_acquire);
106
if
(head == cachedTail_) {
107
return
false
;
// genuinely empty
108
}
109
}
110
OrderEvent
* eventSlot = slot(head);
111
out = std::move(*eventSlot);
112
std::destroy_at(eventSlot);
113
// release: publishes the freed slot to the producer's
114
// subsequent acquire load of head_ in tryPush()'s refresh path.
115
head_.store(head + 1, std::memory_order_release);
116
return
true
;
117
}
118
119
/**
120
* @brief Approximate occupancy, for diagnostics only.
121
*
122
* Reads both cursors without synchronizing them against each other,
123
* so the result can be stale or transiently negative-looking
124
* (wrapped) the instant either side is mid-operation. Never use
125
* this to decide whether tryPush()/tryPop() will succeed; call them
126
* and check their return value instead.
127
*/
128
std::size_t
sizeApprox
()
const
{
129
return
tail_.load(std::memory_order_relaxed) - head_.load(std::memory_order_relaxed);
130
}
131
132
static
constexpr
std::size_t capacity() {
return
Capacity; }
133
134
private
:
135
static
constexpr
std::size_t mask_ = Capacity - 1;
136
137
OrderEvent* slot(std::size_t index) {
138
return
std::launder(
reinterpret_cast<
OrderEvent*
>
(
139
&storage_[(index & mask_) *
sizeof
(OrderEvent)]));
140
}
141
142
// Every member below is deliberately on its own cache line. head_
143
// is written only by the consumer and read by the producer; tail_
144
// is written only by the producer and read by the consumer;
145
// cachedHead_ and cachedTail_ are each private to one side and
146
// never touched by the other thread at all, but still get their
147
// own line so that a write to one never invalidates a neighbor a
148
// future change might otherwise pack in beside it.
149
alignas
(64) std::atomic<std::size_t> head_{0};
150
alignas
(64) std::atomic<std::size_t> tail_{0};
151
alignas
(64) std::size_t cachedHead_{0};
// producer-private view of head_
152
alignas
(64) std::size_t cachedTail_{0};
// consumer-private view of tail_
153
alignas
(
alignof
(OrderEvent)) std::array<std::byte, Capacity *
sizeof
(OrderEvent)> storage_;
154
};
155
156
}
// namespace lob
157
158
#endif
// SPSC_EVENT_QUEUE_HPP
lob::OrderEvent
Type-safe event-first command for the matching engine.
Definition
order_event.hpp:77
lob::SpscEventQueue::~SpscEventQueue
~SpscEventQueue()
Destroys any events left in the queue at the time of destruction.
Definition
spsc_event_queue.hpp:64
lob::SpscEventQueue::tryPush
bool tryPush(const OrderEvent &event)
Producer-only.
Definition
spsc_event_queue.hpp:76
lob::SpscEventQueue::tryPop
bool tryPop(OrderEvent &out)
Consumer-only.
Definition
spsc_event_queue.hpp:102
lob::SpscEventQueue::sizeApprox
std::size_t sizeApprox() const
Approximate occupancy, for diagnostics only.
Definition
spsc_event_queue.hpp:128
order_event.hpp
Defines event-first order command payloads and dispatch types.
include
lob
concurrency
spsc_event_queue.hpp
Generated by
1.18.0