- Verwendete Werkzeuge
- Allgemeines
- Klasse
std::functionoderstd::move_only_function - Konzeption einer
enqueue-Methode an der KlasseEventLoop - Funktionen ohne Parameter in der Ereigniswarteschlange
- Funktionen mit Parametern in der Ereigniswarteschlange
std::invokeverwenden oder nicht?- Doppelpuffertechnik (Double Buffering)
- Beendigung der Ausführung
- Ein Beispiel: Berechnung von Primzahlen
- Literaturhinweise
std::functionundstd::move_only_functionstd::mutexstd::lock_guardundstd::unique_lockstd::condition_variablestd::jthreadstd::swap
EventLoop.h
EventLoop.cpp
TestEventLoop.cpp
Program.cpp
Kurz gefasst:
Eine Ereigniswarteschlange (engl. Event Loop ) kann man als Alternative zu einem Mutex-Objekt betrachten. Beide serialisieren Zugriffe auf kritische Abschnitte eines Programms, jedoch auf unterschiedliche Weise:
std:mutex-Objekte stellen einen Synchronisationsmechanismus dar, es sind zu diesem Zweck die kritischen Abschnitte zu identifizieren und mit entsprechendenlockbzw.unlock-Aufrufen zu schützen.
Bemerkung: In der Praxis kommen hier entsprechende Hüllenobjekte wie z.B.std::lock_guardzum Zuge.- Reiht man die kritischen Abschnitte in eine Ereigniswarteschlange ein, kann man auf
std:mutex-Objekte verzichten. Die kritischen Abschnitte müssen zu diesem Zweck aber Funktions-(Methoden-)grenzen haben, um sie in eine Ereigniswarteschlange einschleusen zu können.
Generell können die Gründe für den Einsatz dieser Synchronisationsmechanismen unterschiedlicher Natur sein:
- Möglicherweise wurden die Klassen von einem alten Teil eines Softwaresystems geerbt.
- Sie entwerfen gerade neue Klassen, möchten diese aber nicht gleich mit
Synchronisationsmechanismen wie
std:mutex-Objekten überfrachten.
Wir stellen in diesem Abschnitt die Realisierung einer Klasse EventLoop,
die eine Ereigniswarteschlange darstellt. Es ist möglich, Funktionen ohne als auch mit Parametern
in dieser Warteschlange einzureihen. Konzeptionell beabsichtigt hingegen ist der Rückgabetyp void bei allen Funktionen.
Welchen Sinn sollte es ergeben, zu einem späteren Zeitpunkte einer Funktionsausführung ein Ergebnis zu erhalten?
Meiner Meinung nach keinen, deshalb dieser Ansatz.
Es folgen einige Hinweise zur Realisierung.
Um eine Ereigniswarteschlange zu realisieren, benötigt man die Möglichkeit, „Methodenaufrufe” zwischenspeichern zu können. Gewisse Ähnlichkeiten zum Command Pattern aus dem Umfeld der Design Pattern sind hier vorhanden.
Hier kommen zwei Klassen ins Spiel: std::function oder std::move_only_function.
Was ist der Unterschied zwischen diesen beiden Klassen?
std::function<void()> |
std::move_only_function<void()> |
|---|---|
| Kopierbar. | Nur verschiebbar. |
Erfordert, dass die gespeicherte Funktion kopierbar ist (es dürfen in der Funktion z. B. keine std::unique_ptr-Variablen verwendet werden). |
Kann nur verschiebbare Funktionen speichern (z. B. sind std::unique_ptr- oder std::packaged_task-Objekte erlaubt). |
| Kann Speicher auf der Halde anlegen. | Vermeidet unnötige Kopien. |
| Flexibler, aber ressourcenintensiver. | Bessere semantische Eignung für einmalige Aufgaben. |
Tabelle 1: Unterschiede zwischen std::function<void()> und std::move_only_function<void()>.
Welche Situation liegt bei uns im Kontext der Realisierung einer Ereigniswarteschlange vor? Alle Ereignisse
- werden einmalig ausgeführt.
- sind sinnvollerweise nicht zu kopieren.
- werden konzeptionell „verarbeitet”, sie müssen nicht aufgehoben werden.
Dies entspricht perfekt der Move-Only-Semantik.
Damit sollten wir in der Realisierung auf die std::move_only_function<void()>-Klasse zurückgreifen:
using Event = std::move_only_function<void()>;Betrachten Sie ein Beispiel zur Klasse std::move_only_function<>.
Gehen Sie das Beispiel Schritt für Schritt im Debugger durch.
Beobachten Sie dabei, wie das std::unique_ptr<int>-Objekt Anweisung für Anweisung
im Programm verschoben wird:
01: void test()
02: {
03: std::unique_ptr<int> ptr{ std::make_unique<int>(123) };
04:
05: auto moveOnlyLambda{ [capturedPtr = std::move(ptr)] () -> void {
06: std::println("Value inside Lambda: {}", *capturedPtr);
07: }
08: };
09:
10: // moveOnlyLambda is a callable that can only be moved
11: std::move_only_function<void()> func{ std::move(moveOnlyLambda) };
12:
13: func(); // prints "Value inside Lambda: 123"
14: }Die bewährte, idiomatische Vorgehensweise in Modern C++ lautet: Pass-by-Value.
void enqueue(Event callable); // Pass by ValueWas ist an dieser Lösung so gut?
- Funktioniert effizient mit RValues:
eventLoop.enqueue([] () { /* ... */ }); // Works ! Lambda is first moved into the parameter,
// then into the std::vector- Funktioniert mit LValues (allerdings ist explizites Verschieben erforderlich)
Event event = [] { /* ... */ };
eventLoop.enqueue(std::move(event)); // Works ! Clear IntentDa std::move_only_function-Objekte nicht kopiert werden können,
müssen Benutzer beim Übergeben eines vorhandenen Objekts an diese Funktion explizit std::move() aufrufen.
Wie sieht es aus, wenn an die Ereignisfunktion Parameter übergeben werden sollen?
template<typename TFunc, typename ... TArgs>
void enqueueTask(...);
Dies ist äußerst praktisch, da es dem Benutzer erspart,
beim Binden von Argumenten an eine Funktion Lambda-Ausdrücke mit Standard-Boilerplate-Code schreiben zu müssen.
Dieser Boilerplate-Code ist innerhalb der enqueueTask-Methode vorhanden.
Zusammenfassung:
- Einfache und sichere Schnittstelle
- Keine Überladungen
- Entspricht modernen C++-Konventionen
Eine Realisierung der enqueue-Methode sieht so aus:
01: void EventLoop::enqueue(Event callable)
02: {
03: {
04: std::lock_guard<std::mutex> guard{ m_mutex };
05:
06: m_events.push_back(std::move(callable));
07: }
08:
09: m_condition.notify_one();
10: }In der Realisierung wird RAII für das Thread-Sicherheits-Locking eingesetzt und das Callable effizient in den Vektor verschoben, bevor die Methode den wartenden Worker-Thread benachrichtigt.
Welche Funktionen (Rückgabetyp, Parameter) lassen sich in der Ereigniswarteschlange einreihen?
Es sind dies Funktionen mit beliebig vielen Parametern und auch einem beliebigen Rückgabetyp – und dies sogar,
ohne an der vorhandenen Realisierung der Klasse EventLoop Änderungen vornehmen zu müssen.
- Wie könnte dieser Trick aussehen?
- Wie werden die Parameter zwischengespeichert?
Wir greifen auf das C++–Sprachfeature von Lambda-Objekten zurück.
Lambda-Objekte können über die Capture Clause auf Variablen der Umgebung zugreifen
und diese mittels [=] in das Lambda-Objekt kopieren!
Ab C++ 14 kann man sogar auf das unnötige Kopieren der Parameter verzichten, mit dem so genannten „Generalized Lambda Capture” Feature können die Parameter auch verschoben werden, also kann die Move-Semantik Anwendung finden!
Der realisierende Quellcode mag nicht ganz einfach zu lesen zu sein, da er mit Hilfe variadischer Templates eine beliebige Anzahl von Parametern unterschiedlichen Datentyps in das Lambda-Objekt aufnimmt:
01: template<typename TFunc, typename ... TArgs>
02: void enqueueTask(TFunc&& func, TArgs&& ...args)
03: {
04: Logger::log(std::cout, "enqueueTask ...");
05:
06: // using "Generalized Lambda Capture" to preserve move semantics
07: auto callable{
08: [func = std::forward<TFunc>(func),
09: ... capturedArgs = std::forward<TArgs>(args)]() mutable -> void {
10: std::invoke(std::move(func), std::move(capturedArgs)...);
11: }
12: };
13:
14: {
15: // RAII guard
16: std::lock_guard<std::mutex> guard{ m_mutex };
17: m_events.push_back(std::move(callable));
18: }
19:
20: m_condition.notify_one();
21: }In Zeile 8 des Listings finden wir einen Lambda-Ausdruck vor:
Der Aufruf der Nachricht func ist im Rumpf der Lambda-Funktion platziert – mit std::invoke,
das Funktionsobjekt selbst (func) wird via [func = std::forward<TFunc>(func)] in das Lambda-Objekt verschoben!
Dies gilt genauso für die Parameter der Funktion, nur kommt hier syntaktisch gesehen das so genannte Variadic Capture Sprachfeature hinzu:
[... args = std::forward<TArgs>(args)]Eine weitere Subtilität:
Warum ist die Lambda-Funktion mit dem Schlüsselwort mutable ausgezeichnet?
Wird die Lambda-Funktion nicht als mutable markiert, wird der dazugehörige Aufrufoperator zu
void operator() () const;übersetzt, was bedeutet, dass func innerhalb des Rumpfes effektiv const ist.
Dies verhindert üblicherweise das Verschieben von Funktionen.
Erst durch den Gebrauch von mutable werden Verschiebungen wirksam.
Man könnte die Zeile 10 aus dem letzten Listing
std::invoke(std::move(func), std::move(capturedArgs) ...);auch kürzer so schreiben:
func(std::move(capturedArgs) ...);oder, wenn man func verschieben möchte:
std::move(func) (std::move(capturedArgs) ...);Dennoch sind diese beiden Varianten nicht ganz identisch!
Die Verwendung von std::invoke bietet hier einen Vorteil bezüglich der Flexibilität der Nachrichtenwarteschlange,
wenn man Methoden von Objekten einreihen möchte.
Der direkte Aufruf func(...) funktioniert nur bei „echten” Funktionen.
Dazu zählen reguläre („freie”) Funktionen, Funktionszeiger, Lambdas und aufrufbare Objekte
(Klassen, die den operator() überladen).
Der Gebrauch von std::invoke() akzeptiert zusätzlich zu den oben genannten Typen auch Zeiger auf Elementfunktionen (Member-Funktionen)
und Zeiger auf Datenelemente von Klassen.
Ein Beispiel:
01: struct MyClass
02: {
03: void doSomeWork (int x) { Logger::log(std::cout, "... doing some work ..."); }
04: };
05:
06: void test()
07: {
08: EventLoop loop{};
09:
10: MyClass obj;
11: loop.enqueueTask(&MyClass::doSomeWork, &obj, 123);
12:
13: loop.start();
14: loop.stop();
15: }Dieses Beispiel ist übersetzungs- und lauffähig, wenn man in der enqueueTask-Methode die std::invoke-Funktion verwendet.
Im anderen Fall kommt es zu einem Übersetzungsfehler.
In der Realisierung der Abarbeitung der Nachrichten finden Sie eine Umsetzung der Double Buffering Technik vor.
Es kommen zwei Objekte des Typs std::vector<std::move_only_function<void()>> zum Einsatz:
- Der erste Puffer dient ausschließlich zum Einreihen neuer Nachrichten.
- Der zweite Puffer dient ausschließlich zum Entnehmen vorhandener Nachrichten.
Das Tauschen der Puffer findet innerhalb einer Mutex-Sperre statt, mit der Funktion std::swap werden
die beiden Pufferinhalte möglichst effizient getauscht.
Nach einem Tausch ist der erste Puffer grundsätzlich leer, der zweite Puffer hat den Inhalt des ersten Puffers erhalten und kann nun bei Bedarf bzw. wenn Rechenzeit vorhanden ist, abgearbeitet werden.
Eine grobe Skizzierung der Realisierung der Verarbeitung der Nachrichten in der Warteschlange – inklusive Doppelpuffertechnik – sieht so aus:
01: void event_loop()
02: {
03: std::vector<std::move_only_function<void()>> events;
04:
05: while (true)
06: {
07: {
08: std::unique_lock<std::mutex> guard{ m_mutex };
09:
10: m_condition.wait(
11: guard,
12: [this] () -> bool { return ! m_events.empty() || !m_running; }
13: );
14:
15: if (!m_running && m_events.empty())
16: return;
17:
18: std::swap(events, m_events);
19: }
20:
21: for (auto& callable : events)
22: {
23: callable();
24: }
25:
26: events.clear(); // empty container for next loop
27: }
28: }In Zeile 10 finden wir einen Aufruf der wait-Methode an einem m_condition-Objekt vor.
Hierzu muss es einen korrespondierenden notify_one- oder notify_all-Aufruf geben:
01: void enqueue(std::move_only_function<void()> callable)
02: {
03: {
04: std::lock_guard<std::mutex> guard{ m_mutex };
05: m_events.push_back(std::move(callable));
06: }
07:
08: m_condition.notify_one();
09: }
Sinnigerweise ist dieser Aufruf in der Methode enqueue vorhanden, wenn neue Nachrichten in der
Warteschlange aufgenommen werden.
Wenn die Abarbeitung der Nachrichten beendet werden soll, wird dies dadurch erreicht, dass eine spezielle Nachricht in die Warteschlange am Ende eingefügt wird:
[this] { m_running = false; }Mit diesem Lambda-Ausdruck wird einfach das Flag m_running umgesetzt,
und so die Ausführung der Verarbeitungsprozedur verlassen.
Ein Beispiel zur sequentiellen Berechnung von Primzahlen könnte so aussehen:
01: void test()
02: {
03: Logger::log(std::cout, "Start");
04:
05: std::size_t foundPrimeNumbers{};
06:
07: EventLoop eventLoop;
08:
09: Logger::log(std::cout, "Enqueuing tasks ...");
10:
11: for (std::size_t i{ PrimeNumberLimits::Start }; i < PrimeNumberLimits::End; i += 2) {
12:
13: eventLoop.enqueueTask(
14: [&] (std::size_t value) {
15:
16: bool primeFound{ PrimeNumbers::IsPrime(value) };
17:
18: if (primeFound) {
19: Logger::log(std::cout, "> ", value, " is prime.");
20:
21: ++foundPrimeNumbers;
22: }
23: },
24: i
25: );
26: }
27:
28: Logger::log(std::cout, "Enqueuing tasks done.");
29: Logger::log(std::cout, "Starting Event Loop:");
30:
31: eventLoop.start();
32: eventLoop.stop();
33:
34: Logger::log(std::cout, "Found ", foundPrimeNumbers, " prime numbers between ",
35: PrimeNumberLimits::Start, " and ", PrimeNumberLimits::End, '.'
36: );
37: }Ausgabe:
[1]: Start
[1]: Enqueuing tasks ...
[1]: enqueueTask ...
............
[1]: enqueueTask ...
[1]: enqueueTask ...
[1]: Enqueuing tasks done.
[1]: Starting Event Loop:
[2]: > Event Loop
[2]: swapped 51 event(s) ...
[2]: ! invoking next event
[2]: ! invoking next event
[2]: > 1000000000000000003 is prime.
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
[2]: > 1000000000000000009 is prime.
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
............
[2]: ! invoking next event
[2]: ! invoking next event
[2]: > 1000000000000000031 is prime.
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
............
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
[2]: > 1000000000000000079 is prime.
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
............
[2]: ! invoking next event
[2]: ! invoking next event
[2]: ! invoking next event
[2]: < Event Loop
[1]: Found 4 prime numbers between 1000000000000000001 and 1000000000000000101.
[1]: Done.
[1]: Elapsed time: 4642 [milliseconds]
Der Blick auf den Task Manager lässt erkennen, dass ein Kern sehr stark und ein zweiter Kern etwas weniger an der Ausführung des Programms beteiligt sind:
Die Anregungen zur Klasse EventLoop stammen im Wesentlichen aus dem Artikel
von Anton Vasin.
