ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
lock_free_queue.h
Go to the documentation of this file.
1#pragma once
2
4
5#include <atomic>
6#include <cstddef>
7
8#ifdef USE_ESP32
9#include <freertos/FreeRTOS.h>
10#include <freertos/task.h>
11#endif
12
13/*
14 * Lock-free queue for single-producer single-consumer scenarios.
15 * This allows one thread to push items and another to pop them without
16 * blocking each other.
17 *
18 * This is a Single-Producer Single-Consumer (SPSC) lock-free ring buffer.
19 * Available on multi-threaded platforms (ESP32, LibreTiny) where another task
20 * produces or consumes, and on single-threaded platforms (RP2) where the
21 * producer runs in interrupt context.
22 *
23 * Common use cases:
24 * - BLE events: BLE task produces, main loop consumes
25 * - MQTT messages: main task produces, MQTT thread consumes
26 *
27 * @tparam T The type of elements stored in the queue (must be a pointer type)
28 * @tparam SIZE The maximum number of elements (1-255, limited by uint8_t indices)
29 */
30
31namespace esphome {
32
33namespace lockfree_internal {
34#if defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) || defined(ESPHOME_THREAD_SINGLE)
35// Platforms where std::atomic RMW operations are unavailable or unnecessary:
36// - ESPHOME_THREAD_MULTI_NO_ATOMICS: cores lacking atomic read-modify-write
37// instructions (currently the ARMv5TE BK72xx SoCs — no LDREX/STREX, no
38// libatomic; other LibreTiny chips such as LN882x/RTL87xx are ARMv7-M and
39// keep std::atomic).
40// - ESPHOME_THREAD_SINGLE: every platform on this model (ESP8266, RP2,
41// nRF52) runs everything on one core (the chip may have more — RP2 is
42// dual-core, but ESPHome and its interrupt producers stay on core 0), so
43// the only possible concurrency is same-core interrupt preemption (on RP2
44// the BTstack packet handler runs in the CYW43 async-context low-priority
45// IRQ on the core that initialized it, core 0). Using plain accesses here
46// also avoids __atomic_* library calls on RP2040 (Cortex-M0+, no
47// LDREX/STREX).
48// For this queue's SPSC contract RMW atomics are not needed: aligned 8/16-bit
49// loads and stores are single instructions on these cores, so torn reads
50// cannot occur, and on a single in-order core a compiler barrier supplies all
51// the acquire/release ordering the algorithm requires. Each index has exactly
52// one writer (head_: consumer, tail_: producer). The dropped counter's
53// increment/exchange pair is not atomic here — a concurrent reset can lose
54// counts — which is acceptable for a diagnostic drop counter.
55#define ESPHOME_LFQ_COMPILER_BARRIER() __asm__ __volatile__("" ::: "memory")
56template<typename T> class PlainAtomic {
57 public:
58 PlainAtomic() = default;
59 constexpr PlainAtomic(T value) : value_(value) {}
60 T load(std::memory_order order = std::memory_order_seq_cst) const {
61 T value = value_;
62 if (order != std::memory_order_relaxed) {
63 ESPHOME_LFQ_COMPILER_BARRIER(); // acquire: later reads may not hoist above this load
64 }
65 return value;
66 }
67 void store(T value, std::memory_order order = std::memory_order_seq_cst) {
68 if (order != std::memory_order_relaxed) {
69 ESPHOME_LFQ_COMPILER_BARRIER(); // release: earlier writes may not sink below this store
70 }
71 value_ = value;
72 }
73 T fetch_add(T amount, std::memory_order /*order*/ = std::memory_order_seq_cst) {
74 T value = value_;
75 value_ = value + amount;
76 return value;
77 }
78 T exchange(T desired, std::memory_order /*order*/ = std::memory_order_seq_cst) {
79 T value = value_;
80 value_ = desired;
81 return value;
82 }
83
84 private:
85 volatile T value_{0};
86};
87template<typename T> using AtomicIndex = PlainAtomic<T>;
88#else
89template<typename T> using AtomicIndex = std::atomic<T>;
90#endif
91} // namespace lockfree_internal
92
93// Base lock-free queue without task notification
94template<class T, uint8_t SIZE> class LockFreeQueue {
95 public:
97
98 bool push(T *element) {
99 bool was_empty;
100 uint8_t old_tail;
101 return push_internal_(element, was_empty, old_tail);
102 }
103
104 protected:
105 // Advance ring buffer index by one, wrapping at SIZE.
106 // Power-of-2 sizes use modulo (compiler emits single mask instruction).
107 // Non-power-of-2 sizes use comparison to avoid expensive multiply-shift sequences.
108 static constexpr uint8_t next_index(uint8_t index) {
109 if constexpr ((SIZE & (SIZE - 1)) == 0) {
110 return (index + 1) % SIZE;
111 } else {
112 uint8_t next = index + 1;
113 if (next >= SIZE) [[unlikely]]
114 next = 0;
115 return next;
116 }
117 }
118
119 // Internal push that reports queue state - for use by derived classes
120 bool push_internal_(T *element, bool &was_empty, uint8_t &old_tail) {
121 if (element == nullptr)
122 return false;
123
124 uint8_t current_tail = tail_.load(std::memory_order_relaxed);
125 uint8_t next_tail = next_index(current_tail);
126
127 // Read head before incrementing tail
128 uint8_t head_before = head_.load(std::memory_order_acquire);
129
130 if (next_tail == head_before) {
131 // Buffer full
132 dropped_count_.fetch_add(1, std::memory_order_relaxed);
133 return false;
134 }
135
136 was_empty = (current_tail == head_before);
137 old_tail = current_tail;
138
139 buffer_[current_tail] = element;
140 tail_.store(next_tail, std::memory_order_release);
141
142 return true;
143 }
144
145 public:
146 T *pop() {
147 uint8_t current_head = head_.load(std::memory_order_relaxed);
148
149 if (current_head == tail_.load(std::memory_order_acquire)) {
150 return nullptr; // Empty
151 }
152
153 T *element = buffer_[current_head];
154 head_.store(next_index(current_head), std::memory_order_release);
155 return element;
156 }
157
158 size_t size() const {
159 uint8_t tail = tail_.load(std::memory_order_acquire);
160 uint8_t head = head_.load(std::memory_order_acquire);
161 if constexpr ((SIZE & (SIZE - 1)) == 0) {
162 return (tail - head + SIZE) % SIZE;
163 } else {
164 int diff = static_cast<int>(tail) - static_cast<int>(head);
165 if (diff < 0)
166 diff += SIZE;
167 return static_cast<size_t>(diff);
168 }
169 }
170
172 // Fast path: relaxed load is a single instruction on all platforms.
173 // The atomic exchange (especially for uint16_t on Xtensa) compiles to
174 // an expensive sub-word CAS retry loop (~25 instructions + memory barriers).
175 // Since drops are rare, avoid the exchange in the common case.
176 if (dropped_count_.load(std::memory_order_relaxed) == 0)
177 return 0;
178 return dropped_count_.exchange(0, std::memory_order_relaxed);
179 }
180
181 void increment_dropped_count() { dropped_count_.fetch_add(1, std::memory_order_relaxed); }
182
183 bool empty() const { return head_.load(std::memory_order_acquire) == tail_.load(std::memory_order_acquire); }
184
185 bool full() const {
186 uint8_t next_tail = next_index(tail_.load(std::memory_order_relaxed));
187 return next_tail == head_.load(std::memory_order_acquire);
188 }
189
190 protected:
191 T *buffer_[SIZE]{};
192 // Atomic: written by producer (push/increment), read+reset by consumer (get_and_reset)
193 lockfree_internal::AtomicIndex<uint16_t> dropped_count_; // 65535 max - more than enough for drop tracking
194 // Atomic: written by consumer (pop), read by producer (push) to check if full
195 // Using uint8_t limits queue size to 255 elements but saves memory and ensures
196 // atomic operations are efficient on all platforms
198 // Atomic: written by producer (push), read by consumer (pop) to check if empty
200};
201
202#ifdef USE_ESP32
203// Extended queue with task notification support
204template<class T, uint8_t SIZE> class NotifyingLockFreeQueue : public LockFreeQueue<T, SIZE> {
205 public:
206 NotifyingLockFreeQueue() : LockFreeQueue<T, SIZE>(), task_to_notify_(nullptr) {}
207
208 bool push(T *element) {
209 bool was_empty;
210 uint8_t old_tail;
211 bool result = this->push_internal_(element, was_empty, old_tail);
212
213 // Notify optimization: only notify if we need to
214 if (result && task_to_notify_ != nullptr &&
215 (was_empty || this->head_.load(std::memory_order_acquire) == old_tail)) {
216 // Notify in two cases:
217 // 1. Queue was empty - consumer might be going to sleep
218 // 2. Consumer just caught up to where tail was - might go to sleep
219 // Note: There's a benign race in case 2 - between reading head and calling
220 // xTaskNotifyGive(), the consumer could advance further. This would result
221 // in an unnecessary wake-up, but is harmless and extremely rare in practice.
222 xTaskNotifyGive(task_to_notify_);
223 }
224 // Otherwise: consumer is still behind, no need to notify
225
226 return result;
227 }
228
229 // Set the FreeRTOS task handle to notify when items are pushed to the queue
230 // This enables efficient wake-up of a consumer task that's waiting for data
231 // @param task The FreeRTOS task handle to notify, or nullptr to disable notifications
232 void set_task_to_notify(TaskHandle_t task) { task_to_notify_ = task; }
233
234 private:
235 TaskHandle_t task_to_notify_;
236};
237#endif
238
239} // namespace esphome
uint16_t get_and_reset_dropped_count()
bool push(T *element)
static constexpr uint8_t next_index(uint8_t index)
lockfree_internal::AtomicIndex< uint8_t > head_
bool push_internal_(T *element, bool &was_empty, uint8_t &old_tail)
lockfree_internal::AtomicIndex< uint16_t > dropped_count_
lockfree_internal::AtomicIndex< uint8_t > tail_
void set_task_to_notify(TaskHandle_t task)
T fetch_add(T amount, std::memory_order=std::memory_order_seq_cst)
T exchange(T desired, std::memory_order=std::memory_order_seq_cst)
T load(std::memory_order order=std::memory_order_seq_cst) const
void store(T value, std::memory_order order=std::memory_order_seq_cst)
NameCopy * next