diff --git a/esphome/core/application.h b/esphome/core/application.h index 5557889786..581b536853 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -392,7 +392,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); // RAII guard for a component loop phase. Constructor processes any pending // enable_loop requests from ISRs and marks in_loop_ so reentrant @@ -513,10 +513,11 @@ extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-vari // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has // raised loop_interval_ for power savings (see Application::loop()). -// Returns the timestamp of the last scheduler item that ran (or `now` -// unchanged if none ran), so the caller's WDT feed stays monotonic with the -// per-item feeds inside scheduler.call() without an extra millis(). -inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { +// Returns the advanced `now` (for monotonic wdt feed) alongside the Scheduler's +// 64-bit `now_64` (or 0 if any item fired — then stale). The main loop forwards +// now_64 to next_schedule_in() to avoid a second esp_timer_get_time() MMIO read +// per iteration; 0 means "compute fresh". +inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake. wake_drain_notifications(); @@ -561,11 +562,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Phase A: always service the scheduler. Decouples scheduler cadence from // loop_interval_ so raised intervals (for power savings) don't drag scheduled // items forward. A tick that only runs the scheduler is cheap. - // scheduler_tick_ returns the timestamp of the last scheduler item that ran - // (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now` - // so the gate check and WDT feed both reflect actual elapsed time after - // scheduler dispatch, without an extra millis() call. - uint32_t now = this->scheduler_tick_(MillisInternal::get()); + // scheduler_tick_ returns the advanced `now` (for wdt monotonicity) plus the + // 64-bit `now_64` Scheduler::call() already computed — forward that to the + // next_schedule_in() call below so we don't re-read esp_timer_get_time() a + // second time per iteration. 0 means "stale, recompute". + const Scheduler::CallResult sched_result = this->scheduler_tick_(MillisInternal::get()); + uint32_t now = sched_result.now; #ifdef USE_RUNTIME_STATS // Capture immediately after scheduler_tick_ returns so the post-scheduler // feed_wdt_with_time slice can be separated from the scheduler slice in @@ -685,7 +687,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); - const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); + const uint32_t until_sched = this->scheduler.next_schedule_in(now, sched_result.now_64).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 0dbd2ca465..d6d3ef2d2d 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -412,7 +412,7 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { #pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings -optional HOT Scheduler::next_schedule_in(uint32_t now) { +optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which // is only safe from the main thread. Other threads must not call this method. @@ -455,7 +455,12 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { return {}; SchedulerItem *item = this->items_[0]; - const auto now_64 = this->millis_64_from_(now); + // If the caller did not pass a pre-computed now_64 (or passed the 0 + // sentinel, meaning call() advanced past it by firing items), read the + // clock fresh. Otherwise reuse the passed value to avoid a second + // esp_timer_get_time() MMIO read per main-loop iteration. + if (now_64 == 0) + now_64 = this->millis_64_from_(now); const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) return 0; @@ -566,7 +571,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -uint32_t HOT Scheduler::call(uint32_t now) { +Scheduler::CallResult HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -577,6 +582,10 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Track if any items were added to to_add_ during callbacks bool has_added_items = false; + // Track whether we advanced `now` via execute_item_(). If we did, `now_64` + // is stale by the time we return and next_schedule_in() must recompute — + // signal that by writing 0 to *now_64_out. + bool executed_any_item = false; #ifdef ESPHOME_DEBUG_SCHEDULER static uint64_t last_print = 0; @@ -689,6 +698,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled now = this->execute_item_(item, now); + executed_any_item = true; LockGuard guard{this->lock_}; @@ -745,7 +755,10 @@ uint32_t HOT Scheduler::call(uint32_t now) { #endif // execute_item_() advances `now` as items fire; return it so the caller // stays monotonic with last_wdt_feed_. - return now; + // The returned now_64 is only usable if NO items fired — execute_item_ only + // advances the uint32 `now`, leaving our local now_64 stale. Signal stale + // with the 0 sentinel so next_schedule_in() reads the clock fresh. + return CallResult{now, executed_any_item ? 0 : now_64}; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 590c23503b..7073507a99 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -122,15 +122,32 @@ class Scheduler { // Calculate when the next scheduled item should run. // @param now On ESP32, unused for 64-bit extension (native); on other platforms, extended to 64-bit via rollover. + // @param now_64 Optional pre-computed 64-bit timestamp from a recent call() (see CallResult). + // Pass 0 (sentinel) to have this method compute now_64 freshly. Non-zero values are trusted and + // used directly, saving a millis_64() / esp_timer_get_time() MMIO read when the caller already + // has a fresh value from the same loop iteration. // Returns the time in milliseconds until the next scheduled item, or nullopt if no items. // This method performs cleanup of removed items before checking the schedule. // IMPORTANT: This method should only be called from the main thread (loop task). - optional next_schedule_in(uint32_t now); + optional next_schedule_in(uint32_t now, uint64_t now_64 = 0); + + // Result of Scheduler::call(). + // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed). + // now_64 – 64-bit millis value Scheduler used for item dispatch, OR 0 if any item + // fired (in that case now_64 is stale because execute_item_ only advances + // the uint32 `now`). A subsequent next_schedule_in() treats 0 as "compute + // fresh" and non-zero as "reuse this value", saving a second millis_64() + // / esp_timer_get_time() call per main-loop iteration. + struct CallResult { + uint32_t now; + uint64_t now_64; + }; // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - // @return Timestamp of the last item that ran, or `now` unchanged if none ran. - uint32_t call(uint32_t now); + // @return Both advanced `now` (for wdt monotonicity) and `now_64` (for pipe-through + // to next_schedule_in()). See CallResult. + CallResult call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task).