[core] Replace lwip_select() with direct rcvevent reads on ESP32

On ESP32, replace lwip_select() in yield_with_select_() with direct
socket event reads via lwip_socket_dbg_get_socket(), reducing poll
cost from 133us to ~7us (18.7x faster). Replace the UDP loopback
wake socket with FreeRTOS task notifications (<2us, ISR-safe).

Benchmarks (ESP32, 4 listen sockets):
- Poll path: 7,087 ns vs 132,402 ns (18.7x faster)
- Wake signal: 1,826 ns vs ~130,000 ns (72x faster, now ISR-safe)
- Binary: yield_with_select_ 108B vs 188B (43% smaller)

Non-ESP32 platforms (LibreTiny, ESP8266, RP2040) are unchanged.
This commit is contained in:
J. Nick Koston
2026-02-23 20:47:54 -06:00
parent ad2da0af52
commit 64364961db
5 changed files with 211 additions and 41 deletions
+9 -6
View File
@@ -126,13 +126,14 @@ def require_wake_loop_threadsafe() -> None:
"""Mark that wake_loop_threadsafe support is required by a component.
Call this from components that need to wake the main event loop from background threads.
This enables the shared UDP loopback socket mechanism (~208 bytes RAM).
The socket is shared across all components that use this feature.
On ESP32: Uses FreeRTOS task notifications (<1 us, ISR-safe, no socket needed).
On other platforms: Uses a shared UDP loopback socket mechanism (~208 bytes RAM).
This call is a no-op if networking is not enabled in the configuration.
IMPORTANT: This is for background thread context only, NOT ISR context.
Socket operations are not safe to call from ISR handlers.
On ESP32, this is safe to call from ISR context.
On other platforms, this is for background thread context only, NOT ISR context.
Example:
from esphome.components import socket
@@ -147,8 +148,10 @@ def require_wake_loop_threadsafe() -> None:
):
CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True
cg.add_define("USE_WAKE_LOOP_THREADSAFE")
# Consume 1 socket for the shared wake notification socket
consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({})
if not CORE.is_esp32:
# Only non-ESP32 platforms need a UDP socket for wake notifications.
# ESP32 uses FreeRTOS task notifications instead (no socket needed).
consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({})
CONFIG_SCHEMA = cv.Schema(
+66 -23
View File
@@ -9,6 +9,9 @@
#endif
#ifdef USE_ESP32
#include <esp_chip_info.h>
#include "esphome/core/lwip_fast_select.h"
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#endif
#include "esphome/core/version.h"
#include "esphome/core/hal.h"
@@ -145,8 +148,13 @@ void Application::setup() {
#endif
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#ifdef USE_ESP32
// Initialize fast select: saves main loop task handle for xTaskNotifyGive wake
esphome_lwip_fast_select_init();
#else
// Set up wake socket for waking main loop from tasks
this->setup_wake_loop_threadsafe_();
#endif
#endif
this->schedule_dump_config();
@@ -523,7 +531,7 @@ void Application::enable_pending_loops_() {
}
void Application::before_loop_tasks_(uint32_t loop_start_time) {
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
// Drain wake notifications first to clear socket for next wake
this->drain_wake_notifications_();
#endif
@@ -576,11 +584,15 @@ bool Application::register_socket_fd(int fd) {
#endif
this->socket_fds_.push_back(fd);
#ifdef USE_ESP32
// Hook the socket's netconn callback for instant wake on receive events
esphome_lwip_hook_socket(fd);
#else
this->socket_fds_changed_ = true;
if (fd > this->max_fd_) {
this->max_fd_ = fd;
}
#endif
return true;
}
@@ -599,8 +611,11 @@ void Application::unregister_socket_fd(int fd) {
if (i < this->socket_fds_.size() - 1)
this->socket_fds_[i] = this->socket_fds_.back();
this->socket_fds_.pop_back();
#ifdef USE_ESP32
// Unhook the socket's netconn callback
esphome_lwip_unhook_socket(fd);
#else
this->socket_fds_changed_ = true;
// Only recalculate max_fd if we removed the current max
if (fd == this->max_fd_) {
this->max_fd_ = -1;
@@ -609,6 +624,7 @@ void Application::unregister_socket_fd(int fd) {
this->max_fd_ = sock_fd;
}
}
#endif
return;
}
}
@@ -616,16 +632,34 @@ void Application::unregister_socket_fd(int fd) {
#endif
void Application::yield_with_select_(uint32_t delay_ms) {
// Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run
// since select() with 0 timeout only polls without yielding.
#ifdef USE_SOCKET_SELECT_SUPPORT
if (!this->socket_fds_.empty()) {
// Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run.
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32)
// ESP32 fast path: direct rcvevent reads (~858 ns for 4 sockets vs 133 us for lwip_select)
if (!this->socket_fds_.empty()) [[likely]] {
FD_ZERO(&this->read_fds_);
for (int fd : this->socket_fds_) {
if (esphome_lwip_socket_has_data(fd)) {
FD_SET(fd, &this->read_fds_);
}
}
}
if (delay_ms == 0) [[unlikely]] {
yield();
return;
}
// Sleep with instant wake via FreeRTOS task notification.
// Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout.
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms));
#elif defined(USE_SOCKET_SELECT_SUPPORT)
// Non-ESP32 platforms (LibreTiny bk72xx/rtl87xx): use select()
if (!this->socket_fds_.empty()) [[likely]] {
// Update fd_set if socket list has changed
if (this->socket_fds_changed_) {
if (this->socket_fds_changed_) [[unlikely]] {
FD_ZERO(&this->base_read_fds_);
// fd bounds are already validated in register_socket_fd() or guaranteed by platform design:
// - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS)
// - Other platforms: register_socket_fd() validates fd < FD_SETSIZE
// fd bounds are validated in register_socket_fd()
for (int fd : this->socket_fds_) {
FD_SET(fd, &this->base_read_fds_);
}
@@ -641,7 +675,7 @@ void Application::yield_with_select_(uint32_t delay_ms) {
tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000;
// Call select with timeout
#if defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || (defined(USE_ESP32) && defined(USE_SOCKET_IMPL_BSD_SOCKETS))
#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS
int ret = lwip_select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv);
#else
int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv);
@@ -651,19 +685,18 @@ void Application::yield_with_select_(uint32_t delay_ms) {
// ret < 0: error (except EINTR which is normal)
// ret > 0: socket(s) have data ready - normal and expected
// ret == 0: timeout occurred - normal and expected
if (ret < 0 && errno != EINTR) {
// Actual error - log and fall back to delay
ESP_LOGW(TAG, "select() failed with errno %d", errno);
delay(delay_ms);
if (ret >= 0 || errno == EINTR) [[likely]] {
// Yield if zero timeout since select(0) only polls without yielding
if (delay_ms == 0) [[unlikely]] {
yield();
}
return;
}
// When delay_ms is 0, we need to yield since select(0) doesn't yield
if (delay_ms == 0) {
yield();
}
} else {
// No sockets registered, use regular delay
delay(delay_ms);
// select() error - log and fall through to delay()
ESP_LOGW(TAG, "select() failed with errno %d", errno);
}
// No sockets registered or select() failed - use regular delay
delay(delay_ms);
#elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)
// No select support but can wake on socket activity via esp_schedule()
socket::socket_delay(delay_ms);
@@ -676,6 +709,14 @@ void Application::yield_with_select_(uint32_t delay_ms) {
Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#ifdef USE_ESP32
void Application::wake_loop_threadsafe() {
// Direct FreeRTOS task notification — ISR-safe, <1 us
esphome_lwip_wake_main_loop();
}
#else // !USE_ESP32
void Application::setup_wake_loop_threadsafe_() {
// Create UDP socket for wake notifications
this->wake_socket_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
@@ -742,6 +783,8 @@ void Application::wake_loop_threadsafe() {
lwip_send(this->wake_socket_fd_, &dummy, 1, 0);
}
}
#endif // USE_ESP32
#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
void Application::get_build_time_string(std::span<char, BUILD_TIME_STR_SIZE> buffer) {
+15 -12
View File
@@ -25,7 +25,7 @@
#ifdef USE_SOCKET_SELECT_SUPPORT
#include <sys/select.h>
#ifdef USE_WAKE_LOOP_THREADSAFE
#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
#include <lwip/sockets.h>
#endif
#endif // USE_SOCKET_SELECT_SUPPORT
@@ -497,9 +497,10 @@ class Application {
bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); }
#ifdef USE_WAKE_LOOP_THREADSAFE
/// Wake the main event loop from a FreeRTOS task
/// Thread-safe, can be called from task context to immediately wake select()
/// IMPORTANT: NOT safe to call from ISR context (socket operations not ISR-safe)
/// Wake the main event loop from a FreeRTOS task or ISR.
/// Thread-safe, can be called from any context to immediately wake the main loop.
/// On ESP32: uses xTaskNotifyGive (<1 us, ISR-safe)
/// On other platforms: uses UDP loopback socket (NOT ISR-safe)
void wake_loop_threadsafe();
#endif
#endif
@@ -541,7 +542,7 @@ class Application {
/// Perform a delay while also monitoring socket file descriptors for readiness
void yield_with_select_(uint32_t delay_ms);
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
void setup_wake_loop_threadsafe_(); // Create wake notification socket
inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined)
#endif
@@ -571,7 +572,7 @@ class Application {
FixedVector<Component *> looping_components_{};
#ifdef USE_SOCKET_SELECT_SUPPORT
std::vector<int> socket_fds_; // Vector of all monitored socket file descriptors
#ifdef USE_WAKE_LOOP_THREADSAFE
#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks
#endif
#endif
@@ -584,7 +585,7 @@ class Application {
uint32_t last_loop_{0};
uint32_t loop_component_start_time_{0};
#ifdef USE_SOCKET_SELECT_SUPPORT
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32)
int max_fd_{-1}; // Highest file descriptor number for select()
#endif
@@ -600,14 +601,16 @@ class Application {
bool in_loop_{false};
volatile bool has_pending_enable_loop_requests_{false};
#ifdef USE_SOCKET_SELECT_SUPPORT
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32)
bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes
#endif
#ifdef USE_SOCKET_SELECT_SUPPORT
// Variable-sized members
fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes
fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_
fd_set read_fds_{}; // Working fd_set: populated by select() or direct rcvevent reads
#ifndef USE_ESP32
fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes (select() path)
#endif
#endif
// StaticVectors (largest members - contain actual array data inline)
@@ -694,7 +697,7 @@ class Application {
/// Global storage of Application pointer - only one Application can exist.
extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
// Inline implementations for hot-path functions
// drain_wake_notifications_() is called on every loop iteration
@@ -716,6 +719,6 @@ inline void Application::drain_wake_notifications_() {
}
}
}
#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
} // namespace esphome
+84
View File
@@ -0,0 +1,84 @@
// Fast socket monitoring for ESP32 (ESP-IDF LwIP)
// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications.
//
// This must be a .c file (not .cpp) because:
// 1. lwip/priv/sockets_priv.h conflicts with C++ compilation units that include bootloader headers
// 2. The netconn callback is a C function pointer
//
// defines.h is force-included by the build system (-include flag), providing USE_ESP32 etc.
#ifdef USE_ESP32
// LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc.
#include <lwip/api.h>
#include <lwip/priv/sockets_priv.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "esphome/core/lwip_fast_select.h"
// Task handle for the main loop — set during setup, read from any thread/ISR
static TaskHandle_t s_main_loop_task = NULL;
// Saved original event_callback pointer (same for all LwIP sockets)
static netconn_callback s_original_callback = NULL;
// Wrapper callback: calls original event_callback + notifies main loop task.
// Called from LwIP's TCP/IP thread when socket events occur.
static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) {
// Call original LwIP event_callback first — updates rcvevent/sendevent/errevent,
// signals any select() waiters. This preserves all LwIP behavior.
if (s_original_callback) {
s_original_callback(conn, evt, len);
}
// Wake the main loop task if sleeping in ulTaskNotifyTake().
// Only notify on receive events to avoid spurious wakeups from send-ready events.
// xTaskNotifyGive is thread-safe and ISR-safe, costs <1 us.
if (evt == NETCONN_EVT_RCVPLUS) {
TaskHandle_t task = s_main_loop_task;
if (task != NULL) {
xTaskNotifyGive(task);
}
}
}
void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); }
bool esphome_lwip_socket_has_data(int fd) {
struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd);
return sock != NULL && sock->conn != NULL && sock->rcvevent > 0;
}
void esphome_lwip_hook_socket(int fd) {
struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd);
if (sock == NULL || sock->conn == NULL)
return;
// Save original callback (only once — same event_callback for all LwIP sockets)
if (s_original_callback == NULL) {
s_original_callback = sock->conn->callback;
}
// Replace with our wrapper
sock->conn->callback = esphome_socket_event_callback;
}
void esphome_lwip_unhook_socket(int fd) {
struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd);
if (sock == NULL || sock->conn == NULL)
return;
// Restore original callback
if (s_original_callback != NULL) {
sock->conn->callback = s_original_callback;
}
}
void esphome_lwip_wake_main_loop(void) {
TaskHandle_t task = s_main_loop_task;
if (task != NULL) {
xTaskNotifyGive(task);
}
}
#endif // USE_ESP32
+37
View File
@@ -0,0 +1,37 @@
#pragma once
// Fast socket monitoring for ESP32 (ESP-IDF LwIP)
// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications.
// See fast_select.md for design rationale and benchmarks.
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
/// Initialize fast select — must be called from the main loop task during setup().
/// Saves the current task handle for xTaskNotifyGive() wake notifications.
void esphome_lwip_fast_select_init(void);
/// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket).
/// Uses lwip_socket_dbg_get_socket() which is a direct array lookup — no locking, no refcount.
/// Safe for single-threaded polling from the main loop.
bool esphome_lwip_socket_has_data(int fd);
/// Hook a socket's netconn callback to notify the main loop task on receive events.
/// Wraps the original event_callback with one that also calls xTaskNotifyGive().
/// Must be called from the main loop after socket creation.
void esphome_lwip_hook_socket(int fd);
/// Unhook a socket's netconn callback, restoring the original event_callback.
/// Must be called from the main loop before closing the socket.
void esphome_lwip_unhook_socket(int fd);
/// Wake the main loop task from any thread or ISR — costs <1 us.
/// Replaces the UDP loopback socket wake mechanism.
void esphome_lwip_wake_main_loop(void);
#ifdef __cplusplus
}
#endif