Bei gleichzeitigen Programmen achten Programmierer besonders auf die Datensynchronisation zwischen verschiedenen Prozessen oder Threads. Insbesondere wenn mehrere Threads gleichzeitig dieselbe Variable ändern, müssen zuverlässige Synchronisierungs- oder andere Maßnahmen ergriffen werden, um sicherzustellen, dass die Daten korrekt geändert werden Der Punkt hier ist: Das Prinzip ist: Nehmen Sie nicht die Reihenfolge an, in der Anweisungen ausgeführt werden. Sie können die Reihenfolge, in der Anweisungen zwischen verschiedenen Threads ausgeführt werden, nicht vorhersagen.
Aber in einem Single-Thread-Programm können wir normalerweise leicht davon ausgehen, dass Anweisungen sequentiell ausgeführt werden, andernfalls können wir uns vorstellen, welche schrecklichen Änderungen am Programm passieren werden. Das ideale Modell ist: Die Reihenfolge, in der verschiedene Anweisungen ausgeführt werden, ist eindeutig und geordnet. Diese Reihenfolge ist die Reihenfolge, in der sie in den Code geschrieben werden, unabhängig vom Prozessor oder anderen Faktoren. Dieses Modell wird als sequentielles Konsistenzmodell bezeichnet Es handelt sich um ein Modell, das auf dem von Neumann-System basiert. Natürlich ist diese Annahme an sich vernünftig und kommt in der Praxis selten ungewöhnlich vor, aber tatsächlich übernimmt keine moderne Multiprozessorarchitektur dieses Modell, weil es einfach zu ineffizient ist. Bei der Kompilierungsoptimierung und der CPU-Pipeline geht es fast immer um die Neuordnung von Befehlen.
Neuordnung der Kompilierzeit
Eine typische Neuordnung zur Kompilierungszeit besteht darin, die Befehlsreihenfolge anzupassen, um die Anzahl der Registerlesevorgänge und -speicherungen so weit wie möglich zu reduzieren, ohne die Programmsemantik zu ändern, und die gespeicherten Werte der Register vollständig wiederzuverwenden.
Angenommen, der erste Befehl berechnet einen Wert, weist ihn der Variablen A zu und speichert ihn in einem Register. Der zweite Befehl hat nichts mit A zu tun, sondern muss ein Register belegen (vorausgesetzt, er belegt das Register, in dem sich A befindet). Die Anweisung verwendet den Wert von A und hat nichts mit der zweiten Anweisung zu tun. Wenn dann gemäß dem sequentiellen Konsistenzmodell A in das Register eingetragen wird, nachdem der erste Befehl ausgeführt wurde, existiert A nicht mehr, wenn der zweite Befehl ausgeführt wird, und A wird erneut in das Register eingelesen, wenn der dritte Befehl ausgeführt wird, und währenddessen Bei diesem Vorgang hat sich der Wert von A nicht geändert. Normalerweise vertauscht der Compiler die Positionen des zweiten und dritten Befehls, sodass A am Ende des ersten Befehls im Register vorhanden ist und der Wert von A dann direkt aus dem Register gelesen werden kann, wodurch der Aufwand für wiederholtes Lesen verringert wird.
Die Bedeutung der Neuordnung für die Pipeline
Moderne CPUs verwenden fast alle den Pipeline-Mechanismus, um die Verarbeitung von Befehlen zu beschleunigen. Im Allgemeinen erfordert die Verarbeitung eines Befehls mehrere CPU-Taktzyklen, und durch die parallele Ausführung der Pipeline können mehrere Befehle im selben Taktzyklus ausgeführt werden Die Methode ist einfach angegeben. Teilen Sie die Anweisungen einfach in verschiedene auf Der Ausführungszyklus wie Lesen, Adressieren, Parsen, Ausführen und andere Schritte werden in verschiedenen Komponenten verarbeitet. Gleichzeitig ist in der Ausführungseinheit EU die Funktionseinheit in verschiedene Komponenten unterteilt, beispielsweise Additionskomponenten und Multiplikationskomponenten Durch das Laden von Komponenten, Speicherelementen usw. können außerdem verschiedene Berechnungen parallel ausgeführt werden.
Die Pipeline-Architektur schreibt vor, dass Anweisungen parallel ausgeführt werden sollten, nicht wie im sequentiellen Modell vorgesehen. Eine Neuordnung trägt dazu bei, die Pipeline voll auszunutzen und dadurch superskalare Effekte zu erzielen.
Sorgen Sie für Ordnung
Obwohl Anweisungen nicht unbedingt in der Reihenfolge ausgeführt werden, in der wir sie geschrieben haben, besteht kein Zweifel daran, dass in einer Single-Thread-Umgebung der endgültige Effekt der Befehlsausführung mit seinem Effekt bei der sequentiellen Ausführung übereinstimmen sollte, da diese Optimierung sonst ihre Bedeutung verliert.
Normalerweise werden die oben genannten Prinzipien erfüllt, unabhängig davon, ob die Befehlsneuordnung zur Kompilierungszeit oder zur Laufzeit durchgeführt wird.
Neuordnung im Java-Speichermodell
Im Java Memory Model (JMM) ist die Neuordnung ein sehr wichtiger Abschnitt, insbesondere bei der gleichzeitigen Programmierung. JMM stellt die semantische Ausführungssemantik durch die Vorher-Vorher-Regel sicher. Wenn Sie möchten, dass der Thread, der Operation B ausführt, die Ergebnisse des Threads, der Vorgang A ausführt, beobachtet, müssen A und B das Vorher-Vorher-Prinzip erfüllen. Andernfalls kann die JVM willkürlich vorgehen Sortieren, um die Programmleistung zu verbessern.
Das Schlüsselwort „volatil“ kann die Sichtbarkeit von Variablen sicherstellen, da sich alle Operationen auf „volatil“ im Hauptspeicher befinden und der Hauptspeicher von allen Threads gemeinsam genutzt wird. Der Preis hierfür ist, dass die Leistung geopfert wird und Register oder Cache nicht verwendet werden können, da sie weder global sind , kann die Sichtbarkeit nicht garantiert werden und es kann zu fehlerhaften Lesevorgängen kommen.
Eine weitere Funktion von volatile besteht darin, eine Neuordnung lokal zu verhindern. Operationsanweisungen für flüchtige Variablen werden nicht neu angeordnet, da bei einer Neuordnung Probleme mit der Sichtbarkeit auftreten können.
Im Hinblick auf die Gewährleistung der Sichtbarkeit können Sperren (einschließlich expliziter Sperren, Objektsperren) sowie das Lesen und Schreiben atomarer Variablen die Sichtbarkeit von Variablen gewährleisten. Die Implementierungsmethoden unterscheiden sich jedoch geringfügig. Beispielsweise stellt die Synchronisierungssperre sicher, dass Daten aus dem Speicher erneut gelesen werden, um den Cache zu aktualisieren. Wenn die Sperre aufgehoben wird, werden die Daten zurück in den Speicher geschrieben dass die Daten sichtbar sind, während flüchtige Variablen einfach den Speicher lesen und schreiben.