Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 76

Building an Event Loop

Recommended resources

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::function to create a generic task queue.
  • A complete, modern C++ implementation of an event loop class.
  • A deep dive into the synchronization mechanics using std::mutex and std::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:

  1. Type Erasure: std::function "erases" the specific type of the callable it holds. A std::function<void()> can hold a lambda [] { ... } or a function pointer void 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.
  2. Small-Object Optimization (SOO): To avoid performance penalties from frequent heap allocations, std::function includes 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::function falls 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.

Idiomatic Event Loop in C++

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 EventLoop object spawns its own dedicated std::thread.
  • Other threads can submit tasks by calling the public enqueue method.
  • enqueue places tasks into a std::vector (m_writeBuffer), which serves as our task queue.
  • The m_mutex and m_condVar are 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:

  1. Waiting with std::condition_variable::wait: This is the most critical part for efficiency.

    • The thread acquires a std::unique_lock on the mutex.
    • It calls wait. The wait function 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), wait puts the thread back to sleep.
  2. The Double-Buffer Swap: Instead of processing tasks while holding the lock, the code performs a lightning-fast std::swap. std::swap on 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 to enqueue tasks are blocked for a minimal amount of time.

  3. Executing Tasks: The loop can now safely process all the tasks in its local readBuffer without holding any locks, allowing other threads to queue new tasks into the now-empty m_writeBuffer concurrently.

  4. Clearing the Buffer: readBuffer.clear() destroys the std::function objects, but importantly, std::vector typically 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();
}
  1. A std::lock_guard is 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 is noexcept).
  2. After releasing the lock, notify_one() is called. This wakes up the threadFunc if it's sleeping in m_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 enqueue and threadFunc is synchronized using std::mutex and std::condition_variable.
  • std::condition_variable::wait allows 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.