From 9d20bd0e2f697e5bcb7a4cdb3138fea4dfddace0 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 09:49:16 +0200 Subject: [PATCH 1/7] Optimize zero-observer notification fast path --- src/ESPressio_ThreadSafeObservable.hpp | 87 +++++++++++++++++++++----- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/src/ESPressio_ThreadSafeObservable.hpp b/src/ESPressio_ThreadSafeObservable.hpp index c65bd3c..21d5461 100644 --- a/src/ESPressio_ThreadSafeObservable.hpp +++ b/src/ESPressio_ThreadSafeObservable.hpp @@ -1,11 +1,12 @@ #pragma once +#include +#include #include #include #include #include #include -#include #include "ESPressio_IObservable.hpp" #include "ESPressio_IObserver.hpp" @@ -22,6 +23,7 @@ namespace ESPressio { private: std::vector _observers; std::recursive_mutex _mutex; + std::atomic _observerCount{0}; std::size_t _notificationDepth = 0; bool _needsCompaction = false; @@ -51,7 +53,9 @@ namespace ESPressio { try { for (std::size_t index = 0; index < observerCount; ++index) { IObserverHandle* handle = _observers[index]; - if (handle != nullptr) { callback(handle->GetObserver()); } + if (handle != nullptr) { + callback(handle->GetObserver()); + } } } catch (...) { _finishNotification(); @@ -68,9 +72,14 @@ namespace ESPressio { try { for (std::size_t index = 0; index < observerCount; ++index) { IObserverHandle* handle = _observers[index]; - if (handle == nullptr) { continue; } - ObserverType* observerAsT = dynamic_cast(handle->GetObserver()); - if (observerAsT != nullptr) { callback(observerAsT); } + if (handle == nullptr) { + continue; + } + ObserverType* observerAsT = + dynamic_cast(handle->GetObserver()); + if (observerAsT != nullptr) { + callback(observerAsT); + } } } catch (...) { _finishNotification(); @@ -107,10 +116,29 @@ namespace ESPressio { template void ExecuteNotification(Operation&& operation) { + /* + * Notifications are intentionally very cheap when no observers + * are registered. The relaxed/acquire atomic read avoids taking + * the recursive mutex and avoids acquiring a notification-lifetime + * shared_ptr on the overwhelmingly common production fast path. + * + * A concurrently registering observer is not required to observe + * a notification that had already begun before registration. + */ + if ( + _observerCount.load( + std::memory_order_acquire + ) == 0 + ) { + return; + } + NotificationContext context( - *this, AcquireNotificationLifetime()); + *this, + AcquireNotificationLifetime()); operation(context); } + public: ~ThreadSafeObservable() override { BeginObservableDestruction(); @@ -121,6 +149,7 @@ namespace ESPressio { } } _observers.clear(); + _observerCount.store(0, std::memory_order_release); } ObserverHandlePtr RegisterObserver(IObserver* observer) override { @@ -137,26 +166,52 @@ namespace ESPressio { std::unique_ptr handle( new ObserverHandle(GetLifetimeControl(), observer)); _observers.push_back(handle.get()); + _observerCount.fetch_add(1, std::memory_order_release); return ObserverHandlePtr(handle.release()); } void UnregisterObserver(IObserver* observer) override { std::lock_guard lock(_mutex); - for (auto thisObserver = _observers.begin(); thisObserver != _observers.end(); thisObserver++) { - if ((*thisObserver)->GetObserver() == observer) { - static_cast((*thisObserver))->InvalidateRegistration(); - if (_notificationDepth > 0) { - *thisObserver = nullptr; - _needsCompaction = true; - } else { - _observers.erase(thisObserver); - } - return; + for ( + auto thisObserver = _observers.begin(); + thisObserver != _observers.end(); + ++thisObserver + ) { + if ( + *thisObserver == nullptr || + (*thisObserver)->GetObserver() != observer + ) { + continue; } + + static_cast( + *thisObserver + )->InvalidateRegistration(); + + _observerCount.fetch_sub( + 1, + std::memory_order_acq_rel + ); + + if (_notificationDepth > 0) { + *thisObserver = nullptr; + _needsCompaction = true; + } else { + _observers.erase(thisObserver); + } + return; } } bool IsObserverRegistered(IObserver* observer) override { + if ( + _observerCount.load( + std::memory_order_acquire + ) == 0 + ) { + return false; + } + std::lock_guard lock(_mutex); return _isObserverRegistered(observer); } From 1fe1193d5c98b9a3f5e8be94be1762b4d76b331e Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 10:01:37 +0200 Subject: [PATCH 2/7] Add host test workflow --- .github/workflows/tests.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8cd18e1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,17 @@ +name: tests + +on: + push: + pull_request: + +jobs: + host-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S tests -B build + - name: Build + run: cmake --build build --parallel + - name: Test + run: ctest --test-dir build --output-on-failure From 7f67f1987fdc9c4ef1988267a3a1f4dff00ae667 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 10:06:07 +0200 Subject: [PATCH 3/7] Prepare Observable 3.0.1 release metadata --- library.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library.json b/library.json index 8195f33..791760e 100644 --- a/library.json +++ b/library.json @@ -11,7 +11,7 @@ "type": "git", "url": "https://github.com/Flowduino/ESPressio-Observable.git" }, - "version": "3.0.0", + "version": "3.0.1", "license": "Apache-2.0", "frameworks": "*", "platforms": "*", From 1d54231ee5323bdd252599859b9d2e44b20ad06f Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 10:06:14 +0200 Subject: [PATCH 4/7] Prepare Observable 3.0.1 Arduino metadata --- library.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library.properties b/library.properties index 1db31a3..926e47a 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=Flowduino ESPressio-Observable -version=3.0.0 +version=3.0.1 author=Simon J. Stuart maintainer=Flowduino.com sentence=Observer Pattern library for microcontrollers with modern C++ toolchains From 0eb7ff8ddd7fe956c925abfa29c70e2d370d04a6 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 10:06:22 +0200 Subject: [PATCH 5/7] Correct Observable compile-time version metadata --- component.mk | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/component.mk b/component.mk index 7af0536..fbb20f0 100644 --- a/component.mk +++ b/component.mk @@ -1,7 +1,7 @@ COMPONENT_ADD_INCLUDEDIRS := src COMPONENT_SRCDIRS := src CXXFLAGS += -DESPRESSIO_OBSERVER -CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_MAJOR=2 +CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_MAJOR=3 CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_MINOR=0 -CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_PATCH=0 -CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_STRING=\"2.0.0\" +CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_PATCH=1 +CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_STRING=\"3.0.1\" From ecfe24c38fe070d054bf9b8c37c42e7286f31e21 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 10:06:35 +0200 Subject: [PATCH 6/7] Document Observable 3.0.1 performance release --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f67455..e542e94 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,19 @@ Development Platform. ## Latest Stable Version -**3.0.0** +**3.0.1** + +### 3.0.1 performance maintenance + +Version 3.0.1 preserves the Observable 3.0 public API and ownership-safe +registration model while reducing the cost of optional observability when no +Observers are registered. + +`ThreadSafeObservable` now maintains a lightweight atomic Observer count so +`ExecuteNotification()` can return immediately without taking the notification +mutex or acquiring a notification-lifetime `shared_ptr` when there are no +Observers. Registration, unregistration, mutation-during-notification and RAII +handle semantics are unchanged. ## ESPressio Development Platform From 830c35dfc66060ef4a4eac0268199ae01680d88d Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 10:06:44 +0200 Subject: [PATCH 7/7] Add Observable 3.0.1 changelog entry --- CHANGELOG.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7235d0..22cec60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,23 @@ Versioning](https://semver.org/). > had little or no release-note detail, the entry is intentionally terse > rather than inferring unsupported intent. -## \[3.0.0\] - 2026-08-13 +## [3.0.1] - 2026-08-20 + +### Changed + +- Added an atomic Observer-count fast path to `ThreadSafeObservable`. +- Notifications now return immediately when no Observers are registered, + avoiding the notification mutex and notification-lifetime `shared_ptr` + acquisition on the zero-Observer production path. +- Preserved the Observable 3.0 registration, mutation-during-notification, + exception and RAII handle semantics. + +### Fixed + +- Corrected stale `component.mk` compile-time version metadata that still + identified the library as 2.0.0. + +## [3.0.0] - 2026-08-13 ### Changed @@ -29,7 +45,7 @@ Versioning](https://semver.org/). - Corrected Observer-registration lifetime hazards during callback/notification mutation. -## \[2.0.0\] +## [2.0.0] ### Changed @@ -38,7 +54,7 @@ Versioning](https://semver.org/). - Standardised the common `IObserver`-based synchronous observation contract. -## \[1.x\] +## [1.x] ### Added