Warum hat der Compiler meine bedingte Schleife in eine Endlosschleife verwandelt?

Einer der Benutzer des Visual C ++ - Compilers gab das folgende Codebeispiel an und fragte, warum seine Schleife mit der Bedingung endlos ausgeführt wird, obwohl die Bedingung irgendwann aufhören und der Zyklus enden sollte:

#include <windows.h> int x = 0, y = 1; int* ptr; DWORD CALLBACK ThreadProc(void*) { Sleep(1000); ptr = &y; return 0; } int main(int, char**) { ptr = &x; // starts out pointing to x DWORD id; HANDLE hThread = CreateThread(nullptr, 0, ThreadProc, 0, &id); // ,        ptr //     while (*ptr == 0) { } return 0; } 

Für diejenigen, die mit den Funktionen der Windows-Plattform nicht vertraut sind, ist hier das Äquivalent in reinem C ++:

 #include <chrono> #include <thread> int x = 0, y = 1; int* ptr = &x; void ThreadProc() { std::this_thread::sleep_for(std::chrono::seconds(1)); ptr = &y; } int main(int, char**) { ptr = &x; // starts out pointing to x std::thread thread(ThreadProc); // ,        ptr //     while (*ptr == 0) { } return 0; } 

Als nächstes brachte der Benutzer sein Verständnis des Programms ein:
Die bedingte Schleife wurde vom Compiler in eine Endlosschleife umgewandelt. Ich sehe dies aus dem generierten Assembler-Code, der einmal den Wert des ptr-Zeigers in das Register lädt (am Anfang der Schleife) und dann den Wert dieses Registers bei jeder Iteration mit Null vergleicht. Da das Neuladen des Werts von ptr nie wieder erfolgt, endet der Zyklus nie.

Ich verstehe, dass das Deklarieren von ptr als "volatile int *" den Compiler veranlassen sollte, Optimierungen zu löschen und den ptr-Wert bei jeder Iteration der Schleife zu lesen, wodurch das Problem behoben wird. Trotzdem möchte ich wissen, warum der Compiler nicht klug genug sein kann, solche Dinge automatisch zu tun. Offensichtlich kann die globale Variable, die in zwei verschiedenen Threads verwendet wird, geändert werden, was bedeutet, dass sie nicht einfach im Register zwischengespeichert werden kann. Warum kann der Compiler nicht sofort den richtigen Code generieren?


Bevor wir diese Frage beantworten, beginnen wir mit einer kleinen Auswahl: "volatile int * ptr" deklariert die ptr-Variable nicht als "Zeiger, für den Optimierungen verboten sind". Dies ist ein "normaler Zeiger auf eine Variable, für die Optimierungen verboten sind". Was der Autor der obigen Frage im Sinn hatte, war, als "int * volatile ptr" deklariert zu werden.

Nun zurück zur Hauptfrage. Was ist hier los?

Selbst ein flüchtiger Blick auf den Code zeigt, dass es weder Variablen wie std :: atomic noch die Verwendung von std :: memory_order (entweder explizit oder implizit) gibt. Dies bedeutet, dass jeder Versuch, von zwei verschiedenen Streams aus auf ptr oder * ptr zuzugreifen, zu undefiniertem Verhalten führt. Intuitiv kann man sich das so vorstellen: „Der Compiler optimiert jeden Thread so, als würde er alleine im Programm ausgeführt. Die einzigen Punkte, an denen der Compiler über den Zugriff auf Daten aus verschiedenen Streams nachdenken muss, sind std :: atomic oder std :: memory_order. “

Dies erklärt, warum sich das Programm nicht wie vom Programmierer erwartet verhalten hat. Von dem Moment an, in dem Sie vages Verhalten zulassen, kann absolut nichts garantiert werden.

Aber okay, lassen Sie uns über den zweiten Teil seiner Frage nachdenken: Warum ist der Compiler nicht klug genug, um diese Situation zu erkennen und die Optimierung automatisch zu deaktivieren, indem der Zeigerwert in das Register geladen wird? Nun, der Compiler wendet automatisch alles Mögliche an und widerspricht nicht dem Optimierungsstandard. Es wäre seltsam, von ihm zu verlangen, dass er die Gedanken des Programmierers lesen und einige Optimierungen deaktivieren kann, die nicht dem Standard widersprechen, was laut Programmierer möglicherweise die Logik des Programms zum Besseren ändern sollte. „Oh, was ist, wenn dieser Zyklus eine Änderung des Werts einer globalen Variablen in einem anderen Thread erwartet, obwohl dies nicht explizit angekündigt wurde? Ich werde hundert Mal brauchen, um es zu verlangsamen und auf diese Situation vorbereitet zu sein! " Sollte es so sein? Kaum.

Angenommen, wir fügen dem Compiler eine Regel hinzu: "Wenn die Optimierung zum Auftreten einer Endlosschleife geführt hat, müssen Sie diese abbrechen und den Code ohne Optimierung erfassen." Oder sogar so: "Brechen Sie einzelne Optimierungen nacheinander ab, bis das Ergebnis eine Endlosschleife ist." Wird es neben den erstaunlichen Überraschungen, die dies mit sich bringen wird, überhaupt einen Nutzen bringen?

Ja, in diesem theoretischen Fall erhalten wir keine Endlosschleife. Es wird unterbrochen, wenn ein anderer Stream einen Wert ungleich Null in * ptr schreibt. Es wird auch unterbrochen, wenn ein anderer Thread einen Wert ungleich Null in die Variable x schreibt. Es wird nicht klar, wie tief die Analyse von Abhängigkeiten gehen sollte, um alle Fälle zu „erfassen“, die die Situation beeinflussen könnten. Da der Compiler das erstellte Programm nicht startet und sein Verhalten zur Laufzeit nicht analysiert, besteht der einzige Ausweg darin, anzunehmen, dass überhaupt keine Aufrufe globaler Variablen, Zeiger und Links optimiert werden können.

 int limit; void do_something() { ... if (value > limit) value = limit; //   limit ... for (i = 0; i < 10; i++) array[i] = limit; //   limit ... } 

Dies widerspricht völlig dem Geist von C ++. Der Sprachstandard besagt, dass Sie, wenn Sie eine Variable ändern und erwarten, dass diese Änderung in einem anderen Thread angezeigt wird, dies ausdrücklich sagen sollten: Verwenden Sie eine atomare Operation oder organisieren Sie den Zugriff auf den Speicher (normalerweise mithilfe eines Synchronisationsobjekts).

Also bitte genau das tun.

Source: https://habr.com/ru/post/de424729/


All Articles