Über Performance mit Atomics
Im Zusammenhang mit der zunehmenden Verbreitung von C++11 beobachte ich in der letzten Zeit auch wieder ein gestiegenes Interesse an Atomics. Bei diesen Diskussionen geht es fast immer um die Performance-Verbesserungen, die sich durch deren Einsatz auf Multi-Core-Systemen erzielen lassen. Man sollte aber auch beachten, dass der Einsatz von Atomics die Lesbarkeit und Wartbarkeit eines Programms signifikant beeinträchtigen kann, und dass in vielen Projekten die Entwickler-Effizienz weit wichtiger als die letzte Verbesserung der System-Performance ist.
Aufgrund der Nebenläufigkeit sind Atomics und Speichermodelle in fast allen ernstzunehmenden Programmiersprachen komplex und schwer verständlich. Die meisten Entwickler werden sich damit nicht bis in letzte Detail auseinandersetzen. Mir ist aber wichtig, dass zumindest die Grundlagen bekannt sind, so dass ein Entwickler beurteilen kann, ob ein Einsatz – und damit auch eine intensivere Auseinandersetzung mit dem Thema – im Rahmen eines Projekts in Betracht gezogen werden sollte oder nicht.
Ich unterscheide diesbezüglich drei Kategorien mit zunehmender Komplexität, die ich im folgenden genauer beschreibe: Die erste Kategorie erfordert nur geringe Kenntnisse und ist für die meisten Entwickler geeignet. Die dritte und letzte Kategorie soll hingegen aufzeigen, wie Experten das Maximum aus Atomics und Speichermodellen herausholen, und wie auch der normale Entwickler davon profitiert.
Kritische Abschnitte mit direkter Entsprechung
Die Situation ist einfach, wenn zu kritischen Abschnitten direkte Entsprechungen mit einzelnen Atomic-Operationen existieren. In diesen Fällen können die kritischen Abschnitte einfach ersetzt werden – ohne irgendwelche negativen Auswirkungen – aber natürlich nur unter der Voraussetzung, dass das Programm vor der Transformation auch korrekt war. Dabei ist die Atomic-Operation üblicherweise eine Größenordnung schneller und vermeidet eine mögliche und unnötige Unterbrechung der Threads. Inwiefern sich diese Änderung tatsächlich bei der Ausführung bemerkbar macht, hängt vor allem von dem Anteil ab, den diese Operationen an der Gesamtlaufzeit haben.
Die Atomic-Datentypen in C++11 machen es dem Entwickler besonders einfach, sie auf diese Art und Weise zu verwenden. Für Integer und Zeiger gibt es neben dem üblichen compare_exchange
auch eine ganze Reihe weiterer, atomarer Operationen, so dass die Verwendung in der Regel einfach und natürlich ist.
// define atomic int
std::atomic<int> count{0};
// atomic store and load
count = 42;
int i = count;
// atomic operations
count += 42;
int j = count.exchange(42);
Es bleibt offen, wie viele kritische Abschnitte sich in der Praxis auf diese Art und Weise ersetzen lassen. Ich kenne dazu keine Auswertung, aber ich vermute, dass die möglichen Transformationen einen größeren Effekt haben, als man zunächst glauben mag. Denn es sind doch gerade die kleinen Dinge, die sehr häufig ausgeführt werden. Ein gutes Beispiel sind sicherlich die Referenzzähler für die Speicherverwaltung – beispielsweise in std::shared_ptr
. Diese Operationen werden typischerweise sehr häufig ausgeführt und lassen sich direkt auf Atomic-Datentypen abbilden.
Abseits der Sequential Consistency
Eine wichtige Eigenschaft der Atomics aus dem vorherigen Abschnitt ist, dass sie sich aus funktionaler Sicht genauso wie kritische Abschnitte verhalten. Der große Vorteil daran ist, dass sie sehr einfach und intuitiv zu verwenden sind. Nachteilig ist hingegen die Auswirkung auf die Performance. Genauso wie bei kritischen Abschnitten muss bei obiger Verwendung der Atomics eine scheinbar globale Ordnung aller Speicheroperationen existieren, die sich mit der tatsächlich beobachtbaren Ausführung in jedem Thread verträgt.
Dieses Modell ist unter dem Namen Sequential Consistency
bekannt und weit verbreitet. Allerdings verträgt es sich nicht mit der Natur der Nebenläufigkeit und skaliert schlecht auf Systeme mit vielen Rechenkernen. Denn es bedeutet im Wesentlichen, dass sich beim Datenaustausch zweier Threads alle Kerne in irgendeiner Art und Weise synchronisieren müssen, um die Existenz einer globalen Ordnung zu gewährleisten.
C++11 bietet mit den Atomics die Möglichkeit, aus diesem Modell auszubrechen. Die meisten Atomic-Operationen können mit einem zusätzlichen Parameter aufgerufen werden, durch den die Einschränkungen gezielt aufweicht werden können. Damit lässt sich insbesondere ein Acquire-Release-Modell
realisieren, sowie ein freies Modell, bei dem Speicheroperationen fast beliebig umgeordnet werden können. Die große Gefahr dabei liegt allerdings in der Fehleranfälligkeit. Denn die Abarbeitung ist in vielen Fällen alles andere als intuitiv.
int answer{0};
std::atomic<bool> flag{false};
// --- thread A ---
answer = 42;
flag.store(true, std::memory_order_relaxed);
// --- thread B ---
do { } while (!flag.load(std::memory_order_relaxed));
print(answer); // NOTE: possible data race!
Dieses Beispiel sieht auf den ersten Blick harmlos aus. Tatsächlich ist es aber falsch und führt sogar zu undefiniertem Verhalten. Denn aufgrund der aufgehobenen Einschränkungen ist nicht sichergestellt, dass der erste Thread die Antwort geschrieben hat, bevor sie vom zweiten Thread gelesen wird.
Auch wenn eine korrekte Implementierung im Allgemeinen schwierig ist, so kann man zumindest ein paar Regeln aufstellen, die die Verwendung vereinfachen. Beispielsweise funktioniert std::memory_order_relaxed
gut, wenn Information ausschließlich über die Atomic-Variable fließt, und sie nicht dafür verwendet, um den Zugriff auf andere Speicherbereiche zu steuern. Man muss sich dabei allerdings auch überlegen, wie man diese Eigenschaft im Code deutlich macht, damit sie auch von allen anderen Entwicklern im Team verstanden wird.
Eine Sache sollte man noch beachten: Auf den weit verbreiteten x86‑Prozessoren sind die Unterschiede der verschiedenen Konsistenzmodellen aktuell vernachlässigbar. Die Hersteller dieser Prozessoren treiben einen gewaltigen Aufwand, um die Sequential Consistency
möglichst durchgehend zu gewährleisten. Es ist aber davon auszugehen, dass mit der steigenden Anzahl Kerne auch auf dieser Prozessorarchitektur die Unterschiede in Zukunft wesentlich stärker ausfallen.
Nicht-blockierende Datenstrukturen
Bisher ging es nur um den Einsatz einer einzelnen Atomic-Variable. Für viele einfache Fälle ist das ausreichend. Komplexe Datenstrukturen – wie beispielsweise eine Producer-Consumer-Queue – lassen sich damit allerdings nicht sinnvoll realisieren.
Effiziente Implementierungen solcher Datenstrukturen erfordern das Zusammenspiel vieler Atomic-Operationen – mit unterschiedlichen Parametern für die Speicherumordnungen. Abgesehen von trivialen Beispielen wird der Code dadurch so kompliziert, dass er eigentlich nur von Experten auf diesem Gebiet geschrieben werden sollte. Und ich habe schon zu viele fehlerhafte Implementierungen gesehen, als dass ich mich selbst daran versuchen würde.
Einige Programmiersprachen enthalten in ihrer Standardbibliothek bereits eine begrenzte Auswahl solcher Datenstrukturen. In C++11 sucht man sie hingegen vergeblich. Es bleibt nur zu hoffen, dass diese Lücke durch externe und hochwertige Bibliotheken schnell geschlossen wird, und dass sie einfach von vielen Entwicklern verwendet werden können, wobei nach außen zumindest das Release-Acquire-Modell
garantiert wird.
Das Performance-Potential hängt von der Datenstruktur ab. Gerade für die in Multi-Core-Systemen typischerweise häufig benötigen Queues kann man sagen, dass es signifikant ist, und dass die Performance durchaus eine Größenordnung höher ist.
Fazit
Wichtig ist mir die Aussage, dass es gewisse Einsatzbereiche atomarer Variablen gibt, die einen großen Gewinn ohne negative Auswirkungen bringen, und damit in der Breite eingesetzt werden sollten. Wichtig ist mir auch, dass man von gewissen Szenarien die Finger lassen sollte, wenn man nicht Experte in diesem Gebiet ist. Und irgendwo dazwischen gibt es Fälle, an die man sich durchaus herantrauen darf. Doch sollte man dann zumindest sicherstellen, dass die Optimierung sinnvoll ist und ein tatsächlich vorhandenes Performance-Problem löst.