Event loop in C++

Once the events are fully processed, the semaphore value is reduced to zero and the event loop becomes blocked once again. Your code mandates that each event handler must handle the event.


Question:

To create an EventLoop in C++, certain expectations must be met from the user’s point of view.

  1. The usability of the product should be comparable to that of the Javascript
    event loop
    .
  2. The API is straightforward and doesn’t require intricate event handler registration or type casting.
  3. The reference to the appropriate event type is passed to event handlers.
  4. Enabling the handlers to register and inject events within themselves would prevent any potential race conditions.
  5. By creating a class that is derived from Event, users can define events without having to implement a function or perform any action within the class (which should be a POCO).
  6. header only

From a maintainer perspective:

  1. Minimize the use of dynamic allocation, pointers, and
    dynamic cast
    for static implementation.
  2. Maximizing efficiency by minimizing object copies.
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
// Pre-declaration
class Message;
class Event {};
// Represent a function that will be called with an event.
typedef std::function EventHandler;
// Utility function for our Event map.
template
auto get_event_type() -> std::type_index {
  return std::type_index(typeid(T));
}
// User defined event. POCO!
class KeyDownEvent: public Event {
public:
  KeyDownEvent(int _key): key(_key) {}
  int key;
};
// Another user defined event.
class TimeoutEvent: public Event {
public:
  TimeoutEvent() {}
};
// A message is an event and its associated event handler. Designed inspired by
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop.
class Message {
public:
  template 
  Message(EventType &&event, EventHandler event_handler) :
    m_event(std::make_unique(std::move(event))),
    m_event_handler(event_handler) {}
  template 
  Message(Message&& message) :
    m_event(std::move(message.m_event)),
    m_event_handler(message.m_event_handler) {}
  std::unique_ptr m_event;
  EventHandler m_event_handler;
};
class EventLoop {
public:
  [[ noreturn ]] void run() {
    while (true) {
      std::unique_lock<:mutex> lock(m_message_queue_mutex);
      // Wait for an event
      m_event_cv.wait(lock, [&]{ return m_message_queue.size() > 0; });
      // Retrieve the injected event
      Message message = std::move(m_message_queue.front());
      m_message_queue.pop();
      // Unlock before notify, is this necessary? Where did I saw that?
      lock.unlock();
      m_event_cv.notify_one();
      // Call the event listener
      message.m_event_handler(*message.m_event.get());
    }
  }
  template
  void add_event_listener(std::function event_listener) {
    std::lock_guard<:mutex> guard(m_event_listeners_mutex);
    std::type_index event_type = get_event_type();
    // If the event_type as no listener yet...
    if (m_event_listeners.find(event_type) == m_event_listeners.end()) {
      // ... create the vector of listeners
      m_event_listeners[event_type] = {
        [&event_listener](Event& e) { event_listener(static_cast(e)); }
      };
    } else {
      // ... or append to the existing one.
      m_event_listeners[event_type].push_back([&event_listener](Event& e) {
        event_listener(static_cast(e));
      });
    }
  }
  // Convert an event to a message to be handled by the event loop.
  template 
  void inject_event(EventParamsType&&... event_params) {
    std::lock_guard<:mutex> listener_guard(m_event_listeners_mutex);
    std::vector& listeners = m_event_listeners[get_event_type()];
    std::lock_guard<:mutex> event_guard(m_message_queue_mutex);
    std::for_each(listeners.begin(), listeners.end(), [this, &event_params...](EventHandler &listener) {
      EventType event = EventType(std::forward(event_params)...);
      m_message_queue.emplace(std::move(event), listener);
    });
    m_event_cv.notify_one();
  }
private:
  std::queue m_message_queue;
  std::mutex m_message_queue_mutex;
  std::condition_variable m_event_cv;
  std::map<:type_index std::vector>> m_event_listeners;
  std::mutex m_event_listeners_mutex;
};

Could be used this way:

int main() {
  EventLoop eventLoop;
  std::thread mainThread(&EventLoop::run, &eventLoop);
  // Register event handlers
  eventLoop.add_event_listener([](KeyDownEvent& event) {
    std::cout << "*" << event.key << "*" << std::endl;
  });
  eventLoop.add_event_listener([](TimeoutEvent&) {
    std::cout << "timeout!" << std::endl;
  });
  do {
    int key = getchar();
    if (key != 10) {
      eventLoop.inject_event(key);
    }
  } while (true);
}

The

EventLoop

represents an occurrence that is essentially a

Reactor

.


Solution 1:

template 
Message(EventType &&event, EventHandler event_handler):
    m_event(std::make_unique(std::move(event))),
    m_event_handler(event_handler) {}

It appears unnecessary to demand an r-value for

event

in this case; instead, it may be better to pass it by value.


  template 
  Message(Message&& message) :
    m_event(std::move(message.m_event)),
    m_event_handler(message.m_event_handler) {}

It is incorrect for the move constructor to have a template parameter.


  template
  void add_event_listener(std::function event_listener) {
    ...
    if (m_event_listeners.find(event_type) == m_event_listeners.end()) {
        m_event_listeners[event_type] = {
            [event_listener](Event& e) { event_listener(static_cast(e)); }
        };
    }
    else {
        m_event_listeners[event_type].push_back([&event_listener] (Event& e) {
            event_listener(static_cast(e));
        });
    }
  }

The bug identified in

event_listener

can be resolved by capturing the variable by value instead of reference in the lambdas. This will ensure that the local variable is still available when the reference is used, as it won’t be marked as dead.

Please be aware that it is unnecessary to use both

find

and two distinct branches. Utilizing

operator[]

alone will insert an unoccupied vector, thus enabling us to simply perform the following action:

    m_event_listeners[event_type].push_back([event_listener] (Event& e) {
        event_listener(static_cast(e));
    });

[[noreturn]] void run() {
    while (true) {
        ...
        m_event_cv.notify_one();
        ...
    }
}

What is the purpose of utilizing

m_event_cv.notify_one()

in this context? Is it intended to facilitate the calling of

run()

by multiple threads?

In case they don’t check, they won’t receive the messages as one has been removed from the queue.

In case there are messages on the queue, the next iteration of the

run()

loop will continue with processing without the need to call notify again. This is because the predicate is checked before waiting.


template 
void inject_event(EventParamsType&&... event_params) {

The forwarding process appears to be more intricate than simply providing an argument with

EventType

.


typedef std::function EventHandler;
...
Message(EventType event, EventHandler event_handler):
...
void add_event_listener(std::function event_listener) {
...
    std::for_each(listeners.begin(), listeners.end(), [this, &event](EventHandler &listener) {

It’s important to maintain consistency by selecting and adhering to either “listener” or “handler”.


...
    std::for_each(listeners.begin(), listeners.end(), [this, &event_params...](EventHandler &listener) {
        EventType event = EventType(std::forward(event_params)...);
        m_message_queue.emplace(std::move(event), listener);
    });
...
std::queue m_message_queue;

We could perhaps:

  • Specify the

    Event

    as the input for

    EventHandler

    and declare it as a

    const&

    .
  • Use a queue with

    Event

    rather than a queue with

    Message

    (or a queue with

    std::queue<std::pair<std::unique_ptr<Event>, std::type_index>>

    technically).
  • In the

    run()

    function, trigger the appropriate listeners for the event by locking, duplicating, and then unlocking the code.

With this approach, we can prevent the need for excessive storage and duplication of

Event

. Moreover, instead of having one iteration of

run()

for each listener per event, we would have only one iteration per event.


Solution 2:

Summary

Feel free to ignore opinion:

Personal opinion

While it’s true that the C++ standard library uses “Snake Case”, I personally am not a fan of it and hardly encounter it in C++ projects.

I avoid using “m_” as a prefix for member variables because it suggests that unique and meaningful names have not been given elsewhere. However, this is not necessarily a bad thing. To ensure consistency in naming conventions, it is advisable to include build tools that enforce this convention. Otherwise, half of the code may use “m_” while the other half does not follow the convention.

The indention level of two spaces is too small and difficult to read. I would prefer a minimum of four spaces.

Why do half your functions use:

auto function() -> type

While half use the:

type function()

Consistency in the usage of one form is my preference over a blend of forms. However, in exceptional situations where the first form is a necessity, the second form may be used while the first form remains available.

Code Review

It’s intriguing to consider the necessity of an

Event

base when there are no virtual members present.

class Event {};

The code and comments in the

EventLoop

class are quite heavy, making it difficult to read. In this scenario, the comments actually impede the code’s readability, and thus, they should be eliminated.

  [[ noreturn ]] void run() {
    while (true) {
      std::unique_lock<:mutex> lock(m_message_queue_mutex);
      // Wait for an event
      m_event_cv.wait(lock, [&]{ return m_message_queue.size() > 0; });
      // Retrieve the injected event
      Message message = std::move(m_message_queue.front());
      m_message_queue.pop();
      // Unlock before notify, is this necessary? Where did I saw that?
      lock.unlock();
      m_event_cv.notify_one();
      // Call the event listener
      message.m_event_handler(*message.m_event.get());
    }
  }

To enhance the layout and structure of the code, we can eliminate the comments and insert some white space between the functions. It is worth noting that by utilizing

getNextEvent()

, there is no need for an explicit

unlock()

of the

lock

, as this is already taken care of by

getNextEvent()

that is executed with the destructor of

lock

.

  private:
  Message getNextEvent()
  {
      std::unique_lock<:mutex> lock(m_message_queue_mutex);
      m_event_cv.wait(lock, [&]{ return m_message_queue.size() > 0; });
      Message message = std::move(m_message_queue.front());
      m_message_queue.pop();
      return message;
  }
  public:
  [[ noreturn ]] void run() {
      while (true) {
          Message message = getNextEvent();
          m_event_cv.notify_one();
          message.m_event_handler(*message.m_event.get());
      }
  }

The suitability of using a

noreturn

is uncertain.

  [[ noreturn ]] void run() {

Typically, applications offer an exit option to users. Therefore, when the user chooses to exit, the event loop should terminate as well.


      // Unlock before notify, is this necessary? Where did I saw that?
      lock.unlock();
      m_event_cv.notify_one();

It’s not mandatory; the outcome will be the same regardless.

The question of efficiency depends on the implementation, and it’s difficult to make a definitive judgement. However, unlocking first can prevent possible inefficiencies. While this approach may not offer any additional benefits, it is still the recommended course of action.


This location does not require the use of

else

.

    if (m_event_listeners.find(event_type) == m_event_listeners.end()) {
      m_event_listeners[event_type] = {
        [&event_listener](Event& e) { event_listener(static_cast(e)); }
      };
    } else {
      m_event_listeners[event_type].push_back([&event_listener](Event& e) {
        event_listener(static_cast(e));
      });
    }

I can simplify this too:

    m_event_listeners[event_type].push_back([&event_listener](Event& e) {
        event_listener(static_cast(e));
      });

The reason behind this is that if

m_event_listeners

lacks

operator[]()

, it will automatically add an empty vector.


In order to accomplish the task, it is necessary to transform an

Event

entity into the

EventType

functions. This can be achieved by utilizing

static_cast

.

[&event_listener](Event& e) { event_listener(static_cast(e)); }

While the label “Simple” may suffice for certain scenarios, it may not be suitable in all cases. To ensure universal functionality, the implementation of

dynamic_cast

may be necessary.


There’s nothing I can complain about as

inject_event()

seems fine to me.


In event-driven applications, it is common to observe certain handlers that can consume the event, thereby hindering any further actions by the subsequent handlers based on the same event.

To avoid obliging every event handler to handle the event, you may consider including a member

handled

in the base class

Event

. Thus, the lambda can verify this value before calling the user’s provided handler.

 class Event
 {
     bool handeled;
     public:
     virtual ~Event() {}
              Event(): handeled(false) {}
     bool isHandeled() const {return handeled;}
 };
 // .....
 m_event_listeners[event_type].push_back([&event_listener](Event& e) {
     try {
         if (!e.isHandeled()) {
             event_listener(dynamic_cast(e));
         }
     }
     catch(...) {
         // Event handlers are not written by me so I don't know how
         // they will work. I want to make sure exceptions in their
         // code don't cause the run() method to exit accidentally.
         // Do something to tell user there was an exception.
     }
 });


Solution 3:


Uncertain whether you have an aversion to comments that are not in the language of this community.

typedef std::function EventHandler;

should be

using EventHandler = std::function;

auto get_event_type() -> std::type_index {
  return std::type_index(typeid(T));
}

The trailing return type is unnecessary since

return

has already been typed in.


  Message(EventType &&event, EventHandler event_handler) :
    m_event(std::make_unique(std::move(event))),

As you have already provided

event

as an r-value reference, there is no requirement for

std::move

.


template 
Message(Message&& message) :
    m_event(std::move(message.m_event)),
    m_event_handler(message.m_event_handler) {}

1) As

EventType

doesn’t play a role, there’s no requirement for a template wrapper.
2) To me, it appears to be a conventional move constructor. Therefore, …

Message(Message&&) = default;

Simply omitting any reference to it would suffice.

In addition, if you implement a move constructor according to the rule of five, it is necessary to explicitly specify whether the copy constructor, copy assignment operator, and move assignment operator are allowed.


void run() {
...
m_event_cv.notify_one();

I believe I understand the cause – if two occurrences follow each other rapidly (or more likely, if two messages from the same incident are received),

run()

must cycle through each of them. However, this is not accomplished using

notify_one

, as there is no listener waiting to be notified.

A more effective method would entail duplicating the queue, erasing the original, and executing

for_all

on the replicated version.

It is crucial to ensure that handlers do not impede event creators (

m_message_queue_mutex

) by taking too much time to execute.


message.m_event_handler(*message.m_event.get());

Instead of solely applying operator* to the inside raw pointer, you can safely use unique_ptr to achieve the same result. Simply replace

*message.m_event.get()

with

*message.m_event

to implement this change.


Up until this point, there appear to be no major issues. The errors that have been identified are insignificant, and the framework of the logic is sound.

The concept of a container that holds multiple derived classes in the form of base class pointers is widely known. However, I have recently come across a unique container that holds functions which utilize derived classes through a wrapping function that takes a base reference. This approach is quite ingenious.

Thanks for experience! =)

Frequently Asked Questions

Posted in Uncategorized