C++: Was kostet std::atomic<int>?

von Hubert Schmid vom 2013-10-13

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.