Die Plattformversionen Android 3.0 und höher sind für die Unterstützung Multiprozessor-Architekturen. In diesem Dokument werden Probleme beschrieben, die beim Schreiben von mehrstufigen Code für symmetrische Mehrprozessorsysteme in C, C++ und der Programmiersprache Java auftreten können (im Folgenden aus Gründen der Übersichtlichkeit einfach als „Java“ bezeichnet). Sie ist als Einführung für Android-App-Entwickler gedacht und nicht als umfassender Artikel zum Thema.
Einführung
SMP ist ein Akronym für „Symmetric Multi-Processor“. Es beschreibt ein Design, bei dem zwei oder mehr identische CPU-Kerne den Zugriff auf den Hauptspeicher teilen. Bis Vor einigen Jahren waren alle Android-Geräte UP (Uni-Prozessor).
Die meisten, wenn nicht alle Android-Geräte hatten schon immer mehrere CPUs. Bisher wurde jedoch nur eine davon zum Ausführen von Anwendungen verwendet, während andere verschiedene Hardwarekomponenten des Geräts verwalteten (z. B. das Radio). Die CPUs hatten möglicherweise unterschiedliche Architekturen und die darauf ausgeführten Programme konnten nicht den Hauptspeicher verwenden, um miteinander zu kommunizieren.
Die meisten heute verkauften Android-Geräte basieren auf SMP-Designs. was die Dinge für Softwareentwickler etwas komplizierter macht. Race-Bedingungen in einem Multithread-Programm keine sichtbaren Probleme auf einem aber es können regelmäßig Fehler auftreten, wenn zwei oder mehr Threads gleichzeitig auf verschiedenen Kernen ausgeführt werden. Darüber hinaus kann Code mehr oder weniger anfällig für Fehler sein, wenn er auf unterschiedlichen Prozessorarchitekturen oder sogar auf verschiedenen Implementierungen desselben Architektur. Code, der eingehend auf x86 getestet wurde, kann auf ARM fehlerhaft funktionieren. Code kann fehlschlagen, wenn er mit einem moderneren Compiler neu kompiliert wird.
Im weiteren Verlauf dieses Dokuments wird der Grund dafür erläutert und Sie erfahren, was Sie tun müssen. um sicherzustellen, dass sich der Code korrekt verhält.
Speicherkonsistenzmodelle: Warum sich soziale Netzwerke etwas unterscheiden
Dies ist eine schnelle, glänzende Übersicht über ein komplexes Thema. In einigen Regionen unvollständig sein, aber keines davon sollte irreführend oder falsch sein. Während Sie im nächsten Abschnitt angezeigt wird, sind die Details hier normalerweise nicht wichtig.
Unter Weitere Informationen am Ende dieses Dokuments finden Sie weitere Informationen zu und Hinweise auf eine gründlichere Behandlung der Probande.
Speicherkonsistenzmodelle oder oft einfach nur „Speichermodelle“ die Programmiersprache oder Hardwarearchitektur garantiert, über Arbeitsspeicherzugriffe. Beispiel: Wenn Sie einen Wert in Adresse A und dann einen Wert in Adresse B schreiben, dass jeder CPU-Kern diese Schreibvorgänge sieht, Reihenfolge.
Das Modell, mit dem die meisten Programmierer vertraut sind, ist sequentiell. Consistency (Konsistenz), der so beschrieben wird: (Adve & Gharachorloo):
- Alle Speichervorgänge scheinen nacheinander ausgeführt zu werden
- Alle Vorgänge in einem Thread scheinen in der beschriebenen Reihenfolge ausgeführt zu werden. Programm dieses Prozessors.
Nehmen wir vorübergehend an, wir haben einen sehr einfachen Compiler oder Interpreter. bringt keine Überraschungen mit sich: Zuweisungen im Quellcode an, um Anweisungen genau im und eine Anweisung pro Zugriff. Wir gehen auch für dass jeder Thread auf seinem eigenen Prozessor ausgeführt wird.
Wenn Sie sich Code ansehen und sehen, dass er einige Lese- und Schreibvorgänge sequentiell konsistenter CPU-Architektur. Sie wissen, dass der Code die Lese- und Schreibvorgänge in der erwarteten Reihenfolge ab. Es ist möglich, dass das Die CPU ordnet Anweisungen neu und verzögert Lese- und Schreibvorgänge, aber es gibt Code, der auf dem Gerät ausgeführt wird, kann nicht erkennen, dass die CPU etwas tut als die direkte Ausführung von Anweisungen. (Wir ignorieren dem Arbeitsspeicher zugeordneter Gerätetreiber-E/A)
Zur Veranschaulichung dieser Punkte ist es hilfreich, kleine Code-Snippets zu betrachten, die allgemein als Liefertests bezeichnet werden.
Hier ein einfaches Beispiel, bei dem Code in zwei Threads ausgeführt wird:
Thread 1 | Thread 2 |
---|---|
A = 3 |
reg0 = B |
In diesem und allen zukünftigen Lackus-Beispielen werden die Speicherstandorte durch Großbuchstaben (A, B, C) und CPU-Register beginnen mit "reg". Gesamter Arbeitsspeicher ist anfänglich auf null gesetzt ist. Die Anweisungen werden von oben nach unten ausgeführt. Hier, Thread 1 den Wert 3 an Ort A und dann den Wert 5 an Ort B. Thread 2 lädt den Wert aus Standort B in reg0 und lädt dann den Wert aus aus Standort A in reg1 ein. (Beachten Sie, dass wir in einer Reihenfolge schreiben und eine andere.)
Es wird davon ausgegangen, dass Thread 1 und Thread 2 auf verschiedenen CPU-Kernen ausgeführt werden. Ich immer von dieser Annahme aus, Multithread-Code.
Die sequenzielle Konsistenz garantiert, dass die Register nach Abschluss der Ausführung beider Threads in einem der folgenden Status sind:
Register | Bundesstaaten |
---|---|
reg0=5, reg1=3 | möglich (Thread 1 wurde zuerst angezeigt) |
reg0=0, reg1=0 | möglich (Thread 2 wurde zuerst angezeigt) |
reg0=0, reg1=3 | möglich (gleichzeitige Ausführung) |
reg0=5, reg1=0 | nie |
Damit wir B = 5 sehen, bevor wir den Speicher für A sehen, müssten entweder die Lese- oder die Schreibvorgänge nicht in der richtigen Reihenfolge erfolgen. Auf einer und sequenziellen Konsistenz wird das nicht möglich.
Uni-Prozessoren, einschließlich x86 und ARM, sind normalerweise sequenziell konsistent. Threads scheinen verschränkt ausgeführt zu werden, während der Kernel des Betriebssystems wechselt. zwischen ihnen aufbauen. Die meisten SMP-Systeme, einschließlich x86 und ARM, nicht sequentiell konsistent sind. Zum Beispiel ist es üblich, um Speicher zwischenzuspeichern, den Arbeitsspeicher nicht sofort erreichen und für andere Kerne sichtbar werden.
Die Details können erheblich variieren. Beispielsweise wird bei x86 zwar nicht sequentiell konsistent gearbeitet, aber es wird trotzdem garantiert, dass reg0 = 5 und reg1 = 0 unmöglich ist. Geschäfte werden zwischengespeichert, aber ihre Reihenfolge bleibt erhalten. ARM hingegen nicht. Die Reihenfolge der gepufferten Speicher ist nicht verwaltet und die Geschäfte erreichen möglicherweise nicht alle anderen Kerne gleichzeitig. Diese Unterschiede sind für Assembler-Programmierer wichtig. Wie wir weiter unten sehen werden, können C-, C++- oder Java-Programmierer jedoch und sollte so programmiert werden, dass solche architektonischen Unterschiede verborgen sind.
Bisher sind wir unrealistisch davon ausgegangen, dass nur die Hardware Anweisungen zur Neuanordnung. Tatsächlich ordnet der Compiler die Anweisungen auch so an, die Leistung zu verbessern. In unserem Beispiel könnte der Compiler später entscheiden, Der Code in Thread 2 benötigte den Wert von „reg1“ vor „reg0“ und lädt reg1 an erster Stelle. Oder es ist bereits Code für A geladen worden, und der Compiler diesen Wert wiederverwenden, anstatt A erneut zu laden. In beiden Fällen können die Ladungen in reg0 und reg1 neu angeordnet werden.
Das Neuanordnen von Zugriffen auf verschiedene Speicherorte, entweder in der Hardware oder im Compiler, ist zulässig, da dies die Ausführung eines einzelnen Threads nicht beeinträchtigt und die Leistung erheblich verbessern kann. Wie wir sehen werden, können wir auch verhindern, dass die Ergebnisse von Multithread-Programmen beeinträchtigt werden.
Da Compiler Speicherzugriffe auch neu anordnen können, ist dieses Problem nicht neu für soziale Netzwerke. Selbst auf einem Uniprozessor könnte ein Compiler die Ladevorgänge „reg0“ und „reg1“ in unserem Beispiel. Thread 1 könnte dann Anweisungen neu angeordnet. Wenn unser Compiler die Anweisungen jedoch nicht neu anordnet, wird dieses Problem möglicherweise nie auftreten. Auf den meisten ARM SMPs, auch ohne Compiler erfolgt die Neuanordnung wahrscheinlich nach einer sehr großen erfolgreicher Ausführungen. Sofern Sie nicht in Assembler programmieren, erhöhen SMPs in der Regel nur die Wahrscheinlichkeit, dass Sie Probleme sehen, die schon immer da waren.
Programmieren ohne Datenrennen
Glücklicherweise gibt es in der Regel eine einfache Methode, diese Details. Wenn ihr euch an einige einfache Regeln haltet, um den gesamten vorherigen Abschnitt außer der "sequenziellen Konsistenz" zu vergessen, Die anderen Komplikationen können jedoch sichtbar werden, wenn Sie versehentlich gegen diese Regeln verstoßen.
Moderne Programmiersprachen fördern einen sogenannten „datenabgleichsfreien“ Programmierstil. Solange Sie versprechen, keine „Datenrennen“ einzuführen, und vermeiden Sie einige Konstrukte, die dem Compiler etwas anderes mitteilen, und Hardware versprechen, sequentiell konsistente Ergebnisse zu liefern. Das ist nicht dass sie den Arbeitsspeicherzugriff nicht neu anordnen müssen. Wenn Sie also Wenn Sie die Regeln befolgen, können Sie nicht erkennen, neu angeordnet. Das ist in etwa so, als würde ich Ihnen sagen, dass Wurst ein leckeres und appetitanregendes Lebensmittel ist, solange Sie versprechen, die Wurstfabrik nicht zu besuchen. Data Races lassen die hässliche Wahrheit über das Gedächtnis enthüllen Reihenfolge ändern.
Was ist ein „Datenrennen“?
Ein Datenrennen findet statt, wenn mindestens zwei Threads gleichzeitig auf dieselben gewöhnlichen Daten verarbeitet und sie von mindestens einem von ihnen geändert werden. Von „ordinary“ Daten“ meinen wir etwas, das kein Synchronisierungsobjekt ist, die für die Thread-Kommunikation vorgesehen ist. Mutexe, Bedingungsvariablen, Java flüchtige oder atomare C++-Objekte sind keine gewöhnlichen Daten und ihr Zugriff dürfen am Rennen teilnehmen. Tatsächlich werden sie verwendet, um Datenwettkämpfe auf anderen Objekte.
Um zu bestimmen, ob zwei Threads gleichzeitig auf denselben
können wir die obige Diskussion zur Neuanordnung des Arbeitsspeichers ignorieren.
sequenzielle Konsistenz. Im folgenden Programm kommt es nicht zu einem Datenübergriff, wenn A
und B
gewöhnliche boolesche Variablen sind, die anfangs den Wert „falsch“ haben:
Thread 1 | Thread 2 |
---|---|
if (A) B = true |
if (B) A = true |
Da die Vorgänge nicht neu angeordnet werden, werden beide Bedingungen als falsch ausgewertet und keine der Variablen wird aktualisiert. Daher kann es keinen Wettlauf mit den Daten geben. Sie müssen sich nicht überlegen, was passieren könnte, wenn die Ladung von A
und das Speichern in B
in Thread 1 irgendwie neu angeordnet würden. Der Compiler ist nicht berechtigt, Thread neu zu ordnen
1, indem es in "B = true; if (!A) B = false
" umgeschrieben wird. Das wäre
wie Wurst mitten in der Stadt bei Tageslicht Würstchen zu machen.
Data Races sind offiziell auf Basis integrierter Typen wie Ganzzahlen und
Referenzen oder Verweise. Zuweisen zu einem int
bei gleichzeitiger
in einem anderen Thread zu lesen,
ist eindeutig ein Datenwettkampf. Aber sowohl die C++-
Standardbibliothek und
Die Java-Sammlungsbibliotheken sind so geschrieben, dass Sie auch die Bedeutung
auf Bibliotheksebene. Sie versprechen, keine Wettkämpfe im Datenbereich einzuführen.
außer es gibt gleichzeitige Zugriffe auf denselben Container,
wodurch er aktualisiert wird. Aktualisierung eines set<T>
in einem Thread während
das gleichzeitige Lesen in einer anderen
Bibliothek ermöglicht,
und kann daher informell als „Datenwettlauf auf Bibliotheksebene“ betrachtet werden.
Umgekehrt wird ein set<T>
in einem Thread beim Lesen
nicht zu einem Wettlauf bei den Daten führen,
verspricht, in diesem Fall kein (Low-Level-)Datenrennen einzuführen.
Normalerweise können durch gleichzeitigen Zugriff auf verschiedene Felder in einer Datenstruktur keine Datenkonflikte auftreten. Es gibt jedoch eine wichtige Ausnahme zusammenhängende Sequenzen von Bitfeldern in C oder C++ einen einzelnen "Speicherort" erstellen. Auf ein beliebiges Bitfeld in einer solchen Sequenz zugreifen wird als Zugriff auf alle angesehen, um zu bestimmen, das Vorhandensein eines Datenrennens besteht. Dies spiegelt die Unfähigkeit gängiger Hardware wider. um einzelne Bits zu aktualisieren, ohne auch benachbarte Bits zu lesen und neu zu schreiben. Java-Programmierer haben keine entsprechenden Bedenken.
Datenrennen vermeiden
Moderne Programmiersprachen bieten eine Reihe von Synchronisierungsfunktionen, um Datenwettkämpfe zu vermeiden. Die grundlegendsten Tools sind:
- Schlösser oder Stummer
- Mutexe (C++11
std::mutex
oderpthread_mutex_t
) odersynchronized
-Blöcke in Java kann verwendet werden, um bestimmte nicht gleichzeitig mit anderen Codeabschnitten ausgeführt werden, dieselben Daten. Diese und ähnliche Einrichtungen werden allgemein erwähnt. als „Schlösser“. Sie erhalten dauerhaft eine bestimmte Sperre, bevor auf eine freigegebene Sperre zugegriffen wird. Datenstruktur und anschließende Freigabe verhindert Datenwettkämpfe beim Zugriff auf der Datenstruktur. Außerdem wird sichergestellt, dass Aktualisierungen und Zugriffe unteilbar sind, d.h. kann eine andere Aktualisierung der Datenstruktur in der Mitte ausgeführt werden. Das ist zuversichtlich das bei Weitem gängigste Tool zur Vermeidung von Datenwettkämpfen. Die Verwendung von Javasynchronized
Blöcke oder C++lock_guard
oderunique_lock
dafür sorgen, dass die Verriegelungen im Ausnahmeereignis. - Flüchtige/atomare Variablen
- Java bietet
volatile
-Felder, die den gleichzeitigen Zugriff unterstützen ohne Rastmöglichkeiten einzuführen. Unterstützung von C und C++ seit 2011atomic
-Variablen und -Felder mit ähnlicher Semantik. Dies sind in der Regel schwieriger zu verwenden als Schlösser, da sie nur dafür sorgen, einzelne Zugriffe auf eine einzelne Variable sehr klein sind. (In C++ ist dies normalerweise geht auf einfache Read-Change-Write-Vorgänge wie Inkrementierungen. Java erfordert hierfür spezielle Methodenaufrufe.) Im Gegensatz zu Sperren könnenvolatile
- oderatomic
-Variablen nicht direkt verwendet werden, um zu verhindern, dass andere Threads in längere Codesequenzen eingreifen.
Beachten Sie, dass volatile
sehr unterschiedliche
Bedeutung in C++ und Java. In C++ verhindert volatile
nicht, dass Daten
obwohl ältere Codes häufig als Problemumgehung verwendet werden, um das Fehlen von
atomic
-Objekte. Dies wird nicht mehr empfohlen. Verwenden Sie in C++ atomic<T>
für Variablen, auf die mehrere Threads gleichzeitig zugreifen können. C++ volatile
ist für Geräteregister und ähnliches gedacht.
C/C++ atomic
-Variablen oder Java volatile
-Variablen
kann verwendet werden, um Datenwettkämpfe bei anderen Variablen zu verhindern. Wenn flag
gleich
deklarierten Typ atomic<bool>
oder atomic_bool
(C/C++) oder volatile boolean
(Java),
und anfangs auf „false“ gesetzt ist, ist das folgende Snippet datenfrei:
Thread 1 | Thread 2 |
---|---|
A = ...
|
while (!flag) {}
|
Da Thread 2 wartet, bis flag
festgelegt ist, wird der Zugriff auf
A
in Thread 2 muss nach dem und nicht gleichzeitig mit dem
Zuweisung an A
in Thread 1. Daher gibt es keinen Wettlauf
A
Das Rennen um flag
zählt nicht als Datenwettkampf,
da flüchtige/atomare Zugriffe keine „normalen Speicherzugriffe“ sind.
Die Implementierung ist erforderlich, um die Neuanordnung von Arbeitsspeicher zu verhindern oder zu verbergen damit sich Code wie im vorherigen Lackmus-Test wie erwartet verhält. Dadurch wird normalerweise auf flüchtige/atomare Speicher zugegriffen erheblich teurer als ein gewöhnlicher Zugriff.
Obwohl das obige Beispiel ohne Datenrennen ist, wird es mit
Object.wait()
in Java oder Bedingungsvariablen in C/C++ normalerweise
eine bessere Lösung zu bieten,
bei der nicht in einer Schleife gewartet werden muss,
die den Akku stark belasten.
Wann wird die Speicherneuordnung sichtbar?
Eine datenrennenfreie Programmierung erspart uns normalerweise bei der Neuanordnung des Arbeitsspeicherzugriffs. Es gibt jedoch mehrere Fälle, in denen welche Neuordnung sichtbar wird:- Wenn bei Ihrem Programm ein Fehler auftritt,
der zu einem unbeabsichtigten Wettlauf der Daten führt,
Compiler- und Hardwaretransformationen sichtbar werden können
Ihres Programms überraschend sein könnte. Wenn wir zum Beispiel vergessen haben,
flag
im vorherigen Beispiel flüchtig ist, sieht Thread 2 möglicherweise eineA
nicht initialisiert. Oder der Compiler könnte entscheiden, sich während der Schleife von Thread 2 möglicherweise ändern und das ProgrammThread 1 Thread 2 A = ...
flag = truereg0 = Flag; während (!reg0) {}
... = Aflag
wahr ist. - C++ bietet Möglichkeiten zum expliziten Entspannen
sequenzielle Konsistenz,
auch wenn es keine Rennen gibt. Atomoperationen
kann explizite
memory_order_
...-Argumente annehmen. In ähnlicher Weise Das Paketjava.util.concurrent.atomic
bietet eine stärker eingeschränkte ähnliche Einrichtungen, insbesonderelazySet()
. und Java Programmierer nutzen gelegentlich vorsätzliche Data Races, um einen ähnlichen Effekt zu erzielen. All dies sorgt für Leistungsverbesserungen Programmierkomplexität. Wir besprechen sie nur kurz. unten. - C- und C++-Code wird teilweise in einem älteren Stil geschrieben, der nicht ganz
entspricht den aktuellen Sprachstandards, in denen
volatile
stattatomic
-Variablen verwendet werden, und die Speicherreihenfolge durch Einfügen von so genannten Zäunen oder Hindernisse. Dies erfordert eine explizite Begründung für die Neusortierung des Zugriffs und ein Verständnis der Hardwarespeichermodelle. Ein solcher Programmierstil wird noch im Linux-Kernel verwendet. Sie sollte nicht in neuen Android-Anwendungen verwendet werden und wird hier auch nicht weiter behandelt.
Übung
Das Beheben von Problemen mit der Speicherkonsistenz kann sehr schwierig sein. Wenn eine fehlende
Deklaration von „Sperren“, „atomic
“ oder „volatile
“
um veraltete Daten zu lesen, können Sie
Speicher-Dumps mit einem Debugger untersuchen. Wenn Sie wissen,
eine Debugger-Abfrage ausführen, haben die CPU-Kerne möglicherweise alle
und der Speicherinhalt und die CPU-Register befinden sich
einen „Unmöglichen“ Zustand haben.
Was ist in C nicht zu tun?
Hier präsentieren wir einige Beispiele für falschen Code sowie einfache Möglichkeiten, zu beheben. Vorher müssen wir jedoch über die Verwendung einer einfachen Sprache sprechen. .
C/C++ und „flüchtig“
C- und C++-volatile
-Deklarationen sind ein sehr spezielles Tool.
Sie verhindern, dass der Compiler flüchtig neu anordnen oder entfernen kann.
Zugriffe. Dies kann hilfreich sein, wenn Code
auf Hardware-Registerkarten zugreift,
Arbeitsspeicher gespeichert, der mehreren
Speicherorten zugeordnet ist, oder in Verbindung mit
setjmp
Aber C und C++ volatile
, im Gegensatz zu Java
volatile
ist nicht für die Thread-Kommunikation vorgesehen.
Zugriff in C und C++ auf volatile
Daten können neu angeordnet werden, indem auf nichtflüchtige Daten zugegriffen wird.
Atomaritätsgarantien. Daher kann volatile
nicht zum Teilen von Daten zwischen
Threads in portablen Code, sogar auf einem Uniprozessor. C volatile
verhindert in der Regel nicht die Neusortierung des Zugriffs durch die Hardware. Daher ist es in SMP-Umgebungen mit mehreren Threads an sich noch weniger nützlich. Aus diesem Grund unterstützen C11 und C++11 atomic
-Objekte. Verwenden Sie stattdessen diese.
Ein großer Teil älterer C- und C++-Codes missbraucht volatile
immer noch für Threads.
Kommunikation. Dies funktioniert oft korrekt bei Daten,
in einem Maschinenregister eingetragen, wenn sie mit expliziten Zäunen oder
in denen die Speicherreihenfolge
keine Rolle spielt. Es kann jedoch nicht garantiert werden, dass es mit zukünftigen Compilern korrekt funktioniert.
Beispiele
In den meisten Fällen ist ein Schloss besser geeignet (z. B.
pthread_mutex_t
oder C++11 std::mutex
) anstelle eines
atomaren Operation, aber wir verwenden Letzteres, um zu veranschaulichen,
die in der Praxis verwendet werden.
MyThing* gGlobalThing = NULL; // Wrong! See below. void initGlobalThing() // runs in Thread 1 { MyStruct* thing = malloc(sizeof(*thing)); memset(thing, 0, sizeof(*thing)); thing->x = 5; thing->y = 10; /* initialization complete, publish */ gGlobalThing = thing; } void useGlobalThing() // runs in Thread 2 { if (gGlobalThing != NULL) { int i = gGlobalThing->x; // could be 5, 0, or uninitialized data ... } }
Die Idee dahinter ist, dass wir eine Struktur zuordnen, ihre Felder initialisieren und Am Ende "veröffentlichen" wir sie, indem wir sie in einer globalen Variablen speichern. Ab diesem Zeitpunkt kann jeder andere Thread sie sehen, aber das ist in Ordnung, da er vollständig initialisiert ist. oder?
Das Problem ist, dass der Speicher für gGlobalThing
beobachtet werden konnte
bevor die Felder initialisiert werden. Dies liegt in der Regel daran, dass entweder der Compiler oder der
Der Verarbeiter hat die Geschäfte nach gGlobalThing
und
thing->x
. Ein anderer Thread, der aus thing->x
liest, könnte
5, 0 oder sogar nicht initialisierte Daten.
Das Kernproblem ist ein Wettlauf auf gGlobalThing
.
Wenn Thread 1 initGlobalThing()
aufruft, während Thread 2 useGlobalThing()
aufruft, kann gGlobalThing
gelesen werden, während es geschrieben wird.
Sie können das Problem beheben, indem Sie gGlobalThing
als
atomar. In C++11:
atomic<MyThing*> gGlobalThing(NULL);
So wird sichergestellt, dass die Schreibvorgänge für andere Threads in der richtigen Reihenfolge sichtbar werden. Es garantiert auch,
andere Fehler zu vermeiden,
Modi, die normalerweise zulässig sind, aber in der realen Welt unwahrscheinlich sind
Android-Hardware So wird beispielsweise sichergestellt, dass wir
Zeiger gGlobalThing
, der nur teilweise geschrieben wurde.
Was in Java nicht zu tun ist
Da wir einige relevante Java-Sprachfunktionen noch nicht besprochen haben, einen kurzen Blick darauf.
Aus technischer Sicht setzt Java voraus, dass Code nicht frei von Datenrennen ist. Und das war's auch schon eine kleine Menge sehr sorgfältig geschriebenem Java-Code, der auch bei Datenwettkämpfen. Das Schreiben solcher Codes ist jedoch extrem und werden im Folgenden kurz erläutert. Um alles im Blick zu behalten Schlimmer noch, die Experten, die die Bedeutung eines solchen Codes angegeben haben, die Spezifikation korrekt ist. (Die Spezifikation ist in Ordnung für Code.)
Vorerst werden wir uns an das Modell ohne Datenrennen halten, für das Java
im Wesentlichen dieselben Garantien wie C und C++. Auch hier bietet die Sprache
einige Primitive lockern, die die sequenzielle Konsistenz explizit lockern, insbesondere die
lazySet()
- und weakCompareAndSet()
-Anrufe
in java.util.concurrent.atomic
.
Wie bei C und C++ werden diese vorerst ignoriert.
Javas „synchronisiert“ und „volatil“, Keywords
Das Schlüsselwort „synchronized“ (synchronisiert) stellt die in die Java-Sprache integrierte Sperrfunktion Mechanismus zur Verfügung. Jedem Objekt ist ein „Monitor“ zugeordnet, über den sich gegenseitig ausschließende Zugriffe. Wenn zwei Threads versuchen, sich zu „synchronisieren“ am Objekt erstellen, wartet eines von ihnen, bis das andere abgeschlossen ist.
Wie bereits erwähnt, ist volatile T
von Java das Analog zu
atomic<T>
von C++11 Gleichzeitige Zugriffe auf
volatile
-Felder sind zulässig und führen nicht zu einem Wettlauf der Daten.
lazySet()
et al. werden ignoriert. ist es Aufgabe der Java-VM,
dass das Ergebnis weiterhin sequentiell konsistent ist.
Insbesondere, wenn Thread 1 in ein volatile
-Feld schreibt und Thread 2 anschließend aus demselben Feld liest und den neu geschriebenen Wert sieht, sieht Thread 2 garantiert auch alle Schreibvorgänge, die zuvor von Thread 1 ausgeführt wurden. In Bezug auf den Gedächtniseffekt
eine flüchtige Version ist analog zu einer Monitor-Version, und
Daten von einer flüchtigen
Quelle ist wie eine Monitor-Akquisition.
Es gibt einen nennenswerten Unterschied zur atomic
in C++:
Wenn wir volatile int x;
schreiben
in Java, dann ist x++
gleich x = x + 1
. sie
führt eine atomare Last aus, inkrementiert das Ergebnis und führt dann
speichern. Im Gegensatz zu C++ ist das Inkrement als Ganzes nicht atomar.
Atomar-Inkrement-Operationen werden stattdessen
java.util.concurrent.atomic
.
Beispiele
Hier ist eine einfache, falsche Implementierung eines monotonen Zählers: (Java Theorie und Praxis: Bewältigung von Volatilität).
class Counter { private int mValue; public int get() { return mValue; } public void incr() { mValue++; } }
Angenommen, get()
und incr()
werden von mehreren
Wir möchten, dass jeder Thread die aktuelle Anzahl sieht,
get()
wird aufgerufen. Das auffälligste Problem ist,
mValue++
besteht aus drei Operationen:
reg = mValue
reg = reg + 1
mValue = reg
Wenn zwei Threads gleichzeitig in incr()
ausgeführt werden, geht möglicherweise eines der Updates verloren. Um das Inkrement atomar zu machen, müssen wir
incr()
„synchronisiert“.
Vor allem bei sozialen Netzwerken funktioniert sie jedoch immer noch nicht. Es gibt immer noch einen Wettlauf mit den Daten,
da get()
gleichzeitig auf mValue
zugreifen kann mit
incr()
Unter Java-Regeln kann der get()
-Aufruf
in Bezug auf den anderen Code neu angeordnet werden. Wenn wir zum Beispiel zwei
Zähler in einer Zeile erscheinen, können die Ergebnisse inkonsistent erscheinen.
weil wir die get()
-Aufrufe entweder durch die Hardware oder
Compiler. Wir können das Problem beheben, indem wir get()
als
synchronisiert. Nach dieser Änderung ist der Code natürlich korrekt.
Leider haben wir die Möglichkeit von Sperrkonflikten eingeführt, was die Leistung beeinträchtigen kann. Anstatt get()
als synchronisiert zu deklarieren, könnten wir mValue
mit „volatile“ deklarieren. Hinweis: Für incr()
muss weiterhin synchronize
verwendet werden, da mValue++
sonst kein einzelner atomarer Vorgang ist.
Dies vermeidet außerdem sämtliche Datenläufe, sodass die sequenzielle Konsistenz erhalten bleibt.
incr()
ist etwas langsamer, da bei diesem Vorgang sowohl Monitorein- und -ausstiege betroffen sind.
und den Aufwand für einen flüchtigen Speicher.
get()
ist schneller. Das heißt, selbst wenn kein Konflikt besteht, ist dies
wenn die Zahl der Schreibvorgänge
größer als die Zahl der Schreibvorgänge ist. (Unter AtomicInteger
erfahren Sie, wie Sie
um den synchronisierten Block zu entfernen.)
Hier ist ein weiteres Beispiel, das in seiner Form den vorherigen C-Beispielen ähnelt:
class MyGoodies { public int x, y; } class MyClass { static MyGoodies sGoodies; void initGoodies() { // runs in thread 1 MyGoodies goods = new MyGoodies(); goods.x = 5; goods.y = 10; sGoodies = goods; } void useGoodies() { // runs in thread 2 if (sGoodies != null) { int i = sGoodies.x; // could be 5 or 0 .... } } }
Dies hat das gleiche Problem wie der C-Code, nämlich dass es
ein Datenwettlauf am sGoodies
. Daher ist die Zuweisung
sGoodies = goods
wird möglicherweise vor der Initialisierung des
Felder in goods
. Wenn Sie sGoodies
mit dem Keyword volatile
deklarieren, wird die sequenzielle Konsistenz wiederhergestellt und alles funktioniert wie erwartet.
Beachten Sie, dass nur der sGoodies
-Verweis selbst flüchtig ist. Die
auf die darin enthaltenen Felder nicht. Sobald sGoodies
gleich
volatile
und die Speicherreihenfolge ordnungsgemäß beibehalten wird, werden die Felder
kann nicht gleichzeitig aufgerufen werden. Die Anweisung z =
sGoodies.x
führt eine volatile Ladung von MyClass.sGoodies
gefolgt von einer nichtflüchtigen Ladung von sGoodies.x
aus. Wenn Sie einen lokalen
Verweis-MyGoodies localGoods = sGoodies
enthält, führt ein nachfolgendes z =
localGoods.x
keine flüchtigen Ladevorgänge aus.
Eine gängigere Redewendung in der Java-Programmierung ist die berüchtigte „überprüfte Sperren“:
class MyClass { private Helper helper = null; public Helper getHelper() { if (helper == null) { synchronized (this) { if (helper == null) { helper = new Helper(); } } } return helper; } }
Die Idee ist, dass wir eine einzelne Instanz einer Helper
haben möchten,
Objekt, das mit einer Instanz von MyClass
verknüpft ist. Wir dürfen nur
Also erstellen wir sie und geben sie über eine dedizierte getHelper()
zurück.
. Um einen Wettlauf zu vermeiden, in dem zwei Threads die Instanz erstellen, müssen wir
und synchronisieren die Objekterstellung. Wir möchten jedoch nicht die Gemeinkosten für
den „synchronisierten“ Block bei jedem Aufruf.
Dieser Teil wird also nur ausgeführt,
helper
ist derzeit null.
Es gibt einen Wettlauf um das Feld helper
. Es kann sein,
wird gleichzeitig mit dem helper == null
in einem anderen Thread festgelegt.
Um zu sehen, wie dies scheitern kann,
denselben Code leicht umgeschrieben, als wäre er in eine C-ähnliche Sprache kompiliert.
(Ich habe einige Ganzzahlfelder hinzugefügt, um Helper’s
darzustellen
Konstruktoraktivität):
if (helper == null) { synchronized() { if (helper == null) { newHelper = malloc(sizeof(Helper)); newHelper->x = 5; newHelper->y = 10; helper = newHelper; } } return helper; }
Weder die Hardware noch der Compiler können durch nichts verhindert werden.
von der Neubestellung des Store über helper
bis hin zur
x
/y
-Feldern. Ein anderer Thread könnte feststellen, dass helper
nicht null ist, seine Felder aber noch nicht festgelegt und zur Verwendung bereit sind.
Weitere Details und Fehlermodi finden Sie im Abschnitt
„Locking is Defekt“-Erklärung“ im Anhang finden Sie weitere Informationen oder
71 („Use lazy initialisierung judiciously“) in Josh Blochs Effective Java,
2nd Edition.
Es gibt zwei Möglichkeiten, dieses Problem zu beheben:
- Führen Sie die einfache Aktion aus und löschen Sie die äußere Prüfung. So wird sichergestellt, dass
Untersuchen Sie den Wert von
helper
außerhalb eines synchronisierten Blocks. - Deklarieren Sie
helper
als „volatile“. Mit dieser einen kleinen Änderung in Beispiel J-3 funktioniert auf Java 1.5 und höher korrekt. (Sie können sich um sich selbst davon zu überzeugen, dass dies richtig ist.)
Hier ist eine weitere Abbildung des Verhaltens von volatile
:
class MyClass { int data1, data2; volatile int vol1, vol2; void setValues() { // runs in Thread 1 data1 = 1; vol1 = 2; data2 = 3; } void useValues() { // runs in Thread 2 if (vol1 == 2) { int l1 = data1; // okay int l2 = data2; // wrong } } }
Unter useValues()
wird geprüft, falls Thread 2 die
auf vol1
aktualisieren, dann kann nicht festgestellt werden, ob data1
oder
data2
wurde noch festgelegt. Sobald das Update für
vol1
, sie weiß, dass auf data1
sicher zugegriffen werden kann
korrekt zu lesen,
ohne einen Wettlauf der Daten einzuleiten. Sie können jedoch
Es können keine Annahmen über data2
getroffen werden, da dieses Geschäft
die nach dem volatilen Speicher erfolgt.
Hinweis: Mit volatile
kann nicht verhindert werden, dass andere Speicherzugriffe, die miteinander in Konflikt stehen, neu angeordnet werden. Es ist nicht garantiert,
und generieren eine Fence-Anweisung für den Maschinenspeicher. Damit lässt sich verhindern,
Wettlauf auf Daten, indem Code nur dann ausgeführt wird, wenn ein anderer Thread eine
bestimmte Krankheit.
Deine Aufgabe:
In C/C++ C++11 bevorzugen
Synchronisierungsklassen wie std::mutex
. Falls nicht, verwenden Sie
die entsprechenden pthread
-Vorgänge.
Dazu gehören die richtigen Speicherzäune, die korrekte und sequenzielle
sofern nicht anders angegeben)
und effizientes Verhalten auf allen Android-Plattformversionen zu ermöglichen. Nutzen Sie diese unbedingt
korrekt sind. Denken Sie z. B. daran, dass Wartezeiten mit Bedingungsvariablen viel zu viel länger brauchen.
zurückgegeben, ohne dass ein Signal erfolgt,
und sollte daher in einer Schleife angezeigt werden.
Atomare Funktionen sollten nicht direkt verwendet werden, es sei denn, die Datenstruktur die Implementierung ist äußerst einfach, wie etwa ein Zähler. Sperren und das Entsperren eines pthread-mutex erfordert jeweils einen einzelnen atomaren Vorgang, und kosten oft weniger als einen Cache-Fehler. Konflikten. Sie sparen also nicht viel, wenn Sie Stummschaltungsanrufe atomic Ops. Sperrenfreie Designs für nicht triviale Datenstrukturen erfordern sehr viel wichtiger ist, dass Operationen auf höherer Ebene an der Datenstruktur durchgeführt werden, atomar erscheinen (als Ganzes, nicht nur ihre explizit atomaren Teile).
Wenn Sie atomare Vorgänge verwenden, kann eine lockere Sortierung mit memory_order
… oder lazySet()
Leistungsvorteile bieten, erfordert aber ein tieferes Verständnis als wir bisher vermittelt haben.
Ein großer Teil des vorhandenen Codes mit
dass sie im Nachhinein Fehler aufweisen. Vermeiden Sie diese, wenn möglich.
Wenn Ihre Anwendungsfälle nicht
genau zu einem der Anwendungsfälle im nächsten Abschnitt passen,
ob Sie Experte sind oder sich beraten lassen.
Verwenden Sie volatile
nicht für die Threadkommunikation in C/C++.
In Java lassen sich Nebenläufigkeitsprobleme oft am besten
entsprechende Dienstprogrammklasse aus
java.util.concurrent
-Paket. Der Code ist gut geschrieben und gut
die im SMP getestet wurden.
Vielleicht ist das sicherste, was Sie tun können, Ihre Objekte unveränderlich zu machen. Objekte wie String- und Integer-Hold-Daten in Java, die nicht mehr geändert werden können, -Objekt erstellt wird. Dadurch wird jegliches Potenzial für Datenüberläufe bei diesen Objekten vermieden. Das Buch Java, 2. Ed. enthält spezifische Anweisungen in „Punkt 15: Minimierbarkeit von Änderbarkeit“. Notiz in insbesondere die Bedeutung der Deklaration von Java-Feldern (Bloch)
Auch wenn ein Objekt unveränderlich ist, denken Sie daran,
ohne Synchronisierung ist
ein Datenwettlauf. Dies kann gelegentlich
in Java akzeptabel sein (siehe unten), erfordert jedoch große Sorgfalt und führt wahrscheinlich
und abfälliger Code. Wenn es nicht extrem leistungskritisch ist, fügen Sie eine volatile
-Erklärung hinzu. In C++ ist die Kommunikation eines Zeigers oder einer Referenz auf ein unveränderliches Objekt ohne ordnungsgemäße Synchronisierung, wie bei jeder Datenübertragung, ein Fehler.
In diesem Fall ist es relativ wahrscheinlich, dass es zu zeitweiligen Abstürzen kommt, da
Zum Beispiel könnte der empfangende Thread eine nicht initialisierte Methodentabelle sehen,
Zeiger aufgrund der Neuanordnung des Geschäfts.
Wenn weder eine vorhandene Bibliotheksklasse noch eine unveränderliche Klasse
die Java-Anweisung synchronized
oder die C++-
lock_guard
/ unique_lock
sollten zum Schutz
Zugriff auf jedes Feld, auf das von mehr als einem Thread zugegriffen werden kann. Wenn Mutexe nicht
für Ihre Situation geeignet, sollten Sie freigegebene Felder
volatile
oder atomic
, aber Sie müssen darauf achten,
um die Interaktionen zwischen
Threads besser zu verstehen. Diese Deklarationen enthalten keine
ersparen Sie sich häufige Programmierfehler, aber sie helfen Ihnen,
die mysteriösen Fehler im Zusammenhang mit der Optimierung von Compilern und SMP vermeiden
Missgeschicke passieren.
Sie sollten Folgendes vermeiden: „Veröffentlichung“ Verweis auf ein Objekt, d.h., es für andere Nutzer verfügbar zu machen Threads in ihrem Konstruktor. In C++ ist das weniger kritisch oder wenn Sie sich an unsere Empfehlung „Keine Datenrennen“ in Java halten. Dieser Rat ist jedoch immer gut und wird kritisch, wenn Ihr Java-Code in anderen Kontexten ausgeführt wird, in denen das Java-Sicherheitsmodell wichtig ist, und nicht vertrauenswürdiger Code durch Zugriff auf diese „geleakte“ Objektreferenz zu einem Datenrennen führen kann. Es ist auch wichtig, wenn Sie unsere Warnungen ignorieren und einige der Techniken im nächsten Abschnitt. Siehe (sichere Konstruktionstechniken in Java) für Details
Weitere Informationen zu schwachen Speicherbestellungen
C++11 und höher bieten explizite Mechanismen zum Lockern sequenzieller Elemente
Konsistenz garantiert für Programme ohne Datenrennen. Explizit
memory_order_relaxed
, memory_order_acquire
(Wird geladen)
nur) und memory_order_release
(nur speichert) Argumente für atomare
Operationen bieten jeweils schwächere Garantien als der Standardwert,
implizit: memory_order_seq_cst
. memory_order_acq_rel
gibt sowohl memory_order_acquire
als auch
memory_order_release
garantiert für atomares Read-Change-Schreibvorgang
Geschäftsabläufe. memory_order_consume
ist noch nicht ausreichend spezifiziert oder implementiert, um nützlich zu sein, und sollte vorerst ignoriert werden.
Die lazySet
-Methoden in Java.util.concurrent.atomic
ähneln C++-memory_order_release
-Speichern. Java
werden mitunter als Ersatz für
memory_order_relaxed
-Zugriffe, obwohl es sich tatsächlich um
noch schwächer werden. Im Gegensatz zu C++ gibt es keinen echten Mechanismus für unsortierte Zugriffe auf Variablen, die als volatile
deklariert sind.
Sie sollten sie im Allgemeinen vermeiden, es sei denn, es gibt dringende Leistungsgründe, sie zu verwenden. Bei schwach geordneten Maschinenarchitekturen wie ARM für jeden atomaren Vorgang etwa ein paar Dutzend Maschinenzyklen einsparen. Auf x86 ist der Leistungsgewinn auf Geschäfte beschränkt und liegt wahrscheinlich geringer auffällig sind. Etwas kontraintuitiv kann der Vorteil bei einer größeren Kernanzahl sinken, da der Arbeitsspeicher dann eher ein begrenzender Faktor wird.
Die vollständige Semantik von schwach geordneten Atomen ist kompliziert. Im Allgemeinen benötigen sie die Sprachregeln genau zu verstehen. nicht hierhin gehen. Beispiel:
- Der Compiler oder die Hardware kann
memory_order_relaxed
verschieben Zugang zu einem kritischen Bereich, der durch ein Schloss begrenzt ist (aber nicht aus einem heraus) Akquisition und Veröffentlichung. Das bedeutet, dass zweimemory_order_relaxed
Geschäfte werden möglicherweise nicht in der richtigen Reihenfolge angezeigt, auch wenn sie durch kritische Abschnitte getrennt sind. - Wenn eine gewöhnliche Java-Variable als freigegebener Zähler missbraucht wird, kann sie für einen anderen Thread abnehmen, obwohl sie nur von einem anderen Thread erhöht wird. Dies gilt jedoch nicht für atomare C++-
memory_order_relaxed
Mit dieser Warnung möchte ich Sie darauf hinweisen, Wir geben hier einige Redewendungen an, die viele der für schwach geordnete Atome. Viele davon gelten nur für C++.
Zugriffe außerhalb des Rennens
Eine Variable ist recht häufig atomar, weil sie manchmal
aber nicht bei allen Zugriffen tritt dieses Problem auf.
Eine Variable kann z. B.
müssen möglicherweise atomar sein, da sie außerhalb eines kritischen Abschnitts gelesen werden, aber alle
sind durch eine Sperre geschützt. In diesem Fall kann es bei einem Lesevorgang, der zufällig durch dieselbe Sperre geschützt ist, nicht zu einem Wettlauf kommen, da es keine gleichzeitigen Schreibvorgänge geben kann. In einem solchen Fall
Zugriff ohne Rennen (in diesem Fall laden) kann mit
memory_order_relaxed
, ohne die Richtigkeit des C++-Codes zu ändern.
Die Sperrimplementierung erzwingt bereits die erforderliche Arbeitsspeicherreihenfolge
im Hinblick auf den Zugriff durch andere Threads und memory_order_relaxed
gibt an, dass im Wesentlichen keine zusätzlichen
für den atomischen Zugriff erzwungen.
In Java gibt es keine entsprechende Analogie.
Die Richtigkeit des Ergebnisses wird nicht zuverlässig ermittelt.
Wenn wir einen Rennlast nur zum Generieren eines Hinweises verwenden, ist das im Allgemeinen auch in Ordnung.
um keine Speicherreihenfolge
für die Last zu erzwingen. Wenn der Wert gleich
nicht zuverlässig ist, können wir aus dem Ergebnis auch nicht
Variablen enthalten. Daher ist es in Ordnung
Speicheranordnung ist nicht garantiert und die Last ist
mit einem memory_order_relaxed
-Argument bereitgestellt.
Eine gemeinsame
Instanz davon ist die Verwendung von C++ compare_exchange
um x
automatisch durch f(x)
zu ersetzen.
Der anfängliche Ladevorgang von x
für die Berechnung von f(x)
muss nicht zuverlässig sein. Wenn wir falsch liegen,
compare_exchange
schlägt fehl und wir versuchen es noch einmal.
Der anfängliche Ladevorgang von x
ist in Ordnung.
Ein memory_order_relaxed
-Argument; Nur Arbeitsspeicherreihenfolge
für den tatsächlichen compare_exchange
.
Atomar geänderte, aber ungelesene Daten
Gelegentlich werden Daten parallel durch mehrere Threads geändert,
erst überprüft werden, wenn die Parallelberechnung abgeschlossen ist. Ein gutes Beispiel hierfür ist ein Zähler, der von mehreren Threads parallel atomisch erhöht wird (z. B. mit fetch_add()
in C++ oder atomic_fetch_add_explicit()
in C), das Ergebnis dieser Aufrufe jedoch immer ignoriert wird. Der sich daraus ergebende Wert wird
nur am Ende gelesen.
nachdem alle Aktualisierungen abgeschlossen sind.
In diesem Fall kann nicht festgestellt werden, ob Zugriffe auf diese Daten neu angeordnet wurden. Daher kann C++-Code ein memory_order_relaxed
-Argument verwenden.
Ein einfaches Beispiel hierfür sind Ereigniszähler. Da es sich um häufig vorkommen, lohnt es sich, einige Beobachtungen zu diesem Fall anzustellen:
- Mit
memory_order_relaxed
wird die Leistung verbessert, aber nicht auf das wichtigste Leistungsproblem reagieren: bei jeder Aktualisierung erfordert exklusiven Zugriff auf die Cache-Zeile, in der sich der Zähler befindet. Dies führt jedes Mal zu einem Cache-Miss, wenn ein neuer Thread auf den Zähler zugreift. Wenn Aktualisierungen häufig sind und zwischen den Threads wechseln, ist es viel schneller, den freigegebenen Zähler nicht jedes Mal zu aktualisieren. Verwenden Sie dazu beispielsweise threadlokale Zähler und summieren Sie sie am Ende. - Diese Technik ist mit dem vorherigen Abschnitt kombinierbar:
ungefähre und unzuverlässige Werte lesen,
während sie aktualisiert werden,
mit allen Vorgängen unter Verwendung von
memory_order_relaxed
. Es ist jedoch wichtig, die resultierenden Werte als völlig unzuverlässig zu betrachten. Nur weil der Zähler scheinbar einmalig erhöht wurde, dass ein anderer Thread den Punkt erreicht hat, an dem das Inkrement durchgeführt wurde. Das Inkrement kann stattdessen mit früherem Code neu angeordnet. (In unserem ähnlichen Fall garantiert, dass ein zweites Laden eines solchen Zählers einen Wert zurückgeben, der kleiner ist als ein früherer Ladevorgang im selben Thread. Es sei denn von ist der Zähler überlaufen.) - Häufig wird Code gefunden, der versucht, Zählerwerte durch Ausführung einzelner (oder nicht) atomarer Lese- und Schreibvorgänge, nicht als ganzes Inkrement. Das übliche Argument ist, ist das „nah genug“, für Leistungsindikatoren oder Ähnliches. Normalerweise nicht. Wenn genügend Updates vorhanden sind (ein Fall wahrscheinlich interessieren, ist ein großer Teil der Zahlen verloren. Auf einem Quad-Core-Gerät gehen häufig mehr als die Hälfte der Zählungen verloren. (Einfache Übung: Erstellen Sie ein Szenario mit zwei Threads, in dem der Zähler aber der letzte Zählerwert ist 1.
Einfache Flaggenkommunikation
Ein memory_order_release
-Speicher (oder Read-Change-Write-Vorgang)
sorgt dafür, dass, wenn anschließend ein memory_order_acquire
-Ladevorgang
(oder Read-Modify-Write-Vorgang) den geschriebenen Wert liest, wird er
auch alle normalen oder atomaren Geschäfte
Ein memory_order_release
-Shop. Umgekehrt werden Ladevorgänge
vor memory_order_release
keine Auswirkungen auf
die auf das Laden von memory_order_acquire
folgten.
Im Gegensatz zu memory_order_relaxed
sind so atomare Vorgänge damit möglich.
verwendet werden, um den Fortschritt eines Threads an einen anderen zu kommunizieren.
Beispielsweise können wir das Beispiel aus oben in C++ als
class MyClass { private: atomic<Helper*> helper {nullptr}; mutex mtx; public: Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper == nullptr) { lock_guard<mutex> lg(mtx); myHelper = helper.load(memory_order_relaxed); if (myHelper == nullptr) { myHelper = new Helper(); helper.store(myHelper, memory_order_release); } } return myHelper; } };
Der Speicher für Abrufe und Releases sorgt dafür, dass bei einem
helper
eingeben, werden auch die Felder korrekt initialisiert.
Wir haben auch die vorherige Beobachtung berücksichtigt, dass Lasten ohne Rennen
kann memory_order_relaxed
verwenden.
Ein Java-Programmierer könnte helper
daher als
java.util.concurrent.atomic.AtomicReference<Helper>
und lazySet()
als Veröffentlichungsspeicher verwenden. Die Last
Bei -Vorgängen würden weiterhin einfache get()
-Aufrufe verwendet.
In beiden Fällen konzentrierte sich unsere Leistungsoptimierung auf die Initialisierung -Pfad, der wahrscheinlich keine leistungskritische Komponente aufweist. Eine besser lesbare Manipulation könnte folgendermaßen aussehen:
Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper != nullptr) { return myHelper; } lock_guard<mutex> lg(mtx); if (helper == nullptr) { helper = new Helper(); } return helper; }
Dies ist der gleiche schnelle Weg, sequenzielle Konsistenz, Vorgänge auf der nicht leistungskritischen Pfad.
Auch hier wird helper.load(memory_order_acquire)
auf aktuellen von Android unterstützten Architekturen wahrscheinlich denselben Code generieren wie eine einfache (sequentiell konsistente) Referenz auf helper
. Die vorteilhafteste Optimierung ist hier die Einführung von myHelper
, um eine zweite Ladung zu vermeiden. Ein zukünftiger Compiler könnte dies jedoch automatisch tun.
Kauf- und Freigabebestellungen verhindern nicht, dass Geschäfte sichtbar sind.
verzögert sich und sorgt nicht dafür, dass Geschäfte für andere Threads sichtbar werden
in einer einheitlichen
Reihenfolge geordnet werden. Daher unterstützt es keine schwierigen,
aber ein ziemlich verbreitetes Codierungsmuster, das durch Dekkers wechselseitigen Ausschluss verdeutlicht wird.
Algorithmus: Alle Threads legen zuerst ein Flag fest, das darauf hinweist, dass sie eine Aktion ausführen möchten.
etwas; Wenn ein Thread t feststellt, dass kein anderer Thread
wenn jemand versucht, etwas zu tun,
wird nicht gestört. Kein anderer Thread wird
fortfahren können, da das Flag von t noch gesetzt ist. Fehlgeschlagen
Der Zugriff auf das Flag erfolgt über die Reihenfolge von Akquisition/Veröffentlichung.
verhindern, dass die Markierung eines Threads zu einem späteren Zeitpunkt für andere sichtbar ist, nachdem diese
fälschlicherweise fortgefahren ist. Standard-memory_order_seq_cst
verhindert.
Unveränderliche Felder
Wenn ein Objektfeld bei der ersten Verwendung initialisiert und dann nie geändert wird,
kann es möglicherweise initialisiert und anschließend mit schwachem
geordnete Zugriffe. In C++ könnte dies als atomic
deklariert werden.
und über memory_order_relaxed
oder in Java aufgerufen wird,
ohne volatile
deklariert und ohne Zugriff
besondere Maßnahmen. Hierfür müssen alle folgenden Holds angewendet werden:
- Der Wert im Feld selbst sollte erkennbar sein. ob er bereits initialisiert wurde. Um auf das Feld zuzugreifen, sollte das Feld nur einmal gelesen werden. Letzteres ist in Java unerlässlich. Auch wenn die Feldtests wie initialisiert kann ein zweiter Ladevorgang den früheren nicht initialisierten Wert lesen. In C++ „Einmal lesen“ ist eine gute Praxis.
- Sowohl die Initialisierung als auch nachfolgende Ladevorgänge müssen atomar sein.
dass Teilaktualisierungen nicht sichtbar sein sollten. Für Java ist das Feld
sollte kein
long
oderdouble
sein. Für C++: eine atomare Zuweisung ist erforderlich. wäre es nicht möglich, sie zu errichten, Die Konstruktion einesatomic
ist nicht atomar. - Wiederholte Initialisierungen müssen sicher sein, da mehrere Threads kann den nicht initialisierten Wert gleichzeitig lesen. In C++ folgt dies im Allgemeinen aus der Anforderung, dass alle atomaren Typen „trivial kopierbar“ sein müssen. Typen mit verschachtelten eigenen Pointern würden im Kopierkonstruktor eine Dealokalisierung erfordern und wären nicht trivial kopierbar. Bei Java sind bestimmte Referenztypen akzeptabel:
- Java-Referenzen sind auf unveränderliche Typen beschränkt, die nur finale Felder enthalten. Der Konstruktor des unveränderlichen Typs darf nicht veröffentlichen auf das Objekt verweisen. In diesem Fall gilt für die endgültigen Java-Feldregeln damit Leser, die die Referenz sehen, auch die die initialisierten endgültigen Felder. C++ hat kein Analog zu diesen Regeln und Verweise auf eigene Objekte sind ebenfalls aus diesem Grund inakzeptabel (in gegen die Richtlinien, die Anforderungen).
Abschlusshinweise
Dieses Dokument dient nicht nur dazu, die Oberfläche zu kratzen, als ein flacher Riss. Dies ist ein sehr weit gefasstes und tiefgreifendes Thema. Einige Bereiche zur weiteren Erkundung:
- Die tatsächlichen Java- und C++ Speichermodelle werden in Form eines
happens-before-Beziehung, die angibt, wann zwei Aktionen garantiert sind
in einer bestimmten
Reihenfolge erfolgen. Als wir einen Wettlauf der Daten definiert haben,
zwei Arbeitsspeicherzugriffe gesprochen,
die „gleichzeitig“ erfolgen.
Offiziell ist dies als kein Ereignis vor dem anderen definiert.
Es ist hilfreich, die tatsächlichen Definitionen von happens-before und synchronizes-with im Java- oder C++-Speichermodell zu kennen.
Obwohl das intuitive Konzept der gleichzeitigen ist im Allgemeinen gut
sind diese Definitionen aufschlussreich, besonders,
schwach geordnete atomare Operationen in C++ verwenden.
(Die aktuelle Java-Spezifikation definiert nur
lazySet()
. sehr informell.) - Erfahren Sie, was Compiler bei der Neuordnung von Code tun dürfen und was nicht. (Die JSR-133-Spezifikation enthält einige großartige Beispiele für rechtliche Transformationen, die zu unerwartete Ergebnisse.)
- Informieren Sie sich, wie Sie unveränderliche Klassen in Java und C++ schreiben. (Es geht dabei um mehr als nur „nach der Erstellung nichts mehr ändern“.)
- Internieren Sie die Empfehlungen im Abschnitt Nebenläufigkeit des Artikels Effektive Java, 2. Edition. So sollten Sie beispielsweise Methoden, die überschrieben werden sollen, nicht innerhalb eines synchronisierten Blocks aufrufen.
- Lies dir die
java.util.concurrent
- undjava.util.concurrent.atomic
-APIs durch, um zu sehen, was verfügbar ist. Erwägen Sie die Verwendung Nebenläufigkeitsannotationen wie@ThreadSafe
und@GuardedBy
(aus net.jcip.annotations).
Der Abschnitt Weitere Informationen im Anhang enthält Links zu Dokumenten und Websites, die diese Themen besser verdeutlichen.
Anhang
Synchronisierungsspeicher implementieren
Das ist bei den meisten Programmierern nicht nötig, aber die Diskussion ist aufschlussreich.)
Bei kleinen integrierten Typen wie int
und von Android unterstützter Hardware sorgen normale Lade- und Speicheranweisungen dafür, dass ein Speicher entweder vollständig oder gar nicht für einen anderen Prozessor sichtbar ist, der denselben Speicherort lädt. So wird ein grundlegendes Konzept der „Atomarität“ kostenlos zur Verfügung gestellt.
Wie wir bereits gesehen haben, reicht das nicht aus. Um eine sequentielle Konsistenz zu gewährleisten, müssen wir auch die Neuanordnung von Vorgängen verhindern und dafür sorgen, dass Speichervorgänge für andere Prozesse in einer konsistenten Reihenfolge sichtbar werden. Bei Android-Geräten erfolgt die letztere automatisch Voraussetzung ist, dass wir bei der Durchsetzung der ersten Deshalb ignorieren wir ihn hier weitgehend.
Die Reihenfolge der Speichervorgänge wird beibehalten, da beides die Neuanordnung verhindert. vom Compiler erstellt und eine Neuanordnung durch die Hardware verhindert wird. Hier konzentrieren wir uns zu Letzterem.
Speicheranordnung unter ARMv7, x86 und MIPS wird erzwungen mit
"zaun" Anweisungen, die
verhindern grob, dass Anweisungen, die dem Zaun folgen, sichtbar werden
vor den Anweisungen vor dem Zaun. (Dies geschieht auch häufig
namens „Barriere“ Anweisungen geben, aber es besteht
die Gefahr von Verwechslungen mit
Hürden im Stil von pthread_barrier
, die viel mehr können
als dieses.) Die genaue Bedeutung von
Zaunanleitungen sind ein ziemlich kompliziertes Thema,
die Garantien für verschiedene Arten von Zäunen.
interagieren und wie diese mit anderen
Bestellgarantien kombiniert werden,
die von der Hardware bereitgestellt werden. Dies ist eine allgemeine Übersicht.
diese Details nicht zu beeindrucken.
Die einfachste Art der Bestellgarantie ist die durch die C++-
memory_order_acquire
und memory_order_release
Atomare Vorgänge: Speichervorgänge vor einem Releasespeicher
sollte nach einem Download-Ladevorgang sichtbar sein. Bei ARMv7 ist dies
erzwungen durch:
- Vor der Anweisung für das Geschäft muss eine geeignete Anweisung für den Zaun eingefügt werden. Dadurch wird verhindert, dass alle vorherigen Speicherzugriffe mit der Store-Anweisung neu angeordnet werden. Außerdem wird unnötigerweise die Neubestellung mit einer späteren Anweisung für den Händler verhindert.
- Folgen Sie den Lastanweisungen mit einem geeigneten Zaun. Dadurch wird verhindert, dass die Ladevorgänge bei nachfolgenden Zugriffen neu angeordnet werden. (Und wieder eine unnötige Bestellung mit mindestens früheren Ladevorgängen.)
Zusammen reichen diese Werte für die Bestellung von C++-Akquisition/-Veröffentlichung aus.
Sie sind für Java erforderlich, aber nicht ausreichend. volatile
oder atomic
mit sequenzieller Konsistenz in C++.
Um zu sehen, was wir sonst noch brauchen, betrachten wir das Fragment des Dekker-Algorithmus
die wir zuvor kurz erwähnt haben.
flag1
und flag2
sind C++ atomic
oder Java volatile
, die beide anfänglich "false" sind.
Thread 1 | Thread 2 |
---|---|
flag1 = true |
flag2 = true |
Sequential Consistency bedeutet, dass eine der Aufgaben
flag
n muss zuerst ausgeführt werden und ist für den
im anderen Thread testen können. Daher werden wir nie
diese Threads die „kritischen Sachen“ gleichzeitig ausführen.
Die für die Erwerb-Freigabe-Reihenfolge erforderliche Absicherung fügt jedoch nur am Anfang und Ende jedes Threads Absicherungen hinzu, was hier nicht weiterhilft. Außerdem müssen wir dafür sorgen, dass die beiden nicht neu angeordnet werden, wenn auf einen volatile
/atomic
-Speicher eine volatile
/atomic
-Ladung folgt.
Dies wird normalerweise durch das Einbauen eines Zauns und nicht nur vor einem
des sequenziellen Konsistenten, aber auch danach.
(Dies ist wiederum viel stärker als erforderlich, da dieser Zaun in der Regel
alle früheren Speicherzugriffe im Hinblick auf alle späteren.)
Wir könnten den zusätzlichen Zaun stattdessen mit sequenzieller konstante Ladevorgänge. Da Geschäfte seltener sind, gilt die Konvention häufiger unter Android verwendet.
Wie wir in einem früheren Abschnitt gesehen haben, müssen wir zwischen den beiden Vorgängen eine Speicher-/Ladebarriere einfügen. Der Code, der in der VM für einen flüchtigen Zugriff ausgeführt wird, sieht in etwa so aus:
Flüchtige Last | flüchtiger Speicher |
---|---|
reg = A |
fence for "release" (2) |
Echte Maschinenarchitekturen bieten in der Regel mehrere Arten von Zäune, die unterschiedliche Zugangsmöglichkeiten bieten und unterschiedliche Kosten. Die Wahl zwischen ihnen ist subtil und dass Speicher für andere Kerne in einheitliche Reihenfolge und dass die Speicherreihenfolge, Kombination mehrerer Zäune korrekt aufgebaut. Weitere Informationen finden Sie auf der Seite der University of Cambridge mit Zuordnungen von Atomaren zu tatsächlichen Prozessoren gesammelt.
Bei einigen Architekturen, insbesondere x86, und „Release“ unnötig sind, da die Hardware immer implizit erzwingt eine ausreichende Reihenfolge. Daher ist auf x86 nur der letzte Fence (3) generiert wird. Ähnlich verhält es sich bei x86-, atomischen Read-Modify-Write- die Operationen implizit einen starken Zaun umfassen. Daher sind diese niemals Zäune erforderlich sind. Unter ARMv7 sind alle oben besprochenen Fechten erforderlich.
ARMv8 bietet LDAR- und STLR-Anweisungen, die direkt Erzwingen der Anforderungen von Java volatile oder sequenzieller Konsistenz in C++ lädt und speichert. Sie vermeiden die unnötigen Einschränkungen bei der Neuordnung, wie oben erwähnt. Der 64-Bit-Android-Code auf ARM verwendet folgende Funktionen: haben wir entschieden, konzentrieren wir uns auf die Platzierung von ARMv7-Zäunen, da sie mehr die tatsächlichen Anforderungen.
Weitere Informationen
Webseiten und Dokumente, die detaillierter oder breiter sind. Je allgemeiner nützlicher ist, weiter oben auf der Liste stehen.
- Konsistenzmodelle für gemeinsam genutzten Speicher: Eine Anleitung
- Dieser Artikel von Adve und Gharachorloo aus dem Jahr 1995 ist ein guter Ausgangspunkt, wenn Sie sich eingehender mit Speicherkonsistenzmodellen befassen möchten.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf - Gedächtnisbarrieren
- In diesem Artikel werden die Probleme zusammengefasst.
https://en.wikipedia.org/wiki/Memory_barrier - Grundlagen von Unterhaltungen
- Eine Einführung in die Multithread-Programmierung in C++ und Java von Hans Böhm. Diskussion von Datenrennen und grundlegenden Synchronisierungsmethoden.
http://www.hboehm.info/c++mm/threadsintro.html - Java-Nebenläufigkeit in der Praxis
- Dieses Buch wurde 2006 veröffentlicht und deckt eine Vielzahl von Themen sehr detailliert ab. Sehr empfehlenswert für alle, die Multithread-Code in Java schreiben.
http://www.javaconcurrencyinpractice.com (in englischer Sprache)
- Häufig gestellte Fragen zu JSR-133 (Java-Speichermodell)
- Eine kurze Einführung in das Java-Speichermodell, einschließlich einer Erläuterung der Synchronisierung, veränderlicher Variablen und der Konstruktion der endgültigen Felder.
(Ein bisschen veraltet, vor allem, wenn darin andere Sprachen behandelt werden.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html - Gültigkeit von Programmtransformationen im Java-Speichermodell
- Eine eher technische Erklärung der verbleibenden Probleme mit dem
Java-Speichermodell Diese Probleme gelten nicht für Daten ohne Datenrennen.
Programmen.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf - Paket java.util.concurrent
- Die Dokumentation für das Paket
java.util.concurrent
. Ganz unten auf der Seite finden Sie den Abschnitt „Eigenschaften der Speicherkonsistenz“, in dem die Garantien der verschiedenen Klassen erläutert werden.
Paketübersicht fürjava.util.concurrent
- Java-Theorie und -Praxis: Sichere Konstruktionstechniken in Java
- In diesem Artikel werden die Risiken von Verweisen, die bei der Objektkonstruktion verworfen werden, ausführlich untersucht. Außerdem finden Sie Richtlinien für threadsichere Konstruktoren.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - Java-Theorie und -Praxis: Bewältigung von Volatilität
- In diesem Artikel wird beschrieben, was mit flüchtigen Feldern in Java möglich ist und was nicht.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html - Erklärung „Doppelt überprüftes Sperren ist fehlerhaft“
- Bill Pugh hat eine detaillierte Erklärung der verschiedenen Möglichkeiten, wie die überprüfte Verriegelung ohne
volatile
oderatomic
aufgehoben werden kann, erklärt. Beinhaltet C/C++ und Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html - [ARM] Barrier Litmus-Tests und Cookbook
- Eine Beschreibung von ARM SMP-Problemen, die durch kurze ARM-Code-Snippets erklärt wird. Wenn Sie die Beispiele auf dieser Seite zu ungenau finden oder die formale Beschreibung der DMB-Anweisung lesen möchten, lesen Sie diesen Artikel. Beschreibt auch die Anweisungen für Speicherbarrieren bei ausführbarem Code (möglicherweise nützlich, wenn Sie schnell Code generieren). Dies ist eine Vorversion von ARMv8,
unterstützt zusätzliche Anweisungen zur Speicheranordnung und wurde zu einer etwas stärkeren
Speichermodells. Weitere Informationen finden Sie im „ARM®-Architektur-Referenzhandbuch ARMv8 für das ARMv8-A-Architekturprofil“.
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf (in englischer Sprache)
- Speicherbarrieren des Linux-Kernels
- Dokumentation zu Linux-Kernel-Speicherbarrieren. Enthält einige nützliche Beispiele und ASCII-Art.
http://www.kernel.org/doc/Documentation/memory-barriers.txt .
- ISO/IEC JTC1 SC22 WG21 (C++-Standards) 14882 (Programmiersprache C++), Abschnitt 1.10 und Klausel 29 („Atomic Operations Library“)
- Entwurf von Standards für atomare C++-Funktionen Diese Version ist
Sie entspricht dem C++14-Standard, was kleinere Änderungen in diesem Bereich einschließt.
aus C++11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(Einführung: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf) - ISO/IEC JTC1 SC22 WG14 (C-Standards) 9899 (Programmiersprache C) Kapitel 7.16 („Atomics <stdatomic.h>“)
- Entwurf einer Norm für Funktionen für atomare Vorgänge gemäß ISO/IEC 9899-201x C.
Weitere Informationen finden Sie auch in späteren Mängelberichten.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf - C/C++11-Zuordnungen zu Prozessoren (University of Cambridge)
- Die Übersetzungen von Jaroslav Sevcik und Peter Sewell
von C++-Atomaren in
verschiedene gängige Prozessor-Anweisungssätze.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html - Dekker-Algorithmus
- Die „erste bekannte korrekte Lösung für das Problem der gegenseitigen Ausschließung bei der parallelen Programmierung“. Der Wikipedia-Artikel enthält den vollständigen Algorithmus mit einer Diskussion darüber, wie er aktualisiert werden muss, um mit modernen optimierenden Compilern und SMP-Hardware zu funktionieren.
https://de.wikipedia.org/wiki/Dekker's_algorithm - Kommentare zu ARM und Alpha sowie Adressabhängigkeiten
- Eine E-Mail für die Arm-Kernel-Mailingliste von Catalin Marinas. Enthält eine schöne Zusammenfassung der Adress- und Steuerungsabhängigkeiten.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html - Was jeder Programmierer über den Arbeitsspeicher wissen sollte
- Ein sehr langer und detaillierter Artikel von Ulrich Drepper über verschiedene Arbeitsspeichertypen, insbesondere CPU-Caches.
http://www.akkadia.org/drepper/cpumemory.pdf - Begründung für das ARM-Modell mit schwacher Konsistenz
- Dieser Artikel wurde von Chong und Ishtiaq von ARM, Ltd. versucht, das ARM SMP-Speichermodell auf strenge, aber zugängliche Weise zu beschreiben. Die hier verwendete Definition von „Beobachtbarkeit“ stammt aus diesem Artikel. Auch dies ist älter als ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711 - JSR-133 Cookbook für Compiler-Autoren
- Doug Lea hat dieses Buch als Begleiter zur Dokumentation von JSR-133 (Java Memory Model) geschrieben. Sie enthält die ersten Implementierungsrichtlinien.
für das Java-Speichermodell, das von vielen Compiler-Schreibern verwendet wurde und
immer noch viel zitiert werden
und wahrscheinlich einen Einblick geben.
Leider sind die hier besprochenen vier Zaunvarianten nicht gut
für Android-unterstützte Architekturen und die oben genannten C++11-Zuordnungen
sind jetzt eine bessere Quelle für präzise Rezepte, sogar für Java.
http://g.oswego.edu/dl/jmm/cookbook.html ,
- x86-TSO: Ein strenges und nutzbares Programmer-Modell für x86-Multiprozessoren
- Eine genaue Beschreibung des x86-Speichermodells. Genaue Beschreibungen der
ARM-Arbeitsspeichermodelle sind leider deutlich komplizierter.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf (in englischer Sprache)