Leka
A low-latency C++20 price-time-priority limit order book and matching engine
▶ Replay viewer
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
6
7#include <array>
8#include <atomic>
9#include <cstddef>
10#include <memory>
11#include <new>
12
13namespace 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 */
51template <std::size_t Capacity>
52class 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. */
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
Type-safe event-first command for the matching engine.
~SpscEventQueue()
Destroys any events left in the queue at the time of destruction.
bool tryPush(const OrderEvent &event)
Producer-only.
bool tryPop(OrderEvent &out)
Consumer-only.
std::size_t sizeApprox() const
Approximate occupancy, for diagnostics only.
Defines event-first order command payloads and dispatch types.