C++: Was kostet std::atomic<int>?
Lohnt es sich in C++ den Typ int
statt std::atomic<int>
zu verwenden, um die parallele Performance zu verbessern? Unwahrscheinlich! Denn erstens ist das Verhalten eines Programms in C++ undefiniert, falls (theoretische) Data-Races existieren. Und zweitens ist std::atomic<int>
auf vielen Hardware-Architekturen vergleichsweise effizient. Letzteres demonstriere ich an ein paar Beispielen, die den Unterschied zwischen int
und std::atomic<int>
verdeutlichen.
In den folgenden Zeilen werden die globale Variable foobar
sowie die Funktionen foobar_set
und foobar_get
definiert, wobei die Funktionen die Lese- und Schreibzugriffe auf die Variable realisieren. Den Quelltext übersetze ich auf meinem Rechner mit g++ -std=c++11 -pthread -O3 -S und betrachte im resultierenden Assembler-Code die Unterschiede gegenüber der Verwendung des Typs int
.
#include <atomic>
std::atomic<int> foobar;
void foobar_set(int value) { foobar = value; }
int foobar_get() { return foobar; }
Der Assembler-Code der Funktion foobar_set
ist im folgenden Listing zu sehen und weitgehend selbsterklärend. Der einzige Unterschied gegenüber der Variante mit int
ist die zusätzliche mfence-Operation, die auf der x86-Architektur die Speicherzugriffe partiell ordnet.
foobar_set(int):
.cfi_startproc
movl %edi, foobar(%rip)
mfence
ret
.cfi_endproc
Keine Frage: Die mfence-Operation ist sehr teuer – im direkten Vergleich zu den restlichen Operationen. Doch wichtig ist auch, dass sonst nichts Besonderes passiert – nichts was mit Sperren oder Kontextwechseln zu tun hat.
Interessant ist der Assembler-Code der Funktion foobar_get
. Er besteht lediglich aus einer Load-Operation und ist für die Typen std::atomic<int>
und int
vollkommen identisch.
foobar_get():
.cfi_startproc
movl foobar(%rip), %eax
ret
.cfi_endproc
Identischer Assembler-Code impliziert identische Performance. Für Anwendungsfälle mit signifikant mehr Lese- als Schreibzugriffen bedeutet das also praktisch Zero-Overhead durch std::atomic<int>
. Zu diesen Anwendungsfällen gehört beispielsweise das Double-Checked-Locking, das vor allem dadurch traurige Bekanntheit erlangt hat, dass es häufig fälschlicherweise ohne geeignete Atomic-Typen realisiert wurde – und wie man hier sieht auch vollkommen unnötigerweise.
Doch Vorsicht: Es ist keineswegs so, dass Lese-Operationen für std::atomic<int>
und int
immer im gleichen Assembler-Code resultieren. Ein einfaches Beispiel genügt um das zu zeigen. Im folgenden Listing ist der Assembler-Code der Funktion foobar_square
zu sehen, die das Quadrat der globalen Variable berechnet. Man sieht sofort, dass – unnötigerweise – zwei Load-Operationen auf die selbe Speicherstelle erfolgen.
# int foobar_square() { return foobar * foobar; }
foobar_square():
.cfi_startproc
movl foobar(%rip), %eax
movl foobar(%rip), %edx
imull %edx, %eax
ret
.cfi_endproc
Häufig lassen sich solche Fälle relativ einfach optimieren. Im folgenden Listing ist zu sehen was passiert, wenn man den Wert der Variablen foobar
einfach in einer lokalen Variable zwischenspeichert. Die lokale Variable wird zum Ergebnis-Register und die zweite Load-Operation entfällt. Damit erhält man den gleichen Assembler-Code, wie wenn man im obigen Beispiel int
statt std::atomic<int>
verwendet hätte.
# int foobar_square() { int value = foobar; return value * value; }
foobar_square():
.cfi_startproc
movl foobar(%rip), %eax
imull %eax, %eax
ret
.cfi_endproc
Nochmals zusammengefasst: std::atomic<int>
ist in vielen Fällen fast genauso effizient wie int
. Damit gibt es fast keinen Grund, int
-Variablen für die Synchronisation zwischen Threads zu verwenden. Es gibt jedoch einen sehr guten Grund genau das nicht zu tun. Das Verhalten eines Programms ist in C++ undefiniert, falls es (theoretische) Data-Races enthält.