Saturday, June 21, 2025

Event Handlers in C++: A Journey from Simplicity to Thread Safety

Event handlers form the nervous system of modern C++ applications, managing communication between components while balancing efficiency and safety. Let's explore four progressively sophisticated event handler implementations that demonstrate this evolution in design philosophy.

Part 1: 4 Event Handlers

1. The Minimalist: CSimpleEvent

This class implements a basic event-driven system using the Observer pattern. Below is a detailed breakdown for a junior software engineer, covering design principles, patterns, and code functionality.

template <typename... Args>
class CSimpleEvent {
public:
    void subscribe(Callback callback) {
        callbacks_.push_back(std::move(callback));
    }
 
    void trigger(Args... args) {
        for (const auto& callback : callbacks_) {
            callback(args...);
        }
    }
};

Core Functionality

·       Template Class (template <typename... Args>):

o   Supports events with any number/type of arguments (e.g., CSimpleEvent<> for no args, CSimpleEvent<int> for an int argument).

o   Example: CSimpleEvent<int, std::string> accepts callbacks with signature void(int, std::string).

·       Callback Type:

using Callback = std::function<void(Args...)>;

o   Stores callbacks (functions/lambdas) that match the event signature.

·       Subscribe:

void subscribe(Callback callback) {
    callbacks_.push_back(std::move(callback));
}

o   Adds a callback to the list. Uses std::move for efficient ownership transfer.

·       Trigger:

void trigger(Args... args) {
    for (const auto& callback : callbacks_) {
        callback(args...);
    }
}

o   Invokes all subscribed callbacks with the provided arguments.

·       Storage:

std::vector<Callback> callbacks_;

o   Holds callbacks in a std::vector.

Key Design Principles & Patterns

·       Observer Pattern:

o   Subject (Event)CSimpleEvent maintains a list of observers (callbacks).

o   Observers (Callbacks): Functions/lambdas that react to the event.

o   When trigger() is called, all observers are notified.

·       Open/Closed Principle:

o   Open for extension: New callbacks can be added via subscribe().

o   Closed for modification: The event class doesn’t need changes to support new callbacks.

·       Encapsulation:

o   The callback list (callbacks_) is private. External code interacts only via subscribe()/trigger().

·       Resource Management:

o   std::move in subscribe() avoids copying callbacks (efficient for large captures).

Summary

This barebones implementation excels in simplicity:

·        Pros: Zero overhead, intuitive interface

·        Cons: No unsubscription, not thread-safe

·        Memory: 24 bytes (typical vector overhead)

- Data Pointer (8 bytes): Address of the heap-allocated callback array
- Size Counter (8 bytes): Tracks callbacks_.size() (number of active callbacks)
- Capacity Tracker (8 bytes): Records callbacks_.capacity() (allocated memory)

·        Performance: O(n) triggers, no allocation latency

Use Case: Perfect for static single-threaded setups where callbacks persist for the application's lifetime, like startup configuration events.

 

2. The Optimized Global: CGlobalEvent

class CGlobalEvent {
    void trigger(Args... args) {
        std::atomic_thread_fence(std::memory_order_acquire);
        for (const auto& callback : callbacks_) {
            if (callback) callback(args...);
        }
    }
};

This version adds thread safety for a specific scenario:

·        Innovation: Uses memory fences instead of locks

·        Assumption: Subscriptions happen before concurrent access

·        Trigger Cost: Same as simple version + fence overhead (~5-20 cycles)

·        Safety: Read-only triggers safe across threads

Use Case: Global application events (e.g., shutdown notifications) where subscriptions occur during initialization.

 

class CDevStatusHandler

{

    CGlobalEvent<std::string> m_onStatusToGuiUpdate;

 public:

    CDevStatusHandler();

};

 

CDevStatusHandler::CDevStatusHandler()

{

    m_onStatusToGuiUpdate.subscribe([this](std::string info) {

        std::cout << "onStatusToGuiUpdate Event triggered: " << info;

    });

}

 

void CEventDev::SetState(int nState)

{

    if (m_nState == nState) {

        return;

    }

    m_nState = nState;

    char acLog[100];

    if (nState == DEV_STATE_OK) {

        snprintf(acLog, sizeof(acLog), "%s_%d Ok", m_acName, m_nId);

    } else {

        snprintf(acLog, sizeof(acLog), "%s_%d Down", m_acName, m_nId);

    }

    g_pLog->LogInfo(LOG_SYS, acLog);

 

    if (g_pDevStatus) {

        g_pDevStatus->m_onStatusToGuiUpdate.trigger(acLog);

    }

}

 

3. The Lifetime Manager: CEvent

class CEvent {
    Subscription subscribe(Callback callback) {
        callbacks_.emplace_back(/*...*/);
        return Subscription(weakSelf, id);
    }
    
    ~Subscription() {
        if (auto event = event_.lock()) 
            event->unsubscribe(id_);
    }
};

This RAII-based solution solves critical lifetime issues:

·        Key InnovationSubscription objects auto-unsubscribe

·        Lazy Cleanup: Marks entries inactive instead of immediate removal

·        Memory Overhead: ~40 bytes per callback (ID, active flag, weak_ptr)

·        Safety: Prevents dangling pointers via weak_ptr

The Lazy Cleanup Trade-off:
If triggers are infrequent, unsubscribed callbacks accumulate as "dead entries," causing:

·        Memory bloat (up to 2-3× actual needed space)

·        Slower triggers (iterating dead entries)

·        Cleanup spikes when finally triggered

Mitigation Strategy:

// Periodic manual cleanup
void cleanup() {
    callbacks_.erase(std::remove_if(/*...*/), callbacks_.end());
}

Use Case: Single-threaded applications with dynamic callback lifetimes like UI systems.

 

4. The Thread-Safe Powerhouse: CEventSafe

class CEventSafe {
    Subscription subscribe(Callback callback) {
        std::lock_guard<std::mutex> lock(mutex_);
        // ...
    }
    
    void trigger(Args... args) {
        std::vector<std::shared_ptr<CallbackEntry>> activeEntries;
        {
            std::lock_guard<std::mutex> lock(mutex_);
            // Copy active entries
        }
        // Execute outside lock
    }
};

This industrial-grade solution adds full thread safety:

·        Concurrency Strategy:

o   Mutex-protected subscription/unsubscription

o   Callback execution outside locks

o   Shared ownership via shared_ptr

·        Memory Overhead: ~64 bytes per callback (mutex, control blocks)

·        Throughput Impact: 10-25% slower than non-threaded versions

Critical Optimization:
The dual-phase trigger prevents lock contention during callback execution:

1.     Locked Phase: Quickly copy active entries

2.     Unlocked Phase: Execute callbacks safely


You may find the full source codes in GitHub.

 

Part 2: Design Evolution Insights

1.     Safety Progression:

o   Level 0: No lifetime management (CSimpleEvent)

o   Level 1: Static thread safety (CGlobalEvent)

o   Level 2: Dynamic ownership (CEvent)

o   Level 3: Full concurrency (CEventSafe)



2.     The Cleanup Paradox:
Eager removal risks iterator invalidation; lazy cleanup risks memory bloat. The optimal solution depends on your event's subscription/trigger ratio.

 

When to Use Which

1.     Embedded SystemsCSimpleEvent (predictable callbacks)

2.     Service InitializationCGlobalEvent (one-time setup)

3.     Desktop ApplicationsCEvent (dynamic UI events)

4.     Server BackendsCEventSafe (high-concurrency APIs)

The Future: Beyond These Implementations

Modern enhancements could include:

·        Lock-free trigger operations

·        Callback prioritization

·        One-shot subscriptions

·        Subscription pools (reuse IDs)

Conclusion

From CSimpleEvent's 24-byte overhead to CEventSafe's robust threading model, we've seen how event handlers evolve to address real-world constraints. The simplest solution isn't "worse"—it's contextually optimal. Understanding these implementations' trade-offs empowers you to select the right event handling strategy for your project's specific needs, balancing performance, safety, and resource consumption in an increasingly concurrent programming landscape.

"Good design is about making wise trade-offs, not chasing perfect solutions." - Chandler Carruth


This article was enhanced with DeepSeek.

No comments:

Post a Comment

Building on Event Handlers: Implementing Delayed Triggers in C++

Introduction Earlier, I explained  four foundational event handler patterns  in C++—from basic callbacks to observer systems ( https://a5w...