Threads und volatile

Hier spielen mindestens drei Dinge mit rein:


  1. Darf der Compiler das Lesen/Schreiben einer Variablen im generierten Maschinentext nach oben oder unten verschieben?
  2. Darf die CPU das Lesen/Schreiben eines Registers in der Ausführung vorziehen oder auf später verschieben?
  3. Darf der Compiler das Lesen/Schreiben einer Variablen wekoptimieren?





Zu 1:
Im Allgemeinen darf der Compiler das Lesen oder Schreiben einer Variable beliebig verschieben, sofern (nur bei Betrachtung eines einzelnen Threads!) das nach außen sichtbare Verhalten gleich bleibt. Mit volatile erlegt man dem Compiler einige Einschränkungen auf: Er muss die Reihenfolge beim Zugriff auf volatile Variablen untereinander beibehalten. Gleiches gilt bei sichtbaren Seiteneffekten. D.h. ein

volatile int i = 1;
FILE * f = fopen("file.txt", "r");
i = 2;

darf nicht zu

FILE * f = fopen("file.txt", "r");
volatile int i = 2;

optimiert werden. Etwas anders verhält sich MSVC bei Standardeinstellungen: Da bedeutet volatile (in Übererfüllung des Standards, kann man umstellen, Schalter sind /volatile:ms und /volatile:iso), dass nicht nur die Reihenfolge beim Zugriff auf volatile Variablen untereinander beibehalten wird, sondern die Reihenfolge beim Zugriff auf volatile Variablen und aller anderen Variablen.

Zu 2:
Eine x86-CPU bzw. x86-64-CPU macht solche Umordnungen nicht. Mit einer Ausnahme: Du benutzt non-temporal load/store instructions. Das produziert kein Compiler selbst, sondern nur, wenn du das in Assembler selbst schreibst. Meist für uncacheable, write-combining memory (UC-WC memory). Wenn dir das nichts sagt, wirst du in der Regel damit nie in Kontakt kommen.

Bei ARM sieht es anders aus.

(Funfact für Grafikprogrammierer: Das benutzt man, wenn man von der CPU zur GPU Sachen lädt; in diesem Falle wird bei Direct3D mit Map oder bei OpenGL mit glMapBuffer eine entsprechende Speicherseite zurückgeben, welche die UC-WC flags gesetzt hat. Das umgeht die gesamte Cache-Hierarchie (uncacheable) und kombiniert mehrere Schreiboperationen zusammen (write-combining), und speichert sie in einem Rutsch in den RAM. Und mir einer einzigen Leseoperation macht man sich alles kaputt.)

Zu 3: Auch hier gilt, dass der Compiler beliebig optimieren darf, solange sich das nach außen sichtbare Verhalten (des einen hier betrachteten Threads!) sich nicht ändert. Er darf also:

bool done = false;
while(!done)
{
printf("Haaaalooooo");
sleep(1);
}

zu

while(true)
{
printf("Haaaalooooo");
sleep(1);
}

optimieren. Sogar wenn ein anderer Thread done auf true setzt. Wäre done mit volatile deklariert worden, dürfte der Compiler nicht meer diese Optimierung durchführen. Wie schon gesagt, man sollte in diesem Falle das aber dennoch nicht zur Inter-Thread-Kommunikation einsetzten:

Der einzige Grund, warum so lange sich der Mythos "einfach volatile und schon ist man Thread-safe" gehalten hat, war, dass MSVC tatsächlich alle Speicherzugriffe bei volatile in der richtigen Reihenfolge hält und man auf x86-CPU ausführte. Damit fällt man sowohl mit Clang und GCC auf die Nase (Umordnungen im generierten Maschinentext) wie auch auf ARM auf die Nase (Umordnungen im Ausführungsstrom der CPU).

In C++ kann man 99% aller Inter-Thread-Kommunikation mittels std::mutex (mit std::scoped_lock) und std::condition_variable lösen. Im Beispiel zu 3. kann man z.B. stattdessen eine std::condition_variable benutzten.