Arbeiten mit Aufgabenwarteschlangen in C++: vom Einfachen zum Komplexen
In der modernen C++-Entwicklung kommt man selten ohne asynchrone Datenverarbeitung aus. Aufgabenwarteschlangen (task queues) sind eines der Schlüsselwerkzeuge zur Organisation paralleler Codeausführung. Sie ermöglichen es, Arbeit auf Threads zu verteilen, Blockaden zu vermeiden und Prozessorressourcen effizient zu nutzen. In diesem Artikel werden wir uns ansehen, wie man eine Aufgabenwarteschlange in C++ implementiert, das Producer-Consumer-Muster betrachten und lernen, einen einfachen Thread Pool zu erstellen.
1. Einfache Warteschlange basierend auf std::queue
Die einfachste Aufgabenwarteschlange ist ein Container, in den Elemente (Aufgaben) hinzugefügt und in der FIFO-Reihenfolge entnommen werden können. In C++ eignet sich dafür hervorragend std::queue aus der Standardbibliothek. Bei der Arbeit mit Multithreading ist eine gewöhnliche Warteschlange jedoch unsicher: Gleichzeitiger Zugriff aus verschiedenen Threads führt zu Datenrennen.
Betrachten wir ein einfaches Beispiel einer synchronisierten Warteschlange unter Verwendung eines Mutex und einer Bedingungsvariablen:
#include <queue>#include <mutex>#include <condition_variable>#include <functional>
class TaskQueue {public: using Task = std::function<void()>;
void push(Task task) { std::lock_guard<std::mutex> lock(mutex_); queue_.push(std::move(task)); cond_var_.notify_one(); }
Task pop() { std::unique_lock<std::mutex> lock(mutex_); cond_var_.wait(lock, [this]() { return !queue_.empty(); }); Task task = std::move(queue_.front()); queue_.pop(); return task; }
bool empty() const { std::lock_guard<std::mutex> lock(mutex_); return queue_.empty(); }
private: std::queue<Task> queue_; mutable std::mutex mutex_; std::condition_variable cond_var_;};In diesem Code fügt push() eine Aufgabe hinzu und benachrichtigt einen wartenden Thread. pop() blockiert den Thread, bis die Warteschlange nicht mehr leer ist. Dieser Ansatz löst das Synchronisationsproblem, skaliert aber ohne Anpassungen nicht auf viele Verbraucher.
2. Das Producer-Consumer-Muster
Die klassische Producer-Consumer-Aufgabe (Erzeuger-Verbraucher) lässt sich ideal auf eine Aufgabenwarteschlange anwenden. Ein oder mehrere Erzeuger-Threads generieren Aufgaben, und Verbraucher-Threads führen sie aus. Es ist wichtig, die Größe der Warteschlange richtig zu verwalten, um Überlauf oder Leerlauf zu vermeiden.
Beispiel mit mehreren Verbrauchern:
#include <thread>#include <vector>#include <iostream>
void worker(TaskQueue& queue, int id) { while (true) { auto task = queue.pop(); if (!task) break; // Signal zum Beenden std::cout << "Worker " << id << " executing task"; task(); }}
int main() { TaskQueue queue; std::vector<std::thread> workers;
// Starte 4 Verbraucher-Threads for (int i = 0; i < 4; ++i) { workers.emplace_back(worker, std::ref(queue), i); }
// Erzeuger fügt Aufgaben hinzu for (int i = 0; i < 10; ++i) { queue.push([i]() { std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::cout << "Task " << i << " done"; }); }
// Signal zum Beenden (nullptr Aufgabe) for (auto& w : workers) { queue.push(nullptr); }
for (auto& w : workers) { w.join(); } return 0;}Hier erhält jeder Verbraucher eine Aufgabe, führt sie aus und wartet auf die nächste. Das Beendigungssignal (nullptr) stoppt die Schleife. Dieses Muster wird häufig in Serveranwendungen, Ereignisbehandlern und Spiel-Engines verwendet.
3. Thread Pool — ein Pool von Threads für die Aufgabenwarteschlange
Ein Thread Pool (Thread-Pool) ist ein Überbau über einer Aufgabenwarteschlange, der die ```