Event Queue, Message Bus and Dispatcher
NOTE: I will use the terms event and message interchangeably. In some contexts there may be a minor differentiation but for this discussion it will be ignored.
Event Queue or Message Bus
Definition:
Decouple when a message or event is sent from when it is processed. - R. Nystrom
What's the easiest way to manage the connections between event listeners and event producers? You guessed it, “Event Queues”.
When an event occurs, such as user input, or an entity sending an event to another entity, it needs to be stored somewhere such that it's not lost while in transit between the source that reported the event and when the program gets around to respond to it. That “somewhere” is a queue.
New events are added (aka enqueue
) to a queue of unprocessed events. And at a later time when it's convenient in the application we pull off the events from the queue (aka dequeue
) and respond to them by calling onEventReceived()
on the receiving party.
Design
- Create a queue of Events
- Create an
Event
Class, which contains everything a 'message envelope' contains - The head of the queue is where requests are read from. The head is the oldest pending event to be received (remember FIFO).
- The queue's tail is where we add, or enqueue, new events.
- to read messages, a class (Entity here) has to implement
onMessageReceived
Event Dispatcher
You can think of an event dispatcher as the conduit through which any module or any subsystem can speak to any other within a larger system of components, without any two components having to know each other.
In reference to our video game example, there are various different entities that interact with each other. Those interactions tend to be very dynamic and deeply connected with gameplay. This tutorial covers the concept and implementation of a message queue system that can unify the interactions of entities, making your code manageable and easy to maintain as it grows in complexity.
In this approach, communication among game entities becomes unified. All entities can send and receive messages. No matter how complex or peculiar the interaction or message is, the communication channel always remains the same. On the other hand, the destination will deal with the message based on its content and on who the sender is.
Basically the event dispatcher is the directory of the event queue. It owns & manages their dispatching. In our code, the MessageDispatcher
owns the MessageBus
and it's the only one that can call its functions.
What will the Message class contain?
We must allow the entities to pass different kinds of data between them, so the messaging system should be able to support messages with any type of payload. The payload data could be relatively large, so it shouldn't be copied around. Only its ownership should be passed from sender to the queue, and from the queue to the receiver.
I concluded that in the general case the following items should be sufficient:
- pointer or id to source object
- vector of pointers or ids to destination objects
- message type - a flag that identifies the general purpose of the event
- a boolean flag indicating whether the message has been received and handled by the recipient(s)
- message payload - a callback function
MessageCall
or data structMessageData
When should you use it?
When the amount of modules & components of your codebase grows, then it gets hard to reason about all of them thus resorting to an EventQueue
/MessageBus
would be an excellent choice.
Comparison with other event driven design patterns
- In case you only want to decouple who receives an event from its sender, patterns like Observer and Command will take care of this with less complexity. You need an
EventQueue
when you want to decouple something in time. - The receiver can delay processing, aggregate requests, or discard them entirely. But queues do this by taking control away from the sender. All the sender can do is throw a request on the queue and hope for the best. This makes queues a poor fit when the sender needs a response. However, our implementation allows that as well! And it's not hard to expand the Message class, with (say) a boolean value, or another type of message to indicate when the sender requires a response.
Some have criticized event queue systems, because of the global nature of the Queue instance. I resent this accusation, because I believe in a complex system you'll always have at least a little bit of domain-crossing, there's no way around it. As long as you have clearly and nicely defined the queue interface you should be fine.
I believe that the biggest advantage of an Event - Queue + Dispatcher system is that it allows a higher level of abstraction between any kind of modules. And hey, if it's used all the time by the OS, for interprocess communication, for network comms etc. then it can certainly find usability in your own usecase!
Experiments:
You may want to “double buffer” your queue. That is add two of them, one for receiving events, one for processing. When the processing queue is processed switch the two queues around (pointer switch) and unlock. Reports indicate there's a shorter lock time. - Instead of a (single or 2) large queue of events you may want to keep a map of event types to queues of events, ie. instead of queue<Event<T>> q;
use map<EventType, queue<Event<T>> mq;
or map<Event<T>::Destination, queue<Event<T>> mq;
.
Advanced
This project contains a bit more than just the EventQueue
and dispatcher. My original intention was to form the foundation for a gameplay interaction system, allowing the ability of any types of entities to communicate among each other, no matter the kind of modules they're stuck in. As a result there are some nitty gritty details in the code so I will explain the most important of those here.
I. `Operation`
Operation
represents a ready-made callable object replete with the function signature and its arguments, packaged & owned by an std::unique_ptr
, which allows it to be easily inserted and manipulated in any std
container, or used in other manner. Under the hood Operation
utilizes std::function<void>
type erasure to be able to call function of any signature. We create that function using std::bind
for argument packing and hand it over to std::function<void>
. (That may have been a mouthful I know, but remember to take everything one step at a time!)
Using std::function
seems to be the only complete way at the time of this writing (2020 C++17) to wrap std::bind
functions and store them in a container.
II. Lifetime of Objects in the Queue
- Pass ownership: This is the traditional way to do things when managing memory manually. When an event gets queued, the queue claims it and the sender no longer owns it. When it gets processed, the receiver takes ownership and is responsible for deallocating it. This is what we do in this tutorial and code.
- Share ownership: These days, now that even C++ programmers are more comfortable with garbage collection, shared ownership is more acceptable. With this, the event sticks around as long as anything has a reference to it and is automatically freed when forgotten.
- The queue owns it: Another option is to have messages always live on the queue. Instead of allocating the event itself, the sender requests a 'fresh' one from the queue. The queue returns a reference to an event already in memory inside the queue and the sender fills it in. When the event gets processed, the receiver refers to the same event in the queue.
III. MessageBus / EventQueue thread safety measures
The careful reader has noticed from the code that MessageBus
is thread safe. The way this is done can be summed up by the following points: unique_lock
in dequeue
which forces the thread to wait/sleep, using the condition variable, while there's no messages in the Queue. While you wait using a condition_variable
you can relinquish control to any other threads trying to enqueue
. Using a lock_guard
wouldn't cut it; somebody wouldn't be able to enqueue and the program would deadlock.
I used Windows and Visual Studio 2017 & C++17 to build the project.
Github
Github repository link.
Acknowledgements
Event Queue by Robert Nystrom.
SO answer.