English | 简体中文
EventBus is a C++17 single-header synchronous event bus. It provides a thread-safe subscription registry, synchronous publishing, runtime argument matching, common string conversions, waiting during unsubscription, orderly shutdown, and injectable diagnostic logging.
The implementation is contained in eventbus.hpp under the eventbus namespace. It depends only on the C++17 standard library.
- Single header: include
eventbus.hppdirectly. - Synchronous dispatch:
publish()invokes callbacks on the calling thread and completes the dispatch before returning. - Thread-safe subscription registry: supports concurrent
subscribe(),publish(),unsubscribe(), queries, andclear()calls from multiple threads. - No callback-level execution lock: the same callback may be invoked concurrently by multiple publishing threads. Callers must synchronize shared state inside their callbacks.
- Orderly lifecycle management:
unsubscribe(),unsubscribe_all(),clear(), andclose()prevent new invocations of the affected callbacks and wait for invocations already in progress to finish. - Type matching: callback signatures are deduced during subscription, and published argument tuples are matched at runtime.
- String conversions: supports
const char*/char*tostd::stringorstd::string_view, andstd::stringtostd::string_view. - Exception isolation: callback exceptions do not escape
publish()and are counted inPublishResult::failed. - Injectable logging: nothing is written to
std::coutorstd::cerrby default. Inject aLogHandlerwhen diagnostics are needed.
#include "eventbus.hpp"
#include <iostream>
#include <string>
int main()
{
eventbus::EventBus bus;
const auto id = bus.subscribe("greet", [](const std::string& name) {
std::cout << "Hello, " << name << "\n";
});
const auto result = bus.publish("greet", "World");
std::cout << "invoked: " << result.invoked << "\n";
(void)bus.unsubscribe("greet", id);
}namespace eventbus {
using callback_id = std::size_t;
enum class LogLevel
{
Debug,
Warning,
Error
};
using LogHandler = std::function<void(LogLevel, const std::string&)>;
} // namespace eventbuscallback_id is returned by subscribe() and identifies a specific subscription for removal. LogHandler is an optional diagnostic callback and is unset by default.
explicit EventBus(bool verbose_logging = false);
EventBus(bool verbose_logging, LogHandler log_handler);
~EventBus() noexcept;
void close();
void clear();verbose_loggingonly controls whether diagnostic messages are generated. No output is produced without aLogHandler.close()enters the closed state, clears the subscription registry, and waits for callbacks already in progress to finish.~EventBus()callsclose()and does not throw.clear()removes only the current subscriptions without entering the closed state. New calls tosubscribe()remain valid afterward.EventBusis neither copyable nor movable.
After the bus is closed:
subscribe()returns0.publish()returns an emptyPublishResult.publish_if_min_subscribers()returnsfalse.
template <typename Callback>
callback_id subscribe(const std::string& eventName, Callback&& callback);
[[nodiscard]] bool unsubscribe(const std::string& eventName, callback_id id);
[[nodiscard]] std::size_t unsubscribe_all(const std::string& eventName);- Callbacks must return
void. - Non-const lvalue-reference parameters, such as
int&, are rejected. unsubscribe()returnstruewhen it finds and removes the requested subscription.unsubscribe_all()returns the number of removed subscriptions.- A callback may unsubscribe itself without waiting for its own invocation to finish.
template <typename... Args>
PublishResult publish(const std::string& eventName, Args&&... args);
template <typename... Args>
[[nodiscard]] bool publish_if_min_subscribers(
const std::string& eventName,
std::size_t min_subscribers,
Args&&... args);publish() first takes a snapshot of the subscriptions, releases the registry lock, and then synchronously invokes each callback in that snapshot. Subscriptions added during publishing are not included in the current snapshot. A subscription removed during publishing may appear as skipped in that dispatch.
publish_if_min_subscribers() publishes only when the current subscriber count meets or exceeds the threshold. Its return value indicates whether the publishing process was started.
struct PublishResult
{
std::size_t subscribers;
std::size_t invoked;
std::size_t failed;
std::size_t type_mismatches;
std::size_t skipped;
};subscribers: number of subscriptions in the dispatch snapshot.invoked: number of callbacks invoked successfully.failed: number of callbacks that threw an exception.type_mismatches: number of callbacks not invoked because their parameters did not match.skipped: number of subscriptions present in the snapshot but deactivated before invocation.
[[nodiscard]] bool isEventRegistered(const std::string& eventName) const;
[[nodiscard]] std::size_t getCallbackCount(const std::string& eventName) const;
[[nodiscard]] std::vector<std::string> getAllEventNames() const;
struct EventBusStats
{
std::size_t total_events;
std::size_t total_callbacks;
std::size_t max_callbacks_per_event;
std::string most_subscribed_event;
};
[[nodiscard]] EventBusStats getStats() const;Query functions observe only the current subscription registry and do not wait for callbacks in progress.
void setVerboseLogging(bool verbose);
void setLogHandler(LogHandler handler);By default, EventBus writes nothing to standard output or standard error. After a LogHandler is installed:
LogLevel::Debug: verbose diagnostics for subscription, publishing, type mismatches, and publish results.LogLevel::Warning: publishing an event with no subscribers; generated only when verbose logging is enabled.LogLevel::Error: a callback threw an exception.
Example:
eventbus::EventBus bus(true, [](eventbus::LogLevel level, const std::string& message) {
if (level == eventbus::LogLevel::Error) {
// route to application logger
(void)message;
}
});eventbus::EventBus bus;
bus.subscribe("user_action",
[](const std::string& user, const std::string& action, int priority) {
std::cout << user << " " << action << " " << priority << "\n";
});
auto result = bus.publish("user_action", "Alice", "login", 5);
if (result.invoked != 1) {
std::cerr << "dispatch issue\n";
}eventbus::EventBus bus;
bus.subscribe("name", [](const std::string& value) {
std::cout << value << "\n";
});
bus.publish("name", "Alice"); // const char* -> std::string
bus.subscribe("view", [](std::string_view value) {
std::cout << value << "\n";
});
std::string text = "payload";
bus.publish("view", text); // std::string -> std::string_view
bus.publish("view", "text"); // const char* -> std::string_viewUse a lambda to bind an object's lifetime explicitly.
class Receiver
{
public:
void on_value(int value)
{
std::cout << value << "\n";
}
};
Receiver receiver;
eventbus::EventBus bus;
const auto id = bus.subscribe("value", [&receiver](int value) {
receiver.on_value(value);
});
bus.publish("value", 42);
(void)bus.unsubscribe("value", id);If the callback might outlive the object, do not capture a raw reference or raw this. Use a std::weak_ptr to check the lifetime:
auto receiver = std::make_shared<Receiver>();
std::weak_ptr<Receiver> weak_receiver = receiver;
bus.subscribe("value", [weak_receiver](int value) {
if (auto locked = weak_receiver.lock()) {
locked->on_value(value);
}
});EventBus does not serialize callback execution. Applications must protect their own state.
eventbus::EventBus bus;
std::mutex total_mutex;
int total = 0;
bus.subscribe("count", [&total_mutex, &total](int value) {
std::lock_guard<std::mutex> lock(total_mutex);
total += value;
});If a callback needs to publish another event, release the application lock before calling publish():
bus.subscribe("input", [&bus, &total_mutex, &total](int value) {
bool should_notify = false;
int new_total = 0;
{
std::lock_guard<std::mutex> lock(total_mutex);
total += value;
new_total = total;
should_notify = total > 100;
}
if (should_notify) {
bus.publish("threshold", new_total);
}
});Published arguments are packed into an internal tuple and may be copied or moved. For large objects, prefer a lightweight handle with explicit ownership or lifetime semantics.
auto data = std::make_shared<const std::vector<int>>(std::vector<int>{1, 2, 3});
bus.subscribe("data", [](std::shared_ptr<const std::vector<int>> payload) {
std::cout << payload->size() << "\n";
});
bus.publish("data", data);if (!bus.publish_if_min_subscribers("important", 2, "payload")) {
// not enough subscribers
}eventbus::EventBus bus;
bus.subscribe("stop", [] {
// cleanup
});
bus.publish("stop");
bus.close();Do not reuse an EventBus instance after closing it. Create a new instance to start again.
- Concurrent access to the subscription registry is protected internally.
publish()does not execute user callbacks while holding the registry lock.unsubscribe(),unsubscribe_all(),clear(), andclose()wait for affected callbacks already in progress to finish.- A callback can unsubscribe itself without deadlocking itself.
- The same callback may be invoked concurrently from multiple threads. Synchronize shared callback state explicitly.
- Do not call unknown external code while holding an application lock.
- Do not call
publish(),unsubscribe(),clear(), orclose()while holding an application lock. - If a callback captures an object by reference, the object must outlive the subscription; otherwise, use a
weak_ptrlifetime check. close()cannot protect against another thread retaining a reference to an already destroyed EventBus. The application remains responsible for object ownership and joining its threads.
- This is a synchronous event bus. It does not provide an asynchronous queue, thread pool, backpressure, cancellation tokens, or cross-thread scheduling semantics.
- The current API does not promise a stable ABI across DLL boundaries. Do not expose
EventBusas a cross-module binary interface. - Event names use
const std::string&. C++17std::unordered_mapdoes not provide standard heterogeneous lookup, so nostd::string_viewevent-name API is currently offered. - Published arguments are stored in a tuple held by
std::any. Type erasure and argument copies add overhead on hot paths. - Non-const lvalue-reference callback parameters are rejected so callbacks cannot mistakenly expect to modify the publisher's original object when they would actually receive an internal argument copy.
- Callback exceptions are caught and counted in
failed; they do not interrupt subsequent callbacks. callback_id == 0indicates that subscription failed, primarily because the EventBus was already closed.
Requirements:
- C++17 or later
- Standard library support for
std::shared_mutex,std::any, andstd::string_view
CMake:
cmake -S . -B build
cmake --build build --config Debug
ctest --test-dir build -C Debug --output-on-failureSingle-file compilation example:
g++ -std=c++17 -Wall -Wextra -Wpedantic -I. simple_test.cpp -o simple_testOn Windows, you can run:
build.bat
demo.batThe current CMake configuration includes:
simple_test: basic behavior, type conversions, concurrent callbacks, waiting during unsubscription, and exception results.complete_test: complete behavior, statistics, conditional publishing, and thread-safety examples.complex_type_test: complex STL types and custom payload types.usage_example: practical usage examples.
.
|-- eventbus.hpp
|-- simple_test.cpp
|-- test_full.cpp
|-- test_complex_types.cpp
|-- example_simple.cpp
|-- CMakeLists.txt
|-- build.bat
|-- demo.bat
|-- docs
| |-- QUICK_START.md
| |-- QUICK_START.zh-CN.md
| |-- PROJECT_SUMMARY.md
| |-- PROJECT_SUMMARY.zh-CN.md
| |-- DELIVERY_NOTES.md
| `-- DELIVERY_NOTES.zh-CN.md
|-- README.md
`-- README.zh-CN.md