Hello! In our last lesson, we compared three high-level models for asynchronous programming: callbacks, futures, and message passing. We established that no matter which model you choose, you need an "engine" to execute the asynchronous work.
Today, we're going to build that engine. This lesson directly addresses the learning outcome: Implement a simple event loop to process tasks or I/O events from a queue. An event loop is a fundamental pattern in concurrent and asynchronous programming, found everywhere from GUI toolkits to web servers, and it will serve as the core logic for each worker thread in the thread pool we'll build for your portfolio project.
We will cover:
- The basic concept of an event loop.
- How to use
std::functionto create a generic task queue. - A complete, modern C++ implementation of an event loop class.
- A deep dive into the synchronization mechanics using
std::mutexandstd::condition_variable.
The Core Idea: A Loop and a Queue
At its heart, an event loop is simple: it's a loop that runs in a single thread, pulling tasks from a queue and executing them one by one. This serializes execution, which is a powerful way to manage access to resources that are not thread-safe.
To explore this concept from first principles, let's look at a guide that builds up the idea from a bare-metal perspective.
Event Loop | Practical Guide to Bare Metal C++
The article 'Event Loop' from the 'Practical Guide to Bare Metal C++' starts with the most basic event-driven concepts and evolves them into a modern C++ approach. This will help you understand the 'why' behind the design.
Please read the initial part of the document. Start with the introduction and the first example using a global flag. Then, read through the critique of that method and the evolution to a queue of event IDs. Finally, focus on the section that introduces the modern C++ approach using std::list of std::function objects. This progression shows why a generic queue of callables is the superior design.
As the article demonstrates, early approaches using global flags or queues of simple IDs are brittle. The real power comes from creating a queue of callable objects. In modern C++, the standard tool for this is std::function.
std::function: The Universal Task Wrapper
std::function is a polymorphic function wrapper. It can store, copy, and invoke any callable target—a regular function pointer, a lambda, an object with operator(), or the result of std::bind.
This is possible because of two powerful C++ idioms that are important to understand, especially given your interest in "under the hood" mechanics:
- Type Erasure:
std::function"erases" the specific type of the callable it holds. Astd::function<void()>can hold a lambda[] { ... }or a function pointervoid foo()without the container (like our task queue) needing to know the original type. It achieves this by using virtual functions internally, similar to how we achieve runtime polymorphism with classes. - Small-Object Optimization (SOO): To avoid performance penalties from frequent heap allocations,
std::functionincludes a small internal buffer. If the callable object (like a lambda with a few captured variables) is small enough to fit in this buffer, no dynamic memory allocation is needed. If it's larger,std::functionfalls back to allocating memory on the heap.
By using a queue of std::function<void()> objects, we can create a generic task queue capable of holding any piece of work we want to execute later.
A Modern C++ Event Loop Implementation
Now, let's look at a complete, idiomatic C++11 implementation of an event loop. We will dissect this class piece by piece.
The article 'Idiomatic Event Loop in C++' provides a compact and highly efficient implementation that we will use as our reference. It leverages modern C++ features to build a clean and robust event loop.
First, carefully study the full source code for the EventLoop class presented in the section 'The Basic Implementation'. We will break down its key parts next.
Let's break down the class members and the threadFunc method.
class EventLoop {
public:
using callable_t = std::function<void()>;
// ... Constructor, Destructor, enqueue method ...
private:
std::vector<callable_t> m_writeBuffer; // The task queue
std::mutex m_mutex; // Protects the queue
std::condition_variable m_condVar; // For efficient waiting
bool m_running{ true }; // For graceful shutdown
std::thread m_thread{ &EventLoop::threadFunc, this }; // The dedicated thread
void threadFunc() noexcept; // The core loop logic
};
The design is straightforward:
- An
EventLoopobject spawns its own dedicatedstd::thread. - Other threads can submit tasks by calling the public
enqueuemethod. enqueueplaces tasks into astd::vector(m_writeBuffer), which serves as our task queue.- The
m_mutexandm_condVarare used to safely coordinate access to this queue between the calling threads and the event loop's own thread.
The Heart of the Loop: threadFunc
The threadFunc is where the event processing happens. It's an infinite loop that waits for tasks, executes them, and repeats.
Let's analyze the implementation from the article LINK:
void EventLoop::threadFunc() noexcept {
std::vector<callable_t> readBuffer; // A temporary buffer
while (m_running) {
// --- Critical Section: Acquire tasks ---
{
std::unique_lock lock(m_mutex);
// 1. Wait until the write buffer is not empty
m_condVar.wait(lock, [this] { return !m_writeBuffer.empty() || !m_running; });
// If woken up to stop, exit the loop
if (!m_running) break;
// 2. Swap buffers to minimize time holding the lock
std::swap(readBuffer, m_writeBuffer);
} // Lock is released here
// --- Non-critical Section: Execute tasks ---
// 3. Process all acquired tasks
for (callable_t& func : readBuffer) {
func();
}
// 4. Clear the buffer for the next iteration
readBuffer.clear();
}
}
This implementation is very clever. Let's break down the key steps:
-
Waiting with
std::condition_variable::wait: This is the most critical part for efficiency.- The thread acquires a
std::unique_lockon the mutex. - It calls
wait. Thewaitfunction atomically releases the lock and puts the thread to sleep. This is crucial—it doesn't waste CPU cycles spinning while waiting for work. - When another thread calls
m_condVar.notify_one(), this thread wakes up, re-acquires the lock, and checks the predicate (!m_writeBuffer.empty()). - The predicate is essential to handle spurious wakeups—a rare but possible scenario where the thread wakes up without a notification. If the predicate is false (the buffer is still empty),
waitputs the thread back to sleep.
- The thread acquires a
-
The Double-Buffer Swap: Instead of processing tasks while holding the lock, the code performs a lightning-fast
std::swap.std::swapon vectors just swaps internal pointers; it doesn't copy the elements. This means the critical section (the time the mutex is locked) is incredibly short. Other threads trying toenqueuetasks are blocked for a minimal amount of time. -
Executing Tasks: The loop can now safely process all the tasks in its local
readBufferwithout holding any locks, allowing other threads to queue new tasks into the now-emptym_writeBufferconcurrently. -
Clearing the Buffer:
readBuffer.clear()destroys thestd::functionobjects, but importantly,std::vectortypically does not deallocate its underlying memory. This means the next time buffers are swapped, the memory is already available and can be reused, avoiding repeated allocations.
This pattern of wait, swap, and execute is fundamental to high-performance worker threads. You can see the same core logic in this video about thread pools.
How to write Thread Pools in C++
The video 'How to write Thread Pools in C++' shows the worker logic for a thread pool. Notice the similarity in the while loop structure.
Watch the section from 03:03 to 05:25, which describes the thread_worker function. Pay close attention to the while loop, the condition_variable.wait() call with a lambda, and the processing of the task from the queue. This is another implementation of the same event loop pattern.
Submitting Work: The enqueue Method
The enqueue method is the public-facing API for submitting a task to the event loop.
void EventLoop::enqueue(callable_t&& callable) noexcept {
// 1. Acquire lock to safely access the queue
{
std::lock_guard guard(m_mutex);
m_writeBuffer.emplace_back(std::move(callable));
} // Lock is released here
// 2. Notify one waiting thread
m_condVar.notify_one();
}
- A
std::lock_guardis used to acquire the mutex. This is an RAII-style lock that guarantees the mutex is released when the guard goes out of scope, even if an exception occurs (though this method isnoexcept). - After releasing the lock,
notify_one()is called. This wakes up thethreadFuncif it's sleeping inm_condVar.wait(). As noted in the LINK article, notifying after releasing the lock can be more efficient as it avoids the "hurry up and wait" problem, where the woken thread immediately tries to acquire the lock but can't because the notifying thread still holds it.
Test your understanding!
In the threadFunc implementation, what are the performance and correctness implications if we skip the double-buffering technique? Imagine we instead did this:
void threadFunc_simplified() {
while (m_running) {
std::function<void()> task;
{
std::unique_lock lock(m_mutex);
m_condVar.wait(lock, [this] { return !m_writeBuffer.empty() || !m_running; });
if (!m_running) break;
// Pop one task and hold the lock while executing
task = std::move(m_writeBuffer.front());
m_writeBuffer.erase(m_writeBuffer.begin());
// Execute task inside the critical section
task();
} // Lock is released here
}
}
What is the major problem with this simplified approach?
Show answer
The major problem is lock contention and reduced throughput.
By executing the task() inside the critical section (while the mutex is locked), the event loop blocks any other thread from calling enqueue. If task() is a long-running operation, the entire event loop becomes unresponsive, and producer threads will stall.
The double-buffering technique is designed specifically to solve this. It minimizes the lock duration to a near-instantaneous std::swap, allowing the event loop to execute tasks concurrently with other threads queueing up new tasks. This dramatically increases the overall throughput of the system.
Conclusion
You have now seen how to implement a simple yet powerful event loop from scratch in modern C++. This single component encapsulates many of the concurrency concepts you've been learning about and is a vital piece of infrastructure.
Key Takeaways:
- An event loop is a design pattern that processes events or tasks from a queue in a single thread.
std::vector<std::function<void()>>provides a generic, type-safe queue for arbitrary tasks.- The producer-consumer relationship between
enqueueandthreadFuncis synchronized usingstd::mutexandstd::condition_variable. std::condition_variable::waitallows the consumer thread to sleep efficiently without wasting CPU cycles.- Performance optimizations like double-buffering are crucial for minimizing lock contention and maximizing throughput in a real-world system.
Next Lesson Preview:
This event loop is the blueprint for a single worker thread. In our next module, we will begin the main portfolio project: Building a C++ Thread Pool. We will scale this concept up, creating a pool of multiple worker threads that all consume tasks from a single, shared, thread-safe queue. You will define the public API for the thread pool and design its internal architecture.