ESPHome 2026.1.2
Loading...
Searching...
No Matches
scheduler.cpp
Go to the documentation of this file.
1#include "scheduler.h"
2
3#include "application.h"
5#include "esphome/core/hal.h"
7#include "esphome/core/log.h"
9#include <algorithm>
10#include <cinttypes>
11#include <cstring>
12#include <limits>
13
14namespace esphome {
15
16static const char *const TAG = "scheduler";
17
18// Memory pool configuration constants
19// Pool size of 5 matches typical usage patterns (2-4 active timers)
20// - Minimal memory overhead (~250 bytes on ESP32)
21// - Sufficient for most configs with a couple sensors/components
22// - Still prevents heap fragmentation and allocation stalls
23// - Complex setups with many timers will just allocate beyond the pool
24// See https://github.com/esphome/backlog/issues/52
25static constexpr size_t MAX_POOL_SIZE = 5;
26
27// Maximum number of logically deleted (cancelled) items before forcing cleanup.
28// Set to 5 to match the pool size - when we have as many cancelled items as our
29// pool can hold, it's time to clean up and recycle them.
30static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5;
31// Half the 32-bit range - used to detect rollovers vs normal time progression
32static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits<uint32_t>::max() / 2;
33// max delay to start an interval sequence
34static constexpr uint32_t MAX_INTERVAL_DELAY = 5000;
35
36#if defined(ESPHOME_LOG_HAS_VERBOSE) || defined(ESPHOME_DEBUG_SCHEDULER)
37// Helper struct for formatting scheduler item names consistently in logs
38// Uses a stack buffer to avoid heap allocation
39// Uses ESPHOME_snprintf_P/ESPHOME_PSTR for ESP8266 to keep format strings in flash
40struct SchedulerNameLog {
41 char buffer[20]; // Enough for "id:4294967295" or "hash:0xFFFFFFFF" or "(null)"
42
43 // Format a scheduler item name for logging
44 // Returns pointer to formatted string (either static_name or internal buffer)
45 const char *format(Scheduler::NameType name_type, const char *static_name, uint32_t hash_or_id) {
46 using NameType = Scheduler::NameType;
47 if (name_type == NameType::STATIC_STRING) {
48 if (static_name)
49 return static_name;
50 // Copy "(null)" to buffer to keep it in flash on ESP8266
51 ESPHOME_strncpy_P(buffer, ESPHOME_PSTR("(null)"), sizeof(buffer));
52 return buffer;
53 } else if (name_type == NameType::HASHED_STRING) {
54 ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("hash:0x%08" PRIX32), hash_or_id);
55 return buffer;
56 } else { // NUMERIC_ID
57 ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("id:%" PRIu32), hash_or_id);
58 return buffer;
59 }
60 }
61};
62#endif
63
64// Uncomment to debug scheduler
65// #define ESPHOME_DEBUG_SCHEDULER
66
67#ifdef ESPHOME_DEBUG_SCHEDULER
68// Helper to validate that a pointer looks like it's in static memory
69static void validate_static_string(const char *name) {
70 if (name == nullptr)
71 return;
72
73 // This is a heuristic check - stack and heap pointers are typically
74 // much higher in memory than static data
75 uintptr_t addr = reinterpret_cast<uintptr_t>(name);
76
77 // Create a stack variable to compare against
78 int stack_var;
79 uintptr_t stack_addr = reinterpret_cast<uintptr_t>(&stack_var);
80
81 // If the string pointer is near our stack variable, it's likely on the stack
82 // Using 8KB range as ESP32 main task stack is typically 8192 bytes
83 if (addr > (stack_addr - 0x2000) && addr < (stack_addr + 0x2000)) {
84 ESP_LOGW(TAG,
85 "WARNING: Scheduler name '%s' at %p appears to be on the stack - this is unsafe!\n"
86 " Stack reference at %p",
87 name, name, &stack_var);
88 }
89
90 // Also check if it might be on the heap by seeing if it's in a very different range
91 // This is platform-specific but generally heap is allocated far from static memory
92 static const char *static_str = "test";
93 uintptr_t static_addr = reinterpret_cast<uintptr_t>(static_str);
94
95 // If the address is very far from known static memory, it might be heap
96 if (addr > static_addr + 0x100000 || (static_addr > 0x100000 && addr < static_addr - 0x100000)) {
97 ESP_LOGW(TAG, "WARNING: Scheduler name '%s' at %p might be on heap (static ref at %p)", name, name, static_str);
98 }
99}
100#endif /* ESPHOME_DEBUG_SCHEDULER */
101
102// A note on locking: the `lock_` lock protects the `items_` and `to_add_` containers. It must be taken when writing to
103// them (i.e. when adding/removing items, but not when changing items). As items are only deleted from the loop task,
104// iterating over them from the loop task is fine; but iterating from any other context requires the lock to be held to
105// avoid the main thread modifying the list while it is being accessed.
106
107// Common implementation for both timeout and interval
108// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
109void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type,
110 const char *static_name, uint32_t hash_or_id, uint32_t delay,
111 std::function<void()> func, bool is_retry, bool skip_cancel) {
112 if (delay == SCHEDULER_DONT_RUN) {
113 // Still need to cancel existing timer if we have a name/id
114 if (!skip_cancel) {
115 LockGuard guard{this->lock_};
116 this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type);
117 }
118 return;
119 }
120
121 // Get fresh timestamp BEFORE taking lock - millis_64_ may need to acquire lock itself
122 const uint64_t now = this->millis_64_(millis());
123
124 // Take lock early to protect scheduler_item_pool_ access
125 LockGuard guard{this->lock_};
126
127 // Create and populate the scheduler item
128 auto item = this->get_item_from_pool_locked_();
129 item->component = component;
130 switch (name_type) {
131 case NameType::STATIC_STRING:
132 item->set_static_name(static_name);
133 break;
134 case NameType::HASHED_STRING:
135 item->set_hashed_name(hash_or_id);
136 break;
137 case NameType::NUMERIC_ID:
138 item->set_numeric_id(hash_or_id);
139 break;
140 }
141 item->type = type;
142 item->callback = std::move(func);
143 // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use
144 this->set_item_removed_(item.get(), false);
145 item->is_retry = is_retry;
146
147#ifndef ESPHOME_THREAD_SINGLE
148 // Special handling for defer() (delay = 0, type = TIMEOUT)
149 // Single-core platforms don't need thread-safe defer handling
150 if (delay == 0 && type == SchedulerItem::TIMEOUT) {
151 // Put in defer queue for guaranteed FIFO execution
152 if (!skip_cancel) {
153 this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type);
154 }
155 this->defer_queue_.push_back(std::move(item));
156 return;
157 }
158#endif /* not ESPHOME_THREAD_SINGLE */
159
160 // Type-specific setup
161 if (type == SchedulerItem::INTERVAL) {
162 item->interval = delay;
163 // first execution happens immediately after a random smallish offset
164 // Calculate random offset (0 to min(interval/2, 5s))
165 uint32_t offset = (uint32_t) (std::min(delay / 2, MAX_INTERVAL_DELAY) * random_float());
166 item->set_next_execution(now + offset);
167#ifdef ESPHOME_LOG_HAS_VERBOSE
168 SchedulerNameLog name_log;
169 ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms",
170 name_log.format(name_type, static_name, hash_or_id), delay, offset);
171#endif
172 } else {
173 item->interval = 0;
174 item->set_next_execution(now + delay);
175 }
176
177#ifdef ESPHOME_DEBUG_SCHEDULER
178 this->debug_log_timer_(item.get(), name_type, static_name, hash_or_id, type, delay, now);
179#endif /* ESPHOME_DEBUG_SCHEDULER */
180
181 // For retries, check if there's a cancelled timeout first
182 // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name
183 if (is_retry && (name_type != NameType::STATIC_STRING || static_name != nullptr) && type == SchedulerItem::TIMEOUT &&
184 (has_cancelled_timeout_in_container_locked_(this->items_, component, name_type, static_name, hash_or_id,
185 /* match_retry= */ true) ||
186 has_cancelled_timeout_in_container_locked_(this->to_add_, component, name_type, static_name, hash_or_id,
187 /* match_retry= */ true))) {
188 // Skip scheduling - the retry was cancelled
189#ifdef ESPHOME_DEBUG_SCHEDULER
190 SchedulerNameLog skip_name_log;
191 ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item",
192 skip_name_log.format(name_type, static_name, hash_or_id));
193#endif
194 return;
195 }
196
197 // If name is provided, do atomic cancel-and-add (unless skip_cancel is true)
198 // Cancel existing items
199 if (!skip_cancel) {
200 this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type);
201 }
202 // Add new item directly to to_add_
203 // since we have the lock held
204 this->to_add_.push_back(std::move(item));
205}
206
207void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, std::function<void()> func) {
208 this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::STATIC_STRING, name, 0, timeout,
209 std::move(func));
210}
211
212void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout,
213 std::function<void()> func) {
214 this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name),
215 timeout, std::move(func));
216}
217void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function<void()> func) {
218 this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout,
219 std::move(func));
220}
221bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) {
222 return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT);
223}
224bool HOT Scheduler::cancel_timeout(Component *component, const char *name) {
225 return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT);
226}
227bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) {
228 return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT);
229}
230void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval,
231 std::function<void()> func) {
232 this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name),
233 interval, std::move(func));
234}
235
236void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval,
237 std::function<void()> func) {
238 this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval,
239 std::move(func));
240}
241void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t interval, std::function<void()> func) {
242 this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval,
243 std::move(func));
244}
245bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) {
246 return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL);
247}
248bool HOT Scheduler::cancel_interval(Component *component, const char *name) {
249 return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL);
250}
251bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) {
252 return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL);
253}
254
255struct RetryArgs {
256 // Ordered to minimize padding on 32-bit systems
257 std::function<RetryResult(uint8_t)> func;
258 Component *component;
259 Scheduler *scheduler;
260 // Union for name storage - only one is used based on name_type
261 union {
262 const char *static_name; // For STATIC_STRING
263 uint32_t hash_or_id; // For HASHED_STRING or NUMERIC_ID
264 } name_;
265 uint32_t current_interval;
266 float backoff_increase_factor;
267 Scheduler::NameType name_type; // Discriminator for name_ union
268 uint8_t retry_countdown;
269};
270
271void retry_handler(const std::shared_ptr<RetryArgs> &args) {
272 RetryResult const retry_result = args->func(--args->retry_countdown);
273 if (retry_result == RetryResult::DONE || args->retry_countdown <= 0)
274 return;
275 // second execution of `func` happens after `initial_wait_time`
276 // args->name_ is owned by the shared_ptr<RetryArgs>
277 // which is captured in the lambda and outlives the SchedulerItem
278 const char *static_name = (args->name_type == Scheduler::NameType::STATIC_STRING) ? args->name_.static_name : nullptr;
279 uint32_t hash_or_id = (args->name_type != Scheduler::NameType::STATIC_STRING) ? args->name_.hash_or_id : 0;
280 args->scheduler->set_timer_common_(
281 args->component, Scheduler::SchedulerItem::TIMEOUT, args->name_type, static_name, hash_or_id,
282 args->current_interval, [args]() { retry_handler(args); },
283 /* is_retry= */ true);
284 // backoff_increase_factor applied to third & later executions
285 args->current_interval *= args->backoff_increase_factor;
286}
287
288void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, const char *static_name,
289 uint32_t hash_or_id, uint32_t initial_wait_time, uint8_t max_attempts,
290 std::function<RetryResult(uint8_t)> func, float backoff_increase_factor) {
291 this->cancel_retry_(component, name_type, static_name, hash_or_id);
292
293 if (initial_wait_time == SCHEDULER_DONT_RUN)
294 return;
295
296#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
297 {
298 SchedulerNameLog name_log;
299 ESP_LOGVV(TAG, "set_retry(name='%s', initial_wait_time=%" PRIu32 ", max_attempts=%u, backoff_factor=%0.1f)",
300 name_log.format(name_type, static_name, hash_or_id), initial_wait_time, max_attempts,
301 backoff_increase_factor);
302 }
303#endif
304
305 if (backoff_increase_factor < 0.0001) {
306 ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor,
307 (name_type == NameType::STATIC_STRING && static_name) ? static_name : "");
308 backoff_increase_factor = 1;
309 }
310
311 auto args = std::make_shared<RetryArgs>();
312 args->func = std::move(func);
313 args->component = component;
314 args->scheduler = this;
315 args->name_type = name_type;
316 if (name_type == NameType::STATIC_STRING) {
317 args->name_.static_name = static_name;
318 } else {
319 args->name_.hash_or_id = hash_or_id;
320 }
321 args->current_interval = initial_wait_time;
322 args->backoff_increase_factor = backoff_increase_factor;
323 args->retry_countdown = max_attempts;
324
325 // First execution of `func` immediately - use set_timer_common_ with is_retry=true
326 this->set_timer_common_(
327 component, SchedulerItem::TIMEOUT, name_type, static_name, hash_or_id, 0, [args]() { retry_handler(args); },
328 /* is_retry= */ true);
329}
330
331void HOT Scheduler::set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts,
332 std::function<RetryResult(uint8_t)> func, float backoff_increase_factor) {
333 this->set_retry_common_(component, NameType::STATIC_STRING, name, 0, initial_wait_time, max_attempts, std::move(func),
334 backoff_increase_factor);
335}
336
337bool HOT Scheduler::cancel_retry_(Component *component, NameType name_type, const char *static_name,
338 uint32_t hash_or_id) {
339 return this->cancel_item_(component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT,
340 /* match_retry= */ true);
341}
342bool HOT Scheduler::cancel_retry(Component *component, const char *name) {
343 return this->cancel_retry_(component, NameType::STATIC_STRING, name, 0);
344}
345
346void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time,
347 uint8_t max_attempts, std::function<RetryResult(uint8_t)> func,
348 float backoff_increase_factor) {
349 this->set_retry_common_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), initial_wait_time,
350 max_attempts, std::move(func), backoff_increase_factor);
351}
352
353bool HOT Scheduler::cancel_retry(Component *component, const std::string &name) {
354 return this->cancel_retry_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name));
355}
356
357void HOT Scheduler::set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts,
358 std::function<RetryResult(uint8_t)> func, float backoff_increase_factor) {
359 this->set_retry_common_(component, NameType::NUMERIC_ID, nullptr, id, initial_wait_time, max_attempts,
360 std::move(func), backoff_increase_factor);
361}
362
363bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) {
364 return this->cancel_retry_(component, NameType::NUMERIC_ID, nullptr, id);
365}
366
367optional<uint32_t> HOT Scheduler::next_schedule_in(uint32_t now) {
368 // IMPORTANT: This method should only be called from the main thread (loop task).
369 // It performs cleanup and accesses items_[0] without holding a lock, which is only
370 // safe when called from the main thread. Other threads must not call this method.
371
372 // If no items, return empty optional
373 if (this->cleanup_() == 0)
374 return {};
375
376 auto &item = this->items_[0];
377 // Convert the fresh timestamp from caller (usually Application::loop()) to 64-bit
378 const auto now_64 = this->millis_64_(now); // 'now' from parameter - fresh from caller
379 const uint64_t next_exec = item->get_next_execution();
380 if (next_exec < now_64)
381 return 0;
382 return next_exec - now_64;
383}
384
385void Scheduler::full_cleanup_removed_items_() {
386 // We hold the lock for the entire cleanup operation because:
387 // 1. We're rebuilding the entire items_ list, so we need exclusive access throughout
388 // 2. Other threads must see either the old state or the new state, not intermediate states
389 // 3. The operation is already expensive (O(n)), so lock overhead is negligible
390 // 4. No operations inside can block or take other locks, so no deadlock risk
391 LockGuard guard{this->lock_};
392
393 std::vector<std::unique_ptr<SchedulerItem>> valid_items;
394
395 // Move all non-removed items to valid_items, recycle removed ones
396 for (auto &item : this->items_) {
397 if (!is_item_removed_(item.get())) {
398 valid_items.push_back(std::move(item));
399 } else {
400 // Recycle removed items
401 this->recycle_item_main_loop_(std::move(item));
402 }
403 }
404
405 // Replace items_ with the filtered list
406 this->items_ = std::move(valid_items);
407 // Rebuild the heap structure since items are no longer in heap order
408 std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
409 this->to_remove_ = 0;
410}
411
412void HOT Scheduler::call(uint32_t now) {
413#ifndef ESPHOME_THREAD_SINGLE
414 this->process_defer_queue_(now);
415#endif /* not ESPHOME_THREAD_SINGLE */
416
417 // Convert the fresh timestamp from main loop to 64-bit for scheduler operations
418 const auto now_64 = this->millis_64_(now); // 'now' from parameter - fresh from Application::loop()
419 this->process_to_add();
420
421 // Track if any items were added to to_add_ during this call (intervals or from callbacks)
422 bool has_added_items = false;
423
424#ifdef ESPHOME_DEBUG_SCHEDULER
425 static uint64_t last_print = 0;
426
427 if (now_64 - last_print > 2000) {
428 last_print = now_64;
429 std::vector<std::unique_ptr<SchedulerItem>> old_items;
430#ifdef ESPHOME_THREAD_MULTI_ATOMICS
431 const auto last_dbg = this->last_millis_.load(std::memory_order_relaxed);
432 const auto major_dbg = this->millis_major_.load(std::memory_order_relaxed);
433 ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(),
434 this->scheduler_item_pool_.size(), now_64, major_dbg, last_dbg);
435#else /* not ESPHOME_THREAD_MULTI_ATOMICS */
436 ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(),
437 this->scheduler_item_pool_.size(), now_64, this->millis_major_, this->last_millis_);
438#endif /* else ESPHOME_THREAD_MULTI_ATOMICS */
439 // Cleanup before debug output
440 this->cleanup_();
441 while (!this->items_.empty()) {
442 std::unique_ptr<SchedulerItem> item;
443 {
444 LockGuard guard{this->lock_};
445 item = this->pop_raw_locked_();
446 }
447
448 SchedulerNameLog name_log;
449 bool is_cancelled = is_item_removed_(item.get());
450 ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s",
451 item->get_type_str(), LOG_STR_ARG(item->get_source()),
452 name_log.format(item->get_name_type(), item->get_name(), item->get_name_hash_or_id()), item->interval,
453 item->get_next_execution() - now_64, item->get_next_execution(), is_cancelled ? " [CANCELLED]" : "");
454
455 old_items.push_back(std::move(item));
456 }
457 ESP_LOGD(TAG, "\n");
458
459 {
460 LockGuard guard{this->lock_};
461 this->items_ = std::move(old_items);
462 // Rebuild heap after moving items back
463 std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
464 }
465 }
466#endif /* ESPHOME_DEBUG_SCHEDULER */
467
468 // Cleanup removed items before processing
469 // First try to clean items from the top of the heap (fast path)
470 this->cleanup_();
471
472 // If we still have too many cancelled items, do a full cleanup
473 // This only happens if cancelled items are stuck in the middle/bottom of the heap
474 if (this->to_remove_ >= MAX_LOGICALLY_DELETED_ITEMS) {
475 this->full_cleanup_removed_items_();
476 }
477 while (!this->items_.empty()) {
478 // Don't copy-by value yet
479 auto &item = this->items_[0];
480 if (item->get_next_execution() > now_64) {
481 // Not reached timeout yet, done for this call
482 break;
483 }
484 // Don't run on failed components
485 if (item->component != nullptr && item->component->is_failed()) {
486 LockGuard guard{this->lock_};
487 this->recycle_item_main_loop_(this->pop_raw_locked_());
488 continue;
489 }
490
491 // Check if item is marked for removal
492 // This handles two cases:
493 // 1. Item was marked for removal after cleanup_() but before we got here
494 // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_()
495#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS
496 // Multi-threaded platforms without atomics: must take lock to safely read remove flag
497 {
498 LockGuard guard{this->lock_};
499 if (is_item_removed_(item.get())) {
500 this->recycle_item_main_loop_(this->pop_raw_locked_());
501 this->to_remove_--;
502 continue;
503 }
504 }
505#else
506 // Single-threaded or multi-threaded with atomics: can check without lock
507 if (is_item_removed_(item.get())) {
508 LockGuard guard{this->lock_};
509 this->recycle_item_main_loop_(this->pop_raw_locked_());
510 this->to_remove_--;
511 continue;
512 }
513#endif
514
515#ifdef ESPHOME_DEBUG_SCHEDULER
516 {
517 SchedulerNameLog name_log;
518 ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")",
519 item->get_type_str(), LOG_STR_ARG(item->get_source()),
520 name_log.format(item->get_name_type(), item->get_name(), item->get_name_hash_or_id()), item->interval,
521 item->get_next_execution(), now_64);
522 }
523#endif /* ESPHOME_DEBUG_SCHEDULER */
524
525 // Warning: During callback(), a lot of stuff can happen, including:
526 // - timeouts/intervals get added, potentially invalidating vector pointers
527 // - timeouts/intervals get cancelled
528 now = this->execute_item_(item.get(), now);
529
530 LockGuard guard{this->lock_};
531
532 // Only pop after function call, this ensures we were reachable
533 // during the function call and know if we were cancelled.
534 auto executed_item = this->pop_raw_locked_();
535
536 if (executed_item->remove) {
537 // We were removed/cancelled in the function call, recycle and continue
538 this->to_remove_--;
539 this->recycle_item_main_loop_(std::move(executed_item));
540 continue;
541 }
542
543 if (executed_item->type == SchedulerItem::INTERVAL) {
544 executed_item->set_next_execution(now_64 + executed_item->interval);
545 // Add new item directly to to_add_
546 // since we have the lock held
547 this->to_add_.push_back(std::move(executed_item));
548 } else {
549 // Timeout completed - recycle it
550 this->recycle_item_main_loop_(std::move(executed_item));
551 }
552
553 has_added_items |= !this->to_add_.empty();
554 }
555
556 if (has_added_items) {
557 this->process_to_add();
558 }
559}
560void HOT Scheduler::process_to_add() {
561 LockGuard guard{this->lock_};
562 for (auto &it : this->to_add_) {
563 if (is_item_removed_(it.get())) {
564 // Recycle cancelled items
565 this->recycle_item_main_loop_(std::move(it));
566 continue;
567 }
568
569 this->items_.push_back(std::move(it));
570 std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
571 }
572 this->to_add_.clear();
573}
574size_t HOT Scheduler::cleanup_() {
575 // Fast path: if nothing to remove, just return the current size
576 // Reading to_remove_ without lock is safe because:
577 // 1. We only call this from the main thread during call()
578 // 2. If it's 0, there's definitely nothing to cleanup
579 // 3. If it becomes non-zero after we check, cleanup will happen on the next loop iteration
580 // 4. Not all platforms support atomics, so we accept this race in favor of performance
581 // 5. The worst case is a one-loop-iteration delay in cleanup, which is harmless
582 if (this->to_remove_ == 0)
583 return this->items_.size();
584
585 // We must hold the lock for the entire cleanup operation because:
586 // 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access
587 // 2. We're decrementing to_remove_ which is also modified by other threads
588 // (though all modifications are already under lock)
589 // 3. Other threads read items_ when searching for items to cancel in cancel_item_locked_()
590 // 4. We need a consistent view of items_ and to_remove_ throughout the operation
591 // Without the lock, we could access items_ while another thread is reading it,
592 // leading to race conditions
593 LockGuard guard{this->lock_};
594 while (!this->items_.empty()) {
595 auto &item = this->items_[0];
596 if (!item->remove)
597 break;
598 this->to_remove_--;
599 this->recycle_item_main_loop_(this->pop_raw_locked_());
600 }
601 return this->items_.size();
602}
603std::unique_ptr<Scheduler::SchedulerItem> HOT Scheduler::pop_raw_locked_() {
604 std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
605
606 // Move the item out before popping - this is the item that was at the front of the heap
607 auto item = std::move(this->items_.back());
608
609 this->items_.pop_back();
610 return item;
611}
612
613// Helper to execute a scheduler item
614uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
615 App.set_current_component(item->component);
616 WarnIfComponentBlockingGuard guard{item->component, now};
617 item->callback();
618 return guard.finish();
619}
620
621// Common implementation for cancel operations - handles locking
622bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id,
623 SchedulerItem::Type type, bool match_retry) {
624 LockGuard guard{this->lock_};
625 return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, match_retry);
626}
627
628// Helper to cancel items - must be called with lock held
629// name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id
630bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type, const char *static_name,
631 uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry) {
632 // Early return if static string name is invalid
633 if (name_type == NameType::STATIC_STRING && static_name == nullptr) {
634 return false;
635 }
636
637 size_t total_cancelled = 0;
638
639#ifndef ESPHOME_THREAD_SINGLE
640 // Mark items in defer queue as cancelled (they'll be skipped when processed)
641 if (type == SchedulerItem::TIMEOUT) {
642 total_cancelled += this->mark_matching_items_removed_locked_(this->defer_queue_, component, name_type, static_name,
643 hash_or_id, type, match_retry);
644 }
645#endif /* not ESPHOME_THREAD_SINGLE */
646
647 // Cancel items in the main heap
648 // We only mark items for removal here - never recycle directly.
649 // The main loop may be executing an item's callback right now, and recycling
650 // would destroy the callback while it's running (use-after-free).
651 // Only the main loop in call() should recycle items after execution completes.
652 if (!this->items_.empty()) {
653 size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name,
654 hash_or_id, type, match_retry);
655 total_cancelled += heap_cancelled;
656 this->to_remove_ += heap_cancelled;
657 }
658
659 // Cancel items in to_add_
660 total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_type, static_name,
661 hash_or_id, type, match_retry);
662
663 return total_cancelled > 0;
664}
665
666uint64_t Scheduler::millis_64_(uint32_t now) {
667 // THREAD SAFETY NOTE:
668 // This function has three implementations, based on the precompiler flags
669 // - ESPHOME_THREAD_SINGLE - Runs on single-threaded platforms (ESP8266, RP2040, etc.)
670 // - ESPHOME_THREAD_MULTI_NO_ATOMICS - Runs on multi-threaded platforms without atomics (LibreTiny BK72xx)
671 // - ESPHOME_THREAD_MULTI_ATOMICS - Runs on multi-threaded platforms with atomics (ESP32, HOST, LibreTiny
672 // RTL87xx/LN882x, etc.)
673 //
674 // Make sure all changes are synchronized if you edit this function.
675 //
676 // IMPORTANT: Always pass fresh millis() values to this function. The implementation
677 // handles out-of-order timestamps between threads, but minimizing time differences
678 // helps maintain accuracy.
679 //
680
681#ifdef ESPHOME_THREAD_SINGLE
682 // This is the single core implementation.
683 //
684 // Single-core platforms have no concurrency, so this is a simple implementation
685 // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics.
686
687 uint16_t major = this->millis_major_;
688 uint32_t last = this->last_millis_;
689
690 // Check for rollover
691 if (now < last && (last - now) > HALF_MAX_UINT32) {
692 this->millis_major_++;
693 major++;
694 this->last_millis_ = now;
695#ifdef ESPHOME_DEBUG_SCHEDULER
696 ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last);
697#endif /* ESPHOME_DEBUG_SCHEDULER */
698 } else if (now > last) {
699 // Only update if time moved forward
700 this->last_millis_ = now;
701 }
702
703 // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time
704 return now + (static_cast<uint64_t>(major) << 32);
705
706#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
707 // This is the multi core no atomics implementation.
708 //
709 // Without atomics, this implementation uses locks more aggressively:
710 // 1. Always locks when near the rollover boundary (within 10 seconds)
711 // 2. Always locks when detecting a large backwards jump
712 // 3. Updates without lock in normal forward progression (accepting minor races)
713 // This is less efficient but necessary without atomic operations.
714 uint16_t major = this->millis_major_;
715 uint32_t last = this->last_millis_;
716
717 // Define a safe window around the rollover point (10 seconds)
718 // This covers any reasonable scheduler delays or thread preemption
719 static const uint32_t ROLLOVER_WINDOW = 10000; // 10 seconds in milliseconds
720
721 // Check if we're near the rollover boundary (close to std::numeric_limits<uint32_t>::max() or just past 0)
722 bool near_rollover = (last > (std::numeric_limits<uint32_t>::max() - ROLLOVER_WINDOW)) || (now < ROLLOVER_WINDOW);
723
724 if (near_rollover || (now < last && (last - now) > HALF_MAX_UINT32)) {
725 // Near rollover or detected a rollover - need lock for safety
726 LockGuard guard{this->lock_};
727 // Re-read with lock held
728 last = this->last_millis_;
729
730 if (now < last && (last - now) > HALF_MAX_UINT32) {
731 // True rollover detected (happens every ~49.7 days)
732 this->millis_major_++;
733 major++;
734#ifdef ESPHOME_DEBUG_SCHEDULER
735 ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last);
736#endif /* ESPHOME_DEBUG_SCHEDULER */
737 }
738 // Update last_millis_ while holding lock
739 this->last_millis_ = now;
740 } else if (now > last) {
741 // Normal case: Not near rollover and time moved forward
742 // Update without lock. While this may cause minor races (microseconds of
743 // backwards time movement), they're acceptable because:
744 // 1. The scheduler operates at millisecond resolution, not microsecond
745 // 2. We've already prevented the critical rollover race condition
746 // 3. Any backwards movement is orders of magnitude smaller than scheduler delays
747 this->last_millis_ = now;
748 }
749 // If now <= last and we're not near rollover, don't update
750 // This minimizes backwards time movement
751
752 // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time
753 return now + (static_cast<uint64_t>(major) << 32);
754
755#elif defined(ESPHOME_THREAD_MULTI_ATOMICS)
756 // This is the multi core with atomics implementation.
757 //
758 // Uses atomic operations with acquire/release semantics to ensure coherent
759 // reads of millis_major_ and last_millis_ across cores. Features:
760 // 1. Epoch-coherency retry loop to handle concurrent updates
761 // 2. Lock only taken for actual rollover detection and update
762 // 3. Lock-free CAS updates for normal forward time progression
763 // 4. Memory ordering ensures cores see consistent time values
764
765 for (;;) {
766 uint16_t major = this->millis_major_.load(std::memory_order_acquire);
767
768 /*
769 * Acquire so that if we later decide **not** to take the lock we still
770 * observe a `millis_major_` value coherent with the loaded `last_millis_`.
771 * The acquire load ensures any later read of `millis_major_` sees its
772 * corresponding increment.
773 */
774 uint32_t last = this->last_millis_.load(std::memory_order_acquire);
775
776 // If we might be near a rollover (large backwards jump), take the lock for the entire operation
777 // This ensures rollover detection and last_millis_ update are atomic together
778 if (now < last && (last - now) > HALF_MAX_UINT32) {
779 // Potential rollover - need lock for atomic rollover detection + update
780 LockGuard guard{this->lock_};
781 // Re-read with lock held; mutex already provides ordering
782 last = this->last_millis_.load(std::memory_order_relaxed);
783
784 if (now < last && (last - now) > HALF_MAX_UINT32) {
785 // True rollover detected (happens every ~49.7 days)
786 this->millis_major_.fetch_add(1, std::memory_order_relaxed);
787 major++;
788#ifdef ESPHOME_DEBUG_SCHEDULER
789 ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last);
790#endif /* ESPHOME_DEBUG_SCHEDULER */
791 }
792 /*
793 * Update last_millis_ while holding the lock to prevent races
794 * Publish the new low-word *after* bumping `millis_major_` (done above)
795 * so readers never see a mismatched pair.
796 */
797 this->last_millis_.store(now, std::memory_order_release);
798 } else {
799 // Normal case: Try lock-free update, but only allow forward movement within same epoch
800 // This prevents accidentally moving backwards across a rollover boundary
801 while (now > last && (now - last) < HALF_MAX_UINT32) {
802 if (this->last_millis_.compare_exchange_weak(last, now,
803 std::memory_order_release, // success
804 std::memory_order_relaxed)) { // failure
805 break;
806 }
807 // CAS failure means no data was published; relaxed is fine
808 // last is automatically updated by compare_exchange_weak if it fails
809 }
810 }
811 uint16_t major_end = this->millis_major_.load(std::memory_order_relaxed);
812 if (major_end == major)
813 return now + (static_cast<uint64_t>(major) << 32);
814 }
815 // Unreachable - the loop always returns when major_end == major
816 __builtin_unreachable();
817
818#else
819#error \
820 "No platform threading model defined. One of ESPHOME_THREAD_SINGLE, ESPHOME_THREAD_MULTI_NO_ATOMICS, or ESPHOME_THREAD_MULTI_ATOMICS must be defined."
821#endif
822}
823
824bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr<SchedulerItem> &a,
825 const std::unique_ptr<SchedulerItem> &b) {
826 // High bits are almost always equal (change only on 32-bit rollover ~49 days)
827 // Optimize for common case: check low bits first when high bits are equal
828 return (a->next_execution_high_ == b->next_execution_high_) ? (a->next_execution_low_ > b->next_execution_low_)
829 : (a->next_execution_high_ > b->next_execution_high_);
830}
831
832// Recycle a SchedulerItem back to the pool for reuse.
833// IMPORTANT: Caller must hold the scheduler lock before calling this function.
834// This protects scheduler_item_pool_ from concurrent access by other threads
835// that may be acquiring items from the pool in set_timer_common_().
836void Scheduler::recycle_item_main_loop_(std::unique_ptr<SchedulerItem> item) {
837 if (!item)
838 return;
839
840 if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) {
841 // Clear callback to release captured resources
842 item->callback = nullptr;
843 this->scheduler_item_pool_.push_back(std::move(item));
844#ifdef ESPHOME_DEBUG_SCHEDULER
845 ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size());
846#endif
847 } else {
848#ifdef ESPHOME_DEBUG_SCHEDULER
849 ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size());
850#endif
851 }
852 // else: unique_ptr will delete the item when it goes out of scope
853}
854
855#ifdef ESPHOME_DEBUG_SCHEDULER
856void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type, const char *static_name,
857 uint32_t hash_or_id, SchedulerItem::Type type, uint32_t delay, uint64_t now) {
858 // Validate static strings in debug mode
859 if (name_type == NameType::STATIC_STRING && static_name != nullptr) {
860 validate_static_string(static_name);
861 }
862
863 // Debug logging
864 SchedulerNameLog name_log;
865 const char *type_str = (type == SchedulerItem::TIMEOUT) ? "timeout" : "interval";
866 if (type == SchedulerItem::TIMEOUT) {
867 ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ")", type_str, LOG_STR_ARG(item->get_source()),
868 name_log.format(name_type, static_name, hash_or_id), type_str, delay);
869 } else {
870 ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ", offset=%" PRIu32 ")", type_str, LOG_STR_ARG(item->get_source()),
871 name_log.format(name_type, static_name, hash_or_id), type_str, delay,
872 static_cast<uint32_t>(item->get_next_execution() - now));
873 }
874}
875#endif /* ESPHOME_DEBUG_SCHEDULER */
876
877// Helper to get or create a scheduler item from the pool
878// IMPORTANT: Caller must hold the scheduler lock before calling this function.
879std::unique_ptr<Scheduler::SchedulerItem> Scheduler::get_item_from_pool_locked_() {
880 std::unique_ptr<SchedulerItem> item;
881 if (!this->scheduler_item_pool_.empty()) {
882 item = std::move(this->scheduler_item_pool_.back());
883 this->scheduler_item_pool_.pop_back();
884#ifdef ESPHOME_DEBUG_SCHEDULER
885 ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size());
886#endif
887 } else {
888 item = make_unique<SchedulerItem>();
889#ifdef ESPHOME_DEBUG_SCHEDULER
890 ESP_LOGD(TAG, "Allocated new item (pool empty)");
891#endif
892 }
893 return item;
894}
895
896} // namespace esphome
void set_current_component(Component *component)
const Component * component
Definition component.cpp:37
uint16_t type
const char *const TAG
Definition spi.cpp:7
Providing packet encoding functions for exchanging data with a remote host.
Definition a01nyub.cpp:7
float random_float()
Return a random float between 0 and 1.
Definition helpers.cpp:158
void retry_handler(const std::shared_ptr< RetryArgs > &args)
void IRAM_ATTR HOT delay(uint32_t ms)
Definition core.cpp:26
uint32_t IRAM_ATTR HOT millis()
Definition core.cpp:25
Application App
Global storage of Application pointer - only one Application can exist.
constexpr uint32_t fnv1a_hash(const char *str)
Calculate a FNV-1a hash of str.
Definition helpers.h:447