Einleitung: Warum Nebenläufigkeit in C++ nicht beängstigend, sondern interessant ist?
\n\nVor langer Zeit, im Zeitalter der Single-Core-Prozessoren, wurden Programme streng sequenziell ausgeführt. Heute hat selbst Ihr Smartphone 8 Kerne und Server haben Dutzende. All diese Kerne für sich arbeiten zu lassen – darin liegt die wahre Magie der Leistungssteigerung. Und C++, eine der mächtigsten und hardwarenächsten Sprachen, gibt uns dafür eine ganze Reihe von Werkzeugen an die Hand. Aber wie verliert man nicht den Überblick? Threads, Coroutinen (oft in Anlehnung an Go als „Goroutinen“ bezeichnet), async/await – was soll man wählen?
\n\nIn diesem Artikel ordnen wir die moderne Nebenläufigkeit in C++ systematisch ein. Sie erfahren, wann Sie klassische Threads und wann Sie die leichtgewichtigen Coroutinen von C++20 einsetzen sollten und wie async/await die asynchrone Programmierung vereinfacht. Los geht's!
\n\nDer Klassiker: Multithreading mit std::thread
\n\nBeginnen wir mit dem Offensichtlichsten – den Threads. std::thread bietet direkten Zugriff auf die System-Threads des Betriebssystems. Jeder Thread ist ein eigener Ausführungskontext, der auf einem separaten CPU-Kern arbeiten kann.
Einfaches Beispiel: Parallele Berechnung
\n\nStellen Sie sich vor, Sie müssen die Summe der Quadrate der Zahlen von 1 bis 10 Millionen berechnen. Sequenziell dauert das lange. Lassen Sie uns parallelisieren:
\n\n#include <iostream>\n#include <thread>\n#include <vector>\n#include <numeric>\n\nvoid sum_squares(int start, int end, long long& result) {\n result = 0;\n for (int i = start; i <= end; ++i) {\n result += i * i;\n }\n}\n\nint main() {\n const int N = 10'000'000;\n const int NUM_THREADS = 4;\n std::vector<std::thread> threads;\n std::vector<long long> results(NUM_THREADS, 0);\n \n int chunk = N / NUM_THREADS;\n for (int i = 0; i < NUM_THREADS; ++i) {\n int start = i * chunk + 1;\n int end = (i == NUM_THREADS - 1) ? N : (i + 1) * chunk;\n threads.emplace_back(sum_squares, start, end, std::ref(results[i]));\n }\n \n for (auto& t : threads) {\n t.join();\n }\n \n long long total = std::accumulate(results.begin(), results.end(), 0LL);\n std::cout << "Summe der Quadrate: " << total << std::endl;\n return 0;\n}\n\n\nHier erstellen wir 4 Threads, von denen jeder seinen Teil des Bereichs berechnet. Die Ergebnisse sammeln wir in einem Vektor. Alles einfach und vorhersagbar. Aber es gibt Nuancen:
\n\n- \n
- Overhead: Das Erstellen jedes Threads ist teuer (Stack-Zuweisung, Systemaufrufe). Für Tausende kleiner Aufgaben ist das ineffizient. \n
- Synchronisation: Wenn Threads auf gemeinsame Daten zugreifen, werden Mutexe benötigt, die Blockierungen verursachen und die Leistung beeinträchtigen können. \n
- Begrenzte Anzahl: Die Anzahl der Threads sollte normalerweise die Anzahl der CPU-Kerne (unter Berücksichtigung von Hyper-Threading) nicht überschreiten. \n
Für schwere, langwierige Berechnungen sind Threads eine ausgezeichnete Wahl. Aber für I/O-Operationen (Dateilesen, Netzwerkanfragen) sind sie oft überdimensioniert. Hier kommen Coroutinen ins Spiel.
\n\nDie Revolution C++20: Coroutinen (Goroutinen)
\n\nCoroutinen in C++20 sind nicht nur ein weiteres Feature. Sie sind eine grundlegende Veränderung in der Art und Weise, wie wir asynchronen Code schreiben. Im Gegensatz zu Threads sind Coroutinen leichtgewichtige Koroutinen, die ihre Ausführung anhalten und später wieder aufnehmen können, ohne den OS-Thread zu blockieren.
\n\nViele Entwickler nennen sie aufgrund der Ähnlichkeit mit Goroutinen in Go „Goroutinen“, obwohl es sich technisch um unterschiedliche Konzepte handelt. Der Hauptvorteil: Sie können Hunderttausende von Coroutinen auf einem einzigen Thread ausführen, ohne Leistungseinbußen.
\n\nWie funktioniert das?
\n\nEine Coroutine ist eine Funktion, die das Schlüsselwort co_await, co_return oder co_yield enthält. Der Compiler wandelt sie automatisch in einen endlichen Automaten um. Hier ist ein einfaches Beispiel:
#include <iostream>\n#include <coroutine>\n#incl