[core] Cache lwip_sock pointers to simplify fast select hot path

Cache struct lwip_sock* pointers at socket registration time instead of
looking them up by fd on every iteration. The previous code stored file
descriptors and called lwip_socket_dbg_get_socket(fd) on every loop
iteration to convert each fd back to a pointer — only to read a single
field from it.

- register_socket(lwip_sock*) returns bool and hooks the callback
  internally — replaces the separate register_socket_fd + hook dance
- Hot loop iterates struct lwip_sock* directly instead of converting
  fds each iteration
- Socket::ready() uses the cached pointer instead of fd lookup
- esphome_lwip_hook_socket() takes struct lwip_sock* directly
- esphome_lwip_socket_has_data() moved to header as static inline
  for inlining — uses offset-based access verified by _Static_assert
- Null cached_sock_ on close to prevent post-close dereference
- socket_ready() tolerant of null pointer for safety
This commit is contained in:
J. Nick Koston
2026-03-01 14:21:40 -10:00
parent 5d9c3a0e71
commit b3b3f18b3b
10 changed files with 154 additions and 65 deletions
+21 -6
View File
@@ -11,11 +11,15 @@ namespace esphome::socket {
BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) {
this->fd_ = fd;
// Register new socket with the application for select() if monitoring requested
if (monitor_loop && this->fd_ >= 0) {
// Only set loop_monitored_ to true if registration succeeds
this->loop_monitored_ = App.register_socket_fd(this->fd_);
}
if (!monitor_loop || this->fd_ < 0)
return;
#ifdef USE_LWIP_FAST_SELECT
// Cache lwip_sock pointer and register for monitoring (hooks callback internally)
this->cached_sock_ = esphome_lwip_get_sock(this->fd_);
this->loop_monitored_ = App.register_socket(this->cached_sock_);
#else
this->loop_monitored_ = App.register_socket_fd(this->fd_);
#endif
}
BSDSocketImpl::~BSDSocketImpl() {
@@ -26,10 +30,17 @@ BSDSocketImpl::~BSDSocketImpl() {
int BSDSocketImpl::close() {
if (!this->closed_) {
// Unregister from select() before closing if monitored
// Unregister before closing to avoid dangling pointer in monitored set
#ifdef USE_LWIP_FAST_SELECT
if (this->loop_monitored_) {
App.unregister_socket(this->cached_sock_);
this->cached_sock_ = nullptr;
}
#else
if (this->loop_monitored_) {
App.unregister_socket_fd(this->fd_);
}
#endif
int ret = ::close(this->fd_);
this->closed_ = true;
return ret;
@@ -48,7 +59,11 @@ int BSDSocketImpl::setblocking(bool blocking) {
return 0;
}
#ifdef USE_LWIP_FAST_SELECT
bool BSDSocketImpl::ready() const { return socket_ready(this->cached_sock_, this->loop_monitored_); }
#else
bool BSDSocketImpl::ready() const { return socket_ready_fd(this->fd_, this->loop_monitored_); }
#endif
size_t BSDSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
struct sockaddr_storage storage;
@@ -13,6 +13,10 @@
#include <lwip/sockets.h>
#endif
#ifdef USE_LWIP_FAST_SELECT
struct lwip_sock;
#endif
namespace esphome::socket {
class BSDSocketImpl {
@@ -105,6 +109,9 @@ class BSDSocketImpl {
protected:
int fd_{-1};
#ifdef USE_LWIP_FAST_SELECT
struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready()
#endif
bool closed_{false};
bool loop_monitored_{false};
};
@@ -11,11 +11,15 @@ namespace esphome::socket {
LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) {
this->fd_ = fd;
// Register new socket with the application for select() if monitoring requested
if (monitor_loop && this->fd_ >= 0) {
// Only set loop_monitored_ to true if registration succeeds
this->loop_monitored_ = App.register_socket_fd(this->fd_);
}
if (!monitor_loop || this->fd_ < 0)
return;
#ifdef USE_LWIP_FAST_SELECT
// Cache lwip_sock pointer and register for monitoring (hooks callback internally)
this->cached_sock_ = esphome_lwip_get_sock(this->fd_);
this->loop_monitored_ = App.register_socket(this->cached_sock_);
#else
this->loop_monitored_ = App.register_socket_fd(this->fd_);
#endif
}
LwIPSocketImpl::~LwIPSocketImpl() {
@@ -26,10 +30,17 @@ LwIPSocketImpl::~LwIPSocketImpl() {
int LwIPSocketImpl::close() {
if (!this->closed_) {
// Unregister from select() before closing if monitored
// Unregister before closing to avoid dangling pointer in monitored set
#ifdef USE_LWIP_FAST_SELECT
if (this->loop_monitored_) {
App.unregister_socket(this->cached_sock_);
this->cached_sock_ = nullptr;
}
#else
if (this->loop_monitored_) {
App.unregister_socket_fd(this->fd_);
}
#endif
int ret = lwip_close(this->fd_);
this->closed_ = true;
return ret;
@@ -48,7 +59,11 @@ int LwIPSocketImpl::setblocking(bool blocking) {
return 0;
}
#ifdef USE_LWIP_FAST_SELECT
bool LwIPSocketImpl::ready() const { return socket_ready(this->cached_sock_, this->loop_monitored_); }
#else
bool LwIPSocketImpl::ready() const { return socket_ready_fd(this->fd_, this->loop_monitored_); }
#endif
size_t LwIPSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
struct sockaddr_storage storage;
@@ -9,6 +9,10 @@
#include "esphome/core/helpers.h"
#include "headers.h"
#ifdef USE_LWIP_FAST_SELECT
struct lwip_sock;
#endif
namespace esphome::socket {
class LwIPSocketImpl {
@@ -71,6 +75,9 @@ class LwIPSocketImpl {
protected:
int fd_{-1};
#ifdef USE_LWIP_FAST_SELECT
struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready()
#endif
bool closed_{false};
bool loop_monitored_{false};
};
+7 -1
View File
@@ -8,7 +8,13 @@
namespace esphome::socket {
#ifdef USE_SOCKET_SELECT_SUPPORT
#ifdef USE_LWIP_FAST_SELECT
// Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets).
// Uses cached lwip_sock pointer for direct rcvevent read — no fd lookup overhead.
bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored) {
return !loop_monitored || (cached_sock != nullptr && esphome_lwip_socket_has_data(cached_sock));
}
#elif defined(USE_SOCKET_SELECT_SUPPORT)
// Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets).
// Checks if the Application's select() loop has marked this fd as ready.
bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || App.is_socket_ready_(fd); }
+8 -1
View File
@@ -6,6 +6,10 @@
#include "esphome/core/optional.h"
#include "headers.h"
#ifdef USE_LWIP_FAST_SELECT
struct lwip_sock;
#endif
#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
// Include only the active implementation's header.
@@ -36,7 +40,10 @@ using Socket = LWIPRawImpl;
using ListenSocket = LWIPRawListenImpl;
#endif
#ifdef USE_SOCKET_SELECT_SUPPORT
#ifdef USE_LWIP_FAST_SELECT
/// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read.
bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored);
#elif defined(USE_SOCKET_SELECT_SUPPORT)
/// Shared ready() helper for fd-based socket implementations.
/// Checks if the Application's select() loop has marked this fd as ready.
bool socket_ready_fd(int fd, bool loop_monitored);
+29 -14
View File
@@ -555,7 +555,32 @@ void Application::after_loop_tasks_() {
this->in_loop_ = false;
}
#ifdef USE_SOCKET_SELECT_SUPPORT
#ifdef USE_LWIP_FAST_SELECT
bool Application::register_socket(struct lwip_sock *sock) {
// It modifies monitored_sockets_ without locking — must only be called from the main loop.
if (sock == nullptr)
return false;
esphome_lwip_hook_socket(sock);
this->monitored_sockets_.push_back(sock);
return true;
}
void Application::unregister_socket(struct lwip_sock *sock) {
// It modifies monitored_sockets_ without locking — must only be called from the main loop.
for (size_t i = 0; i < this->monitored_sockets_.size(); i++) {
if (this->monitored_sockets_[i] != sock)
continue;
// Swap with last element and pop - O(1) removal since order doesn't matter.
// No need to unhook the netconn callback — all LwIP sockets share the same
// static event_callback, and the socket will be closed by the caller.
if (i < this->monitored_sockets_.size() - 1)
this->monitored_sockets_[i] = this->monitored_sockets_.back();
this->monitored_sockets_.pop_back();
return;
}
}
#elif defined(USE_SOCKET_SELECT_SUPPORT)
bool Application::register_socket_fd(int fd) {
// WARNING: This function is NOT thread-safe and must only be called from the main loop
// It modifies socket_fds_ and related variables without locking
@@ -574,15 +599,10 @@ bool Application::register_socket_fd(int fd) {
#endif
this->socket_fds_.push_back(fd);
#ifdef USE_LWIP_FAST_SELECT
// 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;
}
@@ -598,13 +618,9 @@ void Application::unregister_socket_fd(int fd) {
continue;
// Swap with last element and pop - O(1) removal since order doesn't matter.
// No need to unhook the netconn callback on fast select platforms — all LwIP
// sockets share the same static event_callback, and the socket will be closed
// by the caller.
if (i < this->socket_fds_.size() - 1)
this->socket_fds_[i] = this->socket_fds_.back();
this->socket_fds_.pop_back();
#ifndef USE_LWIP_FAST_SELECT
this->socket_fds_changed_ = true;
// Only recalculate max_fd if we removed the current max
if (fd == this->max_fd_) {
@@ -614,7 +630,6 @@ void Application::unregister_socket_fd(int fd) {
this->max_fd_ = sock_fd;
}
}
#endif
return;
}
}
@@ -624,7 +639,7 @@ void Application::unregister_socket_fd(int fd) {
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.
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_LWIP_FAST_SELECT)
// Fast path (ESP32/LibreTiny): reads rcvevent directly via lwip_socket_dbg_get_socket().
// Fast path (ESP32/LibreTiny): reads rcvevent directly from cached lwip_sock pointers.
// Safe because this runs on the main loop which owns socket lifetime (create, read, close).
if (delay_ms == 0) [[unlikely]] {
yield();
@@ -635,8 +650,8 @@ void Application::yield_with_select_(uint32_t delay_ms) {
// If a socket still has unread data (rcvevent > 0) but the task notification was already
// consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency.
// This scan preserves select() semantics: return immediately when any fd is ready.
for (int fd : this->socket_fds_) {
if (esphome_lwip_socket_has_data(fd)) {
for (struct lwip_sock *sock : this->monitored_sockets_) {
if (esphome_lwip_socket_has_data(sock)) {
yield();
return;
}
+16 -16
View File
@@ -487,14 +487,20 @@ class Application {
Scheduler scheduler;
/// Register/unregister a socket file descriptor to be monitored for read events.
#ifdef USE_SOCKET_SELECT_SUPPORT
/// These functions update the fd_set used by select() in the main loop.
/// Register/unregister a socket to be monitored for read events.
/// WARNING: These functions are NOT thread-safe. They must only be called from the main loop.
#ifdef USE_LWIP_FAST_SELECT
/// Fast select path: hooks netconn callback and registers for monitoring.
/// @return true if registration was successful, false if sock is null
bool register_socket(struct lwip_sock *sock);
void unregister_socket(struct lwip_sock *sock);
#elif defined(USE_SOCKET_SELECT_SUPPORT)
/// Fallback select() path: monitors file descriptors.
/// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error.
/// @return true if registration was successful, false if fd exceeds limits
bool register_socket_fd(int fd);
void unregister_socket_fd(int fd);
#endif
#ifdef USE_WAKE_LOOP_THREADSAFE
/// Wake the main event loop from another FreeRTOS task.
@@ -512,28 +518,18 @@ class Application {
static void IRAM_ATTR wake_loop_isrsafe(int *px_higher_priority_task_woken) {
esphome_lwip_wake_main_loop_from_isr(px_higher_priority_task_woken);
}
#endif
#endif
protected:
friend Component;
#ifdef USE_SOCKET_SELECT_SUPPORT
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT)
friend bool socket::socket_ready_fd(int fd, bool loop_monitored);
#endif
friend void ::setup();
friend void ::original_setup();
#ifdef USE_SOCKET_SELECT_SUPPORT
/// Fast path for Socket::ready() via friendship - skips negative fd check.
/// Main loop only — with USE_LWIP_FAST_SELECT, reads rcvevent via
/// lwip_socket_dbg_get_socket(), which has no refcount; safe only because
/// the main loop owns socket lifetime (creates, reads, and closes sockets
/// on the same thread).
#ifdef USE_LWIP_FAST_SELECT
bool is_socket_ready_(int fd) const { return esphome_lwip_socket_has_data(fd); }
#else
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT)
bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); }
#endif
#endif
void register_component_(Component *comp);
@@ -589,8 +585,12 @@ class Application {
// and active_end_ is incremented
// - This eliminates branch mispredictions from flag checking in the hot loop
FixedVector<Component *> looping_components_{};
#ifdef USE_SOCKET_SELECT_SUPPORT
#ifdef USE_LWIP_FAST_SELECT
std::vector<struct lwip_sock *> monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read
#elif defined(USE_SOCKET_SELECT_SUPPORT)
std::vector<int> socket_fds_; // Vector of all monitored socket file descriptors
#endif
#ifdef USE_SOCKET_SELECT_SUPPORT
#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT)
int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks
#endif
+7 -15
View File
@@ -151,6 +151,10 @@ _Static_assert(offsetof(struct netconn, callback) % sizeof(netconn_callback) ==
_Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock *) 0)->rcvevent) == 0,
"lwip_sock.rcvevent must be naturally aligned for atomic access");
// Verify the hardcoded offset used in the header's inline esphome_lwip_socket_has_data().
_Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET,
"lwip_sock.rcvevent offset changed — update ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET in lwip_fast_select.h");
// Task handle for the main loop — written once in init(), read from TCP/IP and background tasks.
static TaskHandle_t s_main_loop_task = NULL;
@@ -195,23 +199,11 @@ static inline struct lwip_sock *get_sock(int fd) {
return sock;
}
bool esphome_lwip_socket_has_data(int fd) {
struct lwip_sock *sock = get_sock(fd);
if (sock == NULL)
return false;
// volatile prevents the compiler from caching/reordering this cross-thread read.
// The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a
// FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value
// is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on
// Xtensa/RISC-V/ARM and cannot produce torn values.
return *(volatile s16_t *) &sock->rcvevent > 0;
struct lwip_sock *esphome_lwip_get_sock(int fd) {
return get_sock(fd);
}
void esphome_lwip_hook_socket(int fd) {
struct lwip_sock *sock = get_sock(fd);
if (sock == NULL)
return;
void esphome_lwip_hook_socket(struct lwip_sock *sock) {
// Save original callback once — all LwIP sockets share the same static event_callback
// (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM).
if (s_original_callback == NULL) {
+31 -6
View File
@@ -4,6 +4,16 @@
// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications.
#include <stdbool.h>
#include <stdint.h>
// Forward declare lwip_sock for C++ callers that store cached pointers.
// The full definition is only available in the .c file (lwip/priv/sockets_priv.h
// conflicts with C++ compilation units).
struct lwip_sock;
// Byte offset of rcvevent (s16_t) within struct lwip_sock.
// Verified at compile time in lwip_fast_select.c via _Static_assert.
#define ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET 8
#ifdef __cplusplus
extern "C" {
@@ -13,16 +23,31 @@ extern "C" {
/// 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() — a direct array lookup without the refcount that
/// get_socket()/done_socket() uses. Safe because the caller owns the socket lifetime:
/// both has_data reads and socket close/unregister happen on the main loop thread.
bool esphome_lwip_socket_has_data(int fd);
/// Look up a LwIP socket struct from a file descriptor.
/// Returns NULL if fd is invalid or the socket/netconn is not initialized.
/// Use this at registration time to cache the pointer for esphome_lwip_socket_has_data().
struct lwip_sock *esphome_lwip_get_sock(int fd);
/// Check if a cached LwIP socket has data ready via direct rcvevent read.
/// The sock pointer must have been obtained from esphome_lwip_get_sock() and must
/// remain valid (caller owns socket lifetime — no concurrent close).
/// Hot path: inlined volatile 16-bit load — no function call overhead.
/// Uses offset-based access because lwip/priv/sockets_priv.h conflicts with C++.
/// The offset is verified at compile time in lwip_fast_select.c.
static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) {
// volatile prevents the compiler from caching/reordering this cross-thread read.
// The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a
// FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value
// is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on
// Xtensa/RISC-V/ARM and cannot produce torn values.
return *(volatile int16_t *) ((char *) sock + ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET) > 0;
}
/// 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);
/// The sock pointer must have been obtained from esphome_lwip_get_sock().
void esphome_lwip_hook_socket(struct lwip_sock *sock);
/// Wake the main loop task from another FreeRTOS task — costs <1 us.
/// NOT ISR-safe — must only be called from task context.