Android 3.0 und höhere Plattformversionen sind für die Unterstützung von Multiprozessorarchitekturen optimiert. In diesem Dokument werden Probleme vorgestellt, die beim Schreiben von Multithread-Code für symmetrische Multiprozessorsysteme in C, C++ und der Programmiersprache Java (im Folgenden der Einfachheit halber als „Java“ bezeichnet) auftreten können. Sie ist als Einführung für Android-App-Entwickler gedacht und nicht als vollständige Abhandlung des Themas.
Einführung
SMP ist ein Akronym für „Symmetric Multi-Processor“. Es beschreibt ein Design, bei dem zwei oder mehr identische CPU-Kerne Zugriff auf den Hauptspeicher haben. Bis vor einigen Jahren waren alle Android-Geräte UP-Geräte (Uni-Processor).
Die meisten, wenn nicht sogar alle Android-Geräte hatten schon immer mehrere CPUs. In der Vergangenheit wurde jedoch nur eine davon zum Ausführen von Anwendungen verwendet, während die anderen verschiedene Teile der Gerätehardware (z. B. das Funkmodul) verwalteten. Die CPUs hatten möglicherweise unterschiedliche Architekturen und die darauf ausgeführten Programme konnten den Hauptspeicher nicht für die Kommunikation untereinander verwenden.
Die meisten heute verkauften Android-Geräte basieren auf SMP-Designs, was die Sache für Softwareentwickler etwas komplizierter macht. Race-Bedingungen in einem Multithread-Programm verursachen auf einem Uniprozessor möglicherweise keine sichtbaren Probleme, können aber regelmäßig fehlschlagen, wenn zwei oder mehr Threads gleichzeitig auf verschiedenen Kernen ausgeführt werden. Außerdem kann Code mehr oder weniger anfällig für Fehler sein, wenn er auf verschiedenen Prozessorarchitekturen oder sogar auf verschiedenen Implementierungen derselben Architektur ausgeführt wird. Code, der auf x86 gründlich getestet wurde, kann auf ARM zu schwerwiegenden Fehlern führen. Der Code kann fehlschlagen, wenn er mit einem moderneren Compiler neu kompiliert wird.
Im Rest dieses Dokuments wird erläutert, warum das so ist und was Sie tun müssen, damit Ihr Code korrekt funktioniert.
Speicherkonsistenzmodelle: Warum SMPs etwas anders sind
Dies ist ein schneller, oberflächlicher Überblick über ein komplexes Thema. Einige Bereiche werden unvollständig sein, aber nichts davon sollte irreführend oder falsch sein. Wie Sie im nächsten Abschnitt sehen werden, sind die Details hier normalerweise nicht wichtig.
Unter Weiterführende Informationen am Ende des Dokuments finden Sie Hinweise auf ausführlichere Informationen zu diesem Thema.
Speicherkonsistenzmodelle, oft auch nur „Speichermodelle“ genannt, beschreiben die Garantien, die die Programmiersprache oder Hardwarearchitektur für Speicherzugriffe bietet. Wenn Sie beispielsweise einen Wert in Adresse A und dann einen Wert in Adresse B schreiben, kann das Modell garantieren, dass jeder CPU-Kern diese Schreibvorgänge in dieser Reihenfolge sieht.
Das Modell, an das die meisten Programmierer gewöhnt sind, ist die sequenzielle Konsistenz, die so beschrieben wird (Adve & Gharachorloo):
- Alle Speichervorgänge werden nacheinander ausgeführt.
- Alle Vorgänge in einem einzelnen Thread werden anscheinend in der Reihenfolge ausgeführt, die im Programm dieses Prozessors beschrieben ist.
Nehmen wir vorübergehend an, dass wir einen sehr einfachen Compiler oder Interpreter haben, der keine Überraschungen mit sich bringt: Er übersetzt Zuweisungen im Quellcode in Lade- und Speicheranweisungen in genau der entsprechenden Reihenfolge, eine Anweisung pro Zugriff. Der Einfachheit halber gehen wir außerdem davon aus, dass jeder Thread auf einem eigenen Prozessor ausgeführt wird.
Wenn Sie sich einen Code ansehen und feststellen, dass er einige Lese- und Schreibvorgänge im Speicher ausführt, wissen Sie, dass der Code diese Lese- und Schreibvorgänge auf einer sequenziell konsistenten CPU-Architektur in der erwarteten Reihenfolge ausführt. Es ist möglich, dass die CPU tatsächlich Anweisungen neu anordnet und Lese- und Schreibvorgänge verzögert. Es gibt jedoch keine Möglichkeit für Code, der auf dem Gerät ausgeführt wird, festzustellen, dass die CPU etwas anderes tut, als Anweisungen auf einfache Weise auszuführen. (Wir ignorieren die E/A von speicherzugeordneten Gerätetreibern.)
Zur Veranschaulichung dieser Punkte ist es hilfreich, kleine Code-Snippets zu betrachten, die häufig als Lackmustests bezeichnet werden.
Hier ist ein einfaches Beispiel mit Code, der in zwei Threads ausgeführt wird:
| Thread 1 | Thread 2 |
|---|---|
A = 3 |
reg0 = B |
In diesem und allen zukünftigen Litmus-Beispielen werden Speicherorte durch Großbuchstaben (A, B, C) dargestellt und CPU-Register beginnen mit „reg“. Der gesamte Speicher ist anfangs null. Die Anweisungen werden von oben nach unten ausgeführt. Hier speichert Thread 1 den Wert 3 an Speicherort A und dann den Wert 5 an Speicherort B. Thread 2 lädt den Wert von Speicherort B in reg0 und dann den Wert von Speicherort A in reg1. (Beachten Sie, dass wir in einer Reihenfolge schreiben und in einer anderen lesen.)
Es wird davon ausgegangen, dass Thread 1 und Thread 2 auf verschiedenen CPU-Kernen ausgeführt werden. Sie sollten immer davon ausgehen, wenn Sie über Multithread-Code nachdenken.
Die sequenzielle Konsistenz garantiert, dass sich die Register nach der Ausführung beider Threads in einem der folgenden Zustände befinden:
| Anmelden | Bundesstaaten |
|---|---|
| reg0=5, reg1=3 | möglich (Thread 1 wurde zuerst ausgeführt) |
| reg0=0, reg1=0 | möglich (Thread 2 wurde zuerst ausgeführt) |
| reg0=0, reg1=3 | möglich (gleichzeitige Ausführung) |
| reg0=5, reg1=0 | nie |
Damit wir B=5 sehen, bevor wir den Speicher zu A sehen, müssten entweder die Lesevorgänge oder die Schreibvorgänge in der falschen Reihenfolge erfolgen. Auf einer sequenziell konsistenten Maschine kann das nicht passieren.
Uniprozessoren, einschließlich x86 und ARM, sind normalerweise sequenziell konsistent. Threads werden scheinbar verschachtelt ausgeführt, da der Betriebssystemkernel zwischen ihnen wechselt. Die meisten SMP-Systeme, einschließlich x86 und ARM, sind nicht sequenziell konsistent. Beispielsweise ist es üblich, dass die Hardware Speicherungen auf dem Weg zum Arbeitsspeicher zwischenspeichert, sodass sie nicht sofort den Arbeitsspeicher erreichen und für andere Kerne sichtbar werden.
Die Details variieren erheblich. Beispielsweise garantiert x86, obwohl nicht sequenziell konsistent, dass reg0 = 5 und reg1 = 0 unmöglich bleiben. Speichervorgänge werden gepuffert, ihre Reihenfolge bleibt jedoch erhalten. ARM hingegen nicht. Die Reihenfolge der gepufferten Speicher wird nicht beibehalten und Speicher erreichen möglicherweise nicht alle anderen Kerne gleichzeitig. Diese Unterschiede sind für Assembler-Programmierer wichtig. Wie wir weiter unten sehen werden, können und sollten C-, C++- oder Java-Programmierer jedoch so programmieren, dass solche Architekturunterschiede verborgen bleiben.
Bisher sind wir unrealistischerweise davon ausgegangen, dass nur die Hardware die Anweisungen neu anordnet. In der Realität ordnet der Compiler auch Anweisungen neu an, um die Leistung zu verbessern. In unserem Beispiel könnte der Compiler entscheiden, dass ein späterer Code in Thread 2 den Wert von reg1 vor reg0 benötigt, und daher reg1 zuerst laden. Oder ein vorheriger Code hat A bereits geladen und der Compiler entscheidet sich, diesen Wert wiederzuverwenden, anstatt A noch einmal zu laden. In beiden Fällen können die Lasten für reg0 und reg1 neu angeordnet werden.
Das Neuanordnen von Zugriffen auf verschiedene Speicherorte, entweder in der Hardware oder im Compiler, ist zulässig, da es die Ausführung eines einzelnen Threads nicht beeinträchtigt und die Leistung erheblich verbessern kann. Wie wir sehen werden, können wir mit ein wenig Sorgfalt auch verhindern, dass die Ergebnisse von Multithread-Programmen beeinträchtigt werden.
Da Compiler auch Speicherzugriffe neu anordnen können, ist dieses Problem für SMPs eigentlich nicht neu. Selbst auf einem Uniprozessor könnte ein Compiler die Ladevorgänge in reg0 und reg1 in unserem Beispiel neu anordnen und Thread 1 könnte zwischen den neu angeordneten Anweisungen geplant werden. Wenn unser Compiler die Reihenfolge nicht geändert hätte, wäre dieses Problem möglicherweise nie aufgetreten. Auf den meisten ARM-SMPs wird die Neuanordnung wahrscheinlich auch ohne Compiler-Neuanordnung angezeigt, möglicherweise nach einer sehr großen Anzahl erfolgreicher Ausführungen. Sofern Sie nicht in Assemblersprache programmieren, ist es bei SMPs im Allgemeinen nur wahrscheinlicher, dass Sie Probleme sehen, die schon immer vorhanden waren.
Programmierung ohne Datenwettläufe
Glücklicherweise gibt es in der Regel eine einfache Möglichkeit, sich nicht mit diesen Details befassen zu müssen. Wenn Sie einige einfache Regeln befolgen, können Sie den gesamten vorherigen Abschnitt mit Ausnahme des Teils „Sequenzielle Konsistenz“ in der Regel ignorieren. Leider können die anderen Komplikationen sichtbar werden, wenn du versehentlich gegen diese Regeln verstößt.
Moderne Programmiersprachen fördern einen sogenannten „Data-Race-sicheren“ Programmierstil. Solange Sie versprechen, keine „Datenrennen“ einzuführen, und eine Handvoll Konstrukte vermeiden, die dem Compiler etwas anderes sagen, versprechen der Compiler und die Hardware, sequenziell konsistente Ergebnisse zu liefern. Das bedeutet nicht, dass sie die Neuanordnung von Speicherzugriffen vermeiden. Wenn Sie sich an die Regeln halten, können Sie nicht feststellen, dass Speicherzugriffe neu angeordnet werden. Das ist so, als würde man Ihnen sagen, dass Wurst ein köstliches und appetitliches Essen ist, solange Sie versprechen, die Wurstfabrik nicht zu besuchen. Datenrennen offenbaren die unschöne Wahrheit über die Neuanordnung von Speicher.
Was ist eine „Daten-Race-Condition“?
Eine Daten-Race-Condition tritt auf, wenn mindestens zwei Threads gleichzeitig auf dieselben gewöhnlichen Daten zugreifen und mindestens einer von ihnen diese ändert. Mit „gewöhnlichen Daten“ meinen wir etwas, das nicht speziell ein Synchronisierungsobjekt für die Thread-Kommunikation ist. Mutexes, Bedingungsvariablen, Java-Volatiles oder atomare C++-Objekte sind keine gewöhnlichen Daten und ihre Zugriffe dürfen sich überschneiden. Tatsächlich werden sie verwendet, um Datenrennen bei anderen Objekten zu verhindern.
Um festzustellen, ob zwei Threads gleichzeitig auf denselben Speicherort zugreifen, können wir die obige Diskussion zur Speicherneuanordnung ignorieren und von sequenzieller Konsistenz ausgehen. Das folgende Programm hat keine Race Condition, wenn A und B gewöhnliche boolesche Variablen sind, die anfangs „false“ sind:
| Thread 1 | Thread 2 |
|---|---|
if (A) B = true |
if (B) A = true |
Da Vorgänge nicht neu angeordnet werden, werden beide Bedingungen als „false“ ausgewertet und keine der Variablen wird jemals aktualisiert. Daher kann es keine Race Condition geben. Sie müssen sich keine Gedanken darüber machen, was passieren könnte, wenn der Ladevorgang von A und das Speichern in B in Thread 1 irgendwie neu angeordnet würden. Der Compiler darf Thread 1 nicht neu anordnen, indem er ihn als „B = true; if (!A) B = false“ umschreibt. Das wäre so, als würde man mitten am Tag in der Stadt Wurst herstellen.
Daten-Races sind offiziell für grundlegende integrierte Typen wie Ganzzahlen und Referenzen oder Verweise definiert. Das Zuweisen zu einem int, während es gleichzeitig in einem anderen Thread gelesen wird, ist eindeutig eine Race Condition. Sowohl die C++-Standardbibliothek als auch die Java Collections-Bibliotheken sind jedoch so geschrieben, dass Sie auch auf Bibliotheksebene über Race Conditions nachdenken können. Sie versprechen, keine Datenrennen einzuführen, es sei denn, es gibt gleichzeitige Zugriffe auf denselben Container, von denen mindestens einer ihn aktualisiert. Wenn ein set<T> in einem Thread aktualisiert und gleichzeitig in einem anderen gelesen wird, kann die Bibliothek eine Race Condition einführen. Dies kann informell als „Race Condition auf Bibliotheksebene“ bezeichnet werden.
Umgekehrt führt das Aktualisieren eines set<T> in einem Thread, während ein anderer in einem anderen Thread gelesen wird, nicht zu einer Race Condition, da die Bibliothek verspricht, in diesem Fall keine (Low-Level-)Race Condition einzuführen.
Normalerweise können gleichzeitige Zugriffe auf verschiedene Felder in einer Datenstruktur keine Race Condition verursachen. Es gibt jedoch eine wichtige Ausnahme von dieser Regel: Zusammenhängende Sequenzen von Bitfeldern in C oder C++ werden als ein einziger „Speicherort“ behandelt. Der Zugriff auf ein beliebiges Bitfeld in einer solchen Sequenz wird so behandelt, als würde auf alle Bitfelder zugegriffen, um festzustellen, ob eine Race Condition vorliegt. Dies spiegelt die Unfähigkeit gängiger Hardware wider, einzelne Bits zu aktualisieren, ohne auch benachbarte Bits zu lesen und neu zu schreiben. Java-Programmierer haben keine vergleichbaren Bedenken.
Daten-Races vermeiden
Moderne Programmiersprachen bieten eine Reihe von Synchronisierungsmechanismen, um Datenkonflikte zu vermeiden. Die grundlegendsten Tools sind:
- Sperren oder Mutexe
- Mutexes (C++11
std::mutexoderpthread_mutex_t) odersynchronized-Blöcke in Java können verwendet werden, um sicherzustellen, dass bestimmte Codeabschnitte nicht gleichzeitig mit anderen Codeabschnitten ausgeführt werden, die auf dieselben Daten zugreifen. Diese und ähnliche Einrichtungen werden im Folgenden allgemein als „Sperren“ bezeichnet. Wenn Sie vor dem Zugriff auf eine freigegebene Datenstruktur immer eine bestimmte Sperre erwerben und diese danach wieder freigeben, werden Datenrennen beim Zugriff auf die Datenstruktur verhindert. Außerdem wird sichergestellt, dass Aktualisierungen und Zugriffe atomar sind, d.h., dass keine andere Aktualisierung der Datenstruktur dazwischen ausgeführt werden kann. Dies ist zu Recht das mit Abstand am häufigsten verwendete Tool zur Vermeidung von Daten-Races. Die Verwendung von Java-synchronized-Blöcken oder C++-lock_guardoderunique_locksorgt dafür, dass Sperren im Falle einer Ausnahme ordnungsgemäß aufgehoben werden. - Flüchtige/atomare Variablen
- Java bietet
volatile-Felder, die gleichzeitigen Zugriff unterstützen, ohne dass es zu Datenkonflikten kommt. Seit 2011 unterstützen C und C++atomic-Variablen und -Felder mit ähnlicher Semantik. Diese sind in der Regel schwieriger zu verwenden als Sperren, da sie nur dafür sorgen, dass einzelne Zugriffe auf eine einzelne Variable atomar sind. (In C++ erstreckt sich dies normalerweise auf einfache Lese-Änderungs-Schreib-Vorgänge wie Inkremente. Java erfordert dafür spezielle Methodenaufrufe. Im Gegensatz zu Sperren könnenvolatile- oderatomic-Variablen nicht direkt verwendet werden, um zu verhindern, dass andere Threads längere Codesequenzen beeinträchtigen.
volatile hat in C++ und Java sehr unterschiedliche Bedeutungen. In C++ verhindert volatile keine Daten-Races, obwohl älterer Code es oft als Behelfslösung für das Fehlen von atomic-Objekten verwendet. 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 können verwendet werden, um Datenkonflikte bei anderen Variablen zu verhindern. Wenn flag als Typ atomic<bool> oder atomic_bool (C/C++) oder volatile boolean (Java) deklariert ist und anfänglich „false“ ist, ist das folgende Snippet frei von Daten-Rennen:
| Thread 1 | Thread 2 |
|---|---|
A = ...
|
while (!flag) {}
|
Da Thread 2 darauf wartet, dass flag festgelegt wird, muss der Zugriff auf A in Thread 2 nach der Zuweisung zu A in Thread 1 erfolgen und nicht gleichzeitig. Daher gibt es keine Race Condition für A. Die Race-Bedingung für flag zählt nicht als Daten-Race-Bedingung, da flüchtige/atomare Zugriffe keine „normalen Speicherzugriffe“ sind.
Die Implementierung muss die Neuanordnung des Arbeitsspeichers so weit verhindern oder verbergen, dass sich Code wie im vorherigen Litmustest wie erwartet verhält. Dadurch sind volatile/atomare Speicherzugriffe in der Regel wesentlich teurer als normale Zugriffe.
Obwohl das vorherige Beispiel frei von Datenrennen ist, bieten Sperren zusammen mit Object.wait() in Java oder Bedingungsvariablen in C/C++ in der Regel eine bessere Lösung, bei der nicht in einer Schleife gewartet werden muss, während der Akku entladen wird.
Wann die Neuanordnung von gemerkten Informationen sichtbar wird
Durch die Programmierung ohne Datenrennen müssen wir uns normalerweise nicht explizit mit Problemen bei der Neuanordnung des Speicherzugriffs befassen. Es gibt jedoch mehrere Fälle, in denen die Neuanordnung sichtbar wird:- Wenn Ihr Programm einen Fehler aufweist, der zu einer unbeabsichtigten Race Condition führt, können Compiler- und Hardwaretransformationen sichtbar werden und das Verhalten Ihres Programms kann überraschend sein. Wenn wir beispielsweise im vorherigen Beispiel vergessen,
flagals flüchtig zu deklarieren, sieht Thread 2 möglicherweise ein nicht initialisiertesA. Oder der Compiler entscheidet, dass sich das Flag während der Schleife von Thread 2 unmöglich ändern kann, und wandelt das Programm in Folgendes um:
Beim Debuggen kann es vorkommen, dass die Schleife trotz der Tatsache, dassThread 1 Thread 2 A = ...
flag = truereg0 = flag; while (!reg0) {}
... = Aflagwahr ist, unendlich weiterläuft. - C++ bietet Möglichkeiten, die sequenzielle Konsistenz explizit zu lockern, auch wenn es keine Race Conditions gibt. Atomare Vorgänge können explizite
memory_order_...-Argumente annehmen. Das Paketjava.util.concurrent.atomicbietet eine eingeschränktere Auswahl ähnlicher Funktionen, insbesonderelazySet(). Und Java-Programmierer verwenden gelegentlich absichtliche Datenrennen, um einen ähnlichen Effekt zu erzielen. Alle diese Optionen bieten Leistungsverbesserungen, sind aber mit einem hohen Programmieraufwand verbunden. Wir gehen unten nur kurz darauf ein. - Einige C- und C++-Codes sind in einem älteren Stil geschrieben, der nicht vollständig mit den aktuellen Sprachstandards übereinstimmt. Dabei werden
volatile-Variablen anstelle vonatomic-Variablen verwendet und die Speicherreihenfolge wird explizit durch das Einfügen sogenannter Fences oder Barrieren verhindert. Dazu sind explizite Überlegungen zur Neuanordnung von Zugriffen und ein Verständnis von Hardware-Speichermodellen erforderlich. Ein Programmierstil dieser Art wird im Linux-Kernel immer noch verwendet. Sie sollte nicht in neuen Android-Apps verwendet werden und wird hier auch nicht weiter erläutert.
Üben
Das Debuggen von Problemen mit der Speicherkonsistenz kann sehr schwierig sein. Wenn eine fehlende Sperre, atomic- oder volatile-Deklaration dazu führt, dass Code veraltete Daten liest, können Sie die Ursache möglicherweise nicht durch die Untersuchung von Speicherabbildern mit einem Debugger ermitteln. Wenn Sie eine Debugger-Abfrage ausführen können, haben die CPU-Kerne möglicherweise alle Zugriffe beobachtet und der Inhalt des Arbeitsspeichers und der CPU-Register wird in einem „unmöglichen“ Zustand angezeigt.
Was Sie in C nicht tun sollten
Hier finden Sie einige Beispiele für fehlerhaften Code und einfache Möglichkeiten, ihn zu korrigieren. Zuvor müssen wir jedoch die Verwendung einer grundlegenden Sprachfunktion besprechen.
C/C++ und „volatile“
volatile-Deklarationen in C und C++ sind ein sehr spezielles Tool.
Sie verhindern, dass der Compiler volatile-Zugriffe neu anordnet oder entfernt. Dies kann hilfreich sein, wenn Code auf Hardwareregister zugreift, wenn Speicher an mehr als einer Stelle zugeordnet ist oder in Verbindung mit setjmp. Aber C und C++ volatile sind im Gegensatz zu Java volatile nicht für die Thread-Kommunikation konzipiert.
In C und C++ können Zugriffe auf volatile-Daten mit Zugriffen auf nicht flüchtige Daten neu angeordnet werden. Es gibt keine Garantien für die Atomizität. Daher kann volatile nicht für die Weitergabe von Daten zwischen Threads in portablem Code verwendet werden, auch nicht auf einem Uniprozessor. C volatile verhindert in der Regel nicht, dass die Hardware die Zugriffsreihenfolge ändert. Daher ist es in Multithread-SMP-Umgebungen noch weniger nützlich. Aus diesem Grund unterstützen C11 und C++11 atomic-Objekte. Verwenden Sie stattdessen diese.
In vielen älteren C- und C++-Codes wird volatile immer noch für die Thread-Kommunikation missbraucht. Dies funktioniert oft korrekt für Daten, die in ein Maschinenregister passen, sofern sie entweder mit expliziten Fences oder in Fällen verwendet werden, in denen die Speicherreihenfolge nicht wichtig ist. Es ist jedoch nicht garantiert, dass er mit zukünftigen Compilern korrekt funktioniert.
Beispiele
In den meisten Fällen ist es besser, eine Sperre (z. B. pthread_mutex_t oder C++11 std::mutex) anstelle eines atomaren Vorgangs zu verwenden. Wir verwenden jedoch Letzteres, um zu veranschaulichen, wie sie in einer praktischen Situation 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 ist, dass wir eine Struktur zuweisen, ihre Felder initialisieren und sie ganz am Ende „veröffentlichen“, indem wir sie in einer globalen Variablen speichern. Ab diesem Zeitpunkt kann jeder andere Thread darauf zugreifen. Das ist aber in Ordnung, da er vollständig initialisiert ist, oder?
Das Problem ist, dass der Store für gGlobalThing vor der Initialisierung der Felder beobachtet werden kann, weil entweder der Compiler oder der Prozessor die Stores für gGlobalThing und thing->x neu angeordnet hat. Ein anderer Thread, der aus thing->x liest, könnte 5, 0 oder sogar nicht initialisierte Daten sehen.
Das Kernproblem ist hier eine Race Condition für gGlobalThing.
Wenn Thread 1 initGlobalThing() aufruft, während Thread 2 useGlobalThing() aufruft, kann gGlobalThing gelesen werden, während es geschrieben wird.
Dies kann behoben werden, indem gGlobalThing als atomar deklariert wird. In C++11:
atomic<MyThing*> gGlobalThing(NULL);
So wird sichergestellt, dass die Schreibvorgänge für andere Threads in der richtigen Reihenfolge sichtbar werden. Außerdem werden einige andere Fehlermodi verhindert, die sonst zulässig sind, aber auf echter Android-Hardware wahrscheinlich nicht auftreten. So wird beispielsweise sichergestellt, dass wir keinen gGlobalThing-Zeiger sehen können, der nur teilweise geschrieben wurde.
Was Sie in Java nicht tun sollten
Einige relevante Java-Sprachfunktionen haben wir noch nicht besprochen. Werfen wir also zuerst einen kurzen Blick darauf.
In Java ist es technisch nicht erforderlich, dass Code frei von Datenkonflikten ist. Und es gibt eine kleine Menge sehr sorgfältig geschriebenen Java-Codes, der bei Datenkonflikten korrekt funktioniert. Das Schreiben eines solchen Codes ist jedoch extrem schwierig und wird im Folgenden nur kurz erläutert. Erschwerend kommt hinzu, dass die Experten, die die Bedeutung dieses Codes festgelegt haben, die Spezifikation nicht mehr für richtig halten. (Die Spezifikation ist für Code ohne Daten-Race-Bedingungen geeignet.)
Wir halten uns vorerst an das Modell ohne Datenkonflikte, für das Java im Wesentlichen dieselben Garantien wie C und C++ bietet. Auch hier bietet die Sprache einige Primitive, die die sequenzielle Konsistenz explizit lockern, insbesondere die Aufrufe lazySet() und weakCompareAndSet() in java.util.concurrent.atomic.
Wie bei C und C++ ignorieren wir diese vorerst.
Die Java-Schlüsselwörter „synchronized“ und „volatile“
Das Schlüsselwort „synchronized“ stellt den integrierten Sperrmechanismus der Java-Sprache bereit. Jedem Objekt ist ein „Monitor“ zugeordnet, der verwendet werden kann, um einen sich gegenseitig ausschließenden Zugriff zu ermöglichen. Wenn zwei Threads versuchen, sich auf demselben Objekt zu „synchronisieren“, wartet einer von ihnen, bis der andere fertig ist.
Wie bereits erwähnt, ist volatile T von Java das Analogon zu atomic<T> von C++11. Gleichzeitige Zugriffe auf volatile-Felder sind zulässig und führen nicht zu Datenkonflikten.
Abgesehen von lazySet() und anderen sowie Daten-Races ist es die Aufgabe der Java-VM, dafür zu sorgen, dass das Ergebnis weiterhin sequenziell konsistent erscheint.
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 zuvor von Thread 1 vorgenommenen Schreibvorgänge. In Bezug auf den Speichereffekt ist das Schreiben in eine flüchtige Variable analog zu einer Monitorfreigabe und das Lesen aus einer flüchtigen Variable wie eine Monitorerfassung.
Es gibt einen bemerkenswerten Unterschied zu atomic in C++: Wenn wir volatile int x; in Java schreiben, ist x++ dasselbe wie x = x + 1. Es führt einen atomaren Ladevorgang aus, erhöht das Ergebnis und führt dann einen atomaren Speichervorgang aus. Im Gegensatz zu C++ ist die Inkrementierung als Ganzes nicht atomar.
Atomare Inkrementvorgänge werden stattdessen von java.util.concurrent.atomic bereitgestellt.
Beispiele
Hier ist eine einfache, falsche Implementierung eines monotonen Zählers: (Java-Theorie und -Praxis: Volatilität verwalten).
class Counter { private int mValue; public int get() { return mValue; } public void incr() { mValue++; } }
Angenommen, get() und incr() werden von mehreren Threads aufgerufen und wir möchten sicherstellen, dass jeder Thread die aktuelle Anzahl sieht, wenn get() aufgerufen wird. Das offensichtlichste Problem ist, dass mValue++ eigentlich drei Vorgänge sind:
reg = mValuereg = reg + 1mValue = reg
Wenn zwei Threads gleichzeitig in incr() ausgeführt werden, kann eine der Aktualisierungen verloren gehen. Damit die Erhöhung atomar ist, müssen wir incr() als „synchronized“ deklarieren.
Es ist jedoch immer noch fehlerhaft, insbesondere bei SMP. Es gibt immer noch eine Race Condition, da get() gleichzeitig mit incr() auf mValue zugreifen kann. Gemäß den Java-Regeln kann der Aufruf get() im Vergleich zu anderem Code neu angeordnet werden. Wenn wir beispielsweise zwei Zähler nacheinander lesen, können die Ergebnisse inkonsistent erscheinen, da die get()-Aufrufe entweder von der Hardware oder vom Compiler neu angeordnet wurden. Wir können das Problem beheben, indem wir get() als synchronisiert deklarieren. Mit dieser Änderung ist der Code offensichtlich korrekt.
Leider haben wir die Möglichkeit von Sperrkonflikten eingeführt, was die Leistung beeinträchtigen könnte. Anstatt get() als synchronisiert zu deklarieren, könnten wir mValue mit „volatile“ deklarieren. (Beachten Sie, dass incr() weiterhin synchronize verwenden muss, da mValue++ sonst kein einzelner atomarer Vorgang ist.)
Dadurch werden auch alle Race Conditions vermieden, sodass die sequenzielle Konsistenz erhalten bleibt.
incr() ist etwas langsamer, da sowohl der Aufwand für den Ein- und Ausgang des Monitors als auch der Aufwand für einen flüchtigen Speicher anfällt. get() ist jedoch schneller, sodass dies auch ohne Konflikte ein Gewinn ist, wenn die Anzahl der Lesevorgänge die Anzahl der Schreibvorgänge deutlich übersteigt. (Siehe auch AtomicInteger, um den synchronisierten Block vollständig zu entfernen.)
Hier ist ein weiteres Beispiel, das 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 .... } } }
Hier besteht dasselbe Problem wie beim C-Code, nämlich dass eine Race Condition für sGoodies vorliegt. Daher kann die Zuweisung sGoodies = goods vor der Initialisierung der Felder in goods beobachtet werden. Wenn Sie sGoodies mit dem Keyword volatile deklarieren, wird die sequenzielle Konsistenz wiederhergestellt und alles funktioniert wie erwartet.
Beachten Sie, dass nur der Verweis sGoodies selbst flüchtig ist. Die Zugriffe auf die darin enthaltenen Felder sind es nicht. Sobald sGoodies volatile ist und die Speicherreihenfolge korrekt beibehalten wird, kann nicht gleichzeitig auf die Felder zugegriffen werden. Die Anweisung z =
sGoodies.x führt einen flüchtigen Ladevorgang von MyClass.sGoodies aus, gefolgt von einem nicht flüchtigen Ladevorgang von sGoodies.x. Wenn Sie eine lokale Referenz MyGoodies localGoods = sGoodies erstellen, werden bei einem nachfolgenden z =
localGoods.x keine flüchtigen Ladevorgänge ausgeführt.
Ein gängigeres Idiom in der Java-Programmierung ist die berüchtigte „doppelt geprüfte Sperre“:
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 eines Helper-Objekts haben möchten, das mit einer Instanz von MyClass verknüpft ist. Wir müssen sie nur einmal erstellen. Daher erstellen wir sie und geben sie über eine dedizierte getHelper()-Funktion zurück. Um eine Race-Bedingung zu vermeiden, bei der zwei Threads die Instanz erstellen, müssen wir die Objekterstellung synchronisieren. Wir möchten jedoch nicht bei jedem Aufruf den Overhead für den „synchronized“-Block bezahlen. Daher führen wir diesen Teil nur aus, wenn helper derzeit null ist.
Hier gibt es eine Race Condition im Feld helper. Er kann gleichzeitig mit dem helper == null in einem anderen Thread festgelegt werden.
Um zu sehen, wie das fehlschlagen kann, betrachten Sie denselben Code, der leicht umgeschrieben wurde, als wäre er in eine C-ähnliche Sprache kompiliert worden. Ich habe ein paar Ganzzahlfelder hinzugefügt, um die Helper’s-Konstruktoraktivität darzustellen:
if (helper == null) { synchronized() { if (helper == null) { newHelper = malloc(sizeof(Helper)); newHelper->x = 5; newHelper->y = 10; helper = newHelper; } } return helper; }
Es gibt nichts, was die Hardware oder den Compiler daran hindert, den Speicher in helper mit den Speichern in den Feldern x/y neu anzuordnen. Ein anderer Thread könnte helper als nicht null finden, aber die Felder sind noch nicht festgelegt und einsatzbereit.
Weitere Informationen und weitere Fehlermodi finden Sie im Anhang unter dem Link „Double Checked Locking is Broken“ oder unter Punkt 71 („Use lazy initialization judiciously“) in Josh Blochs Effective Java, 2nd Edition.
Dafür gibt es zwei Möglichkeiten:
- Machen Sie es sich einfach und löschen Sie die äußere Prüfung. So wird sichergestellt, dass der Wert von
helpernie außerhalb eines synchronisierten Blocks geprüft wird. - Deklarieren Sie
helperals flüchtig. Mit dieser kleinen Änderung funktioniert der Code in Beispiel J-3 unter Java 1.5 und höher korrekt. (Nehmen Sie sich ruhig einen Moment Zeit, um sich davon zu überzeugen, dass das stimmt.)
Hier ist ein weiteres Beispiel für das Verhalten 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 } } }
Wenn Thread 2 in useValues() die Aktualisierung von vol1 noch nicht erkannt hat, kann er nicht wissen, ob data1 oder data2 bereits festgelegt wurde. Sobald das Update für vol1 erkannt wird, weiß es, dass auf data1 sicher zugegriffen und es korrekt gelesen werden kann, ohne dass eine Race Condition entsteht. Es kann jedoch keine Annahmen über data2 treffen, da dieser Speicher nach dem flüchtigen Speicher ausgeführt wurde.
Beachten Sie, dass volatile nicht verwendet werden kann, um die Neuanordnung anderer Speicherzugriffe zu verhindern, die miteinander konkurrieren. Es ist nicht garantiert, dass eine Maschinen-Memory-Fence-Anweisung generiert wird. Sie kann verwendet werden, um Datenkonflikte zu verhindern, indem Code nur ausgeführt wird, wenn ein anderer Thread eine bestimmte Bedingung erfüllt hat.
Was muss ich tun?
In C/C++ sollten Sie C++11-Synchronisierungsklassen wie std::mutex bevorzugen. Andernfalls verwenden Sie die entsprechenden pthread-Vorgänge.
Dazu gehören die richtigen Memory Fences, die auf allen Android-Plattformversionen ein korrektes (sequenziell konsistentes, sofern nicht anders angegeben) und effizientes Verhalten ermöglichen. Achten Sie darauf, sie richtig zu verwenden. Denken Sie beispielsweise daran, dass Wartezeiten für Bedingungsvariablen fälschlicherweise ohne Signal zurückgegeben werden können und daher in einer Schleife angezeigt werden sollten.
Es empfiehlt sich, atomare Funktionen nicht direkt zu verwenden, es sei denn, die von Ihnen implementierte Datenstruktur ist extrem einfach, z. B. ein Zähler. Das Sperren und Entsperren eines pthread-Mutex erfordert jeweils einen einzelnen atomaren Vorgang und kostet oft weniger als ein einzelner Cache-Fehler, wenn es keine Konflikte gibt. Sie sparen also nicht viel, wenn Sie Mutex-Aufrufe durch atomare Vorgänge ersetzen. Sperrenfreie Designs für nicht triviale Datenstrukturen erfordern viel mehr Sorgfalt, um sicherzustellen, dass Vorgänge auf höherer Ebene für die Datenstruktur atomar erscheinen (als Ganzes, nicht nur ihre explizit atomaren Teile).
Wenn Sie atomare Vorgänge verwenden, kann die Lockerung der Reihenfolge mit memory_order... oder lazySet() Leistungsvorteile bieten, erfordert jedoch ein tieferes Verständnis, als wir bisher vermittelt haben.
Ein großer Teil des vorhandenen Codes, der diese verwendet, weist im Nachhinein Fehler auf. Vermeiden Sie diese nach Möglichkeit.
Wenn Ihre Anwendungsfälle nicht genau zu einem der im nächsten Abschnitt beschriebenen passen, sollten Sie entweder ein Experte sein oder einen Experten konsultiert haben.
Vermeiden Sie die Verwendung von volatile für die Thread-Kommunikation in C/C++.
In Java lassen sich Nebenläufigkeitsprobleme oft am besten mit einer geeigneten Dienstprogrammklasse aus dem Paket java.util.concurrent lösen. Der Code ist gut geschrieben und auf SMP gut getestet.
Am sichersten ist es, wenn Sie Ihre Objekte unveränderlich machen. Objekte aus Klassen wie String und Integer von Java enthalten Daten, die nach dem Erstellen eines Objekts nicht mehr geändert werden können. Dadurch werden alle potenziellen Datenrennen für diese Objekte vermieden. Das Buch Effective Java, 2. Auflage enthält spezifische Anweisungen in „Item 15: Minimize Mutability“. Beachten Sie insbesondere die Bedeutung der Deklaration von Java-Feldern als „final“ (Bloch).
Auch wenn ein Objekt unveränderlich ist, ist die Kommunikation mit einem anderen Thread ohne jegliche Synchronisierung eine Race Condition. Dies kann in Java gelegentlich akzeptabel sein (siehe unten), erfordert jedoch große Sorgfalt und führt wahrscheinlich zu instabilem Code. Wenn es nicht extrem leistungskritisch ist, fügen Sie eine volatile-Deklaration hinzu. In C++ ist die Weitergabe eines Zeigers oder einer Referenz auf ein unveränderliches Objekt ohne ordnungsgemäße Synchronisierung, wie bei jeder Race Condition, ein Fehler.
In diesem Fall ist es wahrscheinlich, dass es zu zeitweiligen Abstürzen kommt, da der empfangende Thread beispielsweise aufgrund einer Neuanordnung der Speicher einen nicht initialisierten Methodentabellenzeiger sehen kann.
Wenn weder eine vorhandene Bibliotheksklasse noch eine unveränderliche Klasse geeignet ist, sollte die Java-Anweisung synchronized oder die C++-Anweisung lock_guard / unique_lock verwendet werden, um den Zugriff auf alle Felder zu schützen, auf die von mehr als einem Thread zugegriffen werden kann. Wenn Mutexe für Ihre Situation nicht geeignet sind, sollten Sie freigegebene Felder als volatile oder atomic deklarieren. Sie müssen jedoch die Interaktionen zwischen Threads genau verstehen. Diese Deklarationen schützen Sie nicht vor häufigen Fehlern bei der gleichzeitigen Programmierung, helfen Ihnen aber, die mysteriösen Fehler zu vermeiden, die mit der Optimierung von Compilern und SMP-Fehlern verbunden sind.
Sie sollten es vermeiden, im Konstruktor eines Objekts einen Verweis auf das Objekt zu „veröffentlichen“, d.h. für andere Threads verfügbar zu machen. In C++ ist das weniger kritisch, oder wenn Sie sich in Java an unseren Rat „Keine Daten-Races“ halten. Es ist jedoch immer ein guter Rat 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 den Zugriff auf diese „durchgesickerte“ Objektreferenz eine Race Condition verursachen kann. Sie ist auch wichtig, wenn Sie unsere Warnungen ignorieren und einige der im nächsten Abschnitt beschriebenen Techniken verwenden. Weitere Informationen finden Sie unter (Sichere Konstruktionstechniken in Java).
Weitere Informationen zu schwachen Speicherreihenfolgen
C++11 und höher bieten explizite Mechanismen, um die Garantien für die sequenzielle Konsistenz für Programme ohne Datenkonflikte zu lockern. Explizite memory_order_relaxed-, memory_order_acquire- (nur Ladevorgänge) und memory_order_release-Argumente (nur Speichervorgänge) für atomare Vorgänge bieten jeweils schwächere Garantien als das standardmäßige, normalerweise implizite memory_order_seq_cst. memory_order_acq_rel bietet sowohl memory_order_acquire- als auch memory_order_release-Garantien für atomare Lese-Änderungs-Schreibvorgänge. 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. Die normalen Variablen von Java werden manchmal als Ersatz für memory_order_relaxed-Zugriffe verwendet, obwohl sie eigentlich noch schwächer sind. Im Gegensatz zu C++ gibt es keinen wirklichen Mechanismus für ungeordnete Zugriffe auf Variablen, die als volatile deklariert sind.
Sie sollten diese im Allgemeinen vermeiden, es sei denn, es gibt dringende Leistungsgründe für ihre Verwendung. Auf schwach geordneten Maschinenarchitekturen wie ARM spart die Verwendung dieser Funktionen in der Regel einige Dutzend Maschinenzyklen pro atomarer Operation. Auf x86 ist der Leistungsgewinn auf Speicher beschränkt und wahrscheinlich weniger spürbar. Etwas überraschend ist, dass der Vorteil bei einer größeren Anzahl von Kernen abnehmen kann, da das Speichersystem dann eher zu einem einschränkenden Faktor wird.
Die vollständige Semantik von schwach geordneten atomaren Operationen ist kompliziert. Im Allgemeinen erfordern sie ein genaues Verständnis der Sprachregeln, auf die wir hier nicht näher eingehen werden. Beispiel:
- Der Compiler oder die Hardware kann
memory_order_relaxed-Zugriffe in einen kritischen Abschnitt verschieben, der durch das Erwerben und Freigeben einer Sperre begrenzt ist, aber nicht aus diesem Abschnitt heraus. Das bedeutet, dass zweimemory_order_relaxed-Speichervorgänge möglicherweise in der falschen Reihenfolge sichtbar werden, auch wenn sie durch einen kritischen Abschnitt getrennt sind. - Eine gewöhnliche Java-Variable, die als gemeinsam genutzter Zähler missbraucht wird, kann für einen anderen Thread so aussehen, als würde sie abnehmen, obwohl sie nur von einem einzigen anderen Thread erhöht wird. Dies gilt jedoch nicht für C++-Atom-
memory_order_relaxed.
Hier sind einige Idiome, die viele der Anwendungsfälle für schwach geordnete Atomics abdecken. Viele davon gelten nur für C++.
Nicht-Rennzugriffe
Es kommt recht häufig vor, dass eine Variable atomar ist, weil sie manchmal gleichzeitig mit einem Schreibvorgang gelesen wird. Dieses Problem tritt jedoch nicht bei allen Zugriffen auf.
Beispielsweise muss eine Variable möglicherweise atomar sein, weil sie außerhalb eines kritischen Abschnitts gelesen wird, aber alle Aktualisierungen durch eine Sperre geschützt sind. In diesem Fall kann ein Lesevorgang, der durch dieselbe Sperre geschützt ist, nicht zu einer Race-Bedingung führen, da keine gleichzeitigen Schreibvorgänge möglich sind. In einem solchen Fall kann der Zugriff ohne Race-Bedingung (in diesem Fall die Last) mit memory_order_relaxed annotiert werden, ohne die Korrektheit des C++-Codes zu ändern.
Die Sperrimplementierung erzwingt bereits die erforderliche Speicherreihenfolge für den Zugriff durch andere Threads und memory_order_relaxed gibt an, dass im Wesentlichen keine zusätzlichen Einschränkungen für die Reihenfolge des atomaren Zugriffs erzwungen werden müssen.
In Java gibt es dazu kein echtes Äquivalent.
Das Ergebnis wird nicht auf Richtigkeit geprüft.
Wenn wir eine Racing-Last nur zum Generieren eines Hinweises verwenden, ist es im Allgemeinen auch in Ordnung, keine Speicherreihenfolge für die Last zu erzwingen. Wenn der Wert nicht zuverlässig ist, können wir das Ergebnis auch nicht zuverlässig verwenden, um Rückschlüsse auf andere Variablen zu ziehen. Daher ist es in Ordnung, wenn die Speicherreihenfolge nicht garantiert ist und die Last mit einem memory_order_relaxed-Argument bereitgestellt wird.
Ein gängiges Beispiel hierfür ist die Verwendung von C++-compare_exchange, um x atomar durch f(x) zu ersetzen.
Die anfängliche Last von x zur Berechnung von f(x) muss nicht zuverlässig sein. Wenn wir falsch liegen, schlägt die compare_exchange fehl und wir versuchen es noch einmal.
Für das erste Laden von x kann ein memory_order_relaxed-Argument verwendet werden. Nur die Speicherreihenfolge für das eigentliche compare_exchange ist wichtig.
Atomar geänderte, aber ungelesene Daten
Gelegentlich werden Daten von mehreren Threads parallel geändert, aber erst nach Abschluss der parallelen Berechnung geprüft. Ein gutes Beispiel hierfür ist ein Zähler, der von mehreren Threads parallel atomar erhöht wird (z.B. mit fetch_add() in C++ oder atomic_fetch_add_explicit() in C), wobei das Ergebnis dieser Aufrufe immer ignoriert wird. Der resultierende Wert wird erst am Ende gelesen, nachdem alle Aktualisierungen abgeschlossen sind.
In diesem Fall lässt sich nicht feststellen, ob Zugriffe auf diese Daten neu angeordnet wurden. Daher kann im C++-Code ein memory_order_relaxed-Argument verwendet werden.
Einfache Ereigniszähler sind ein gängiges Beispiel dafür. Da er so häufig vorkommt, lohnt es sich, einige Beobachtungen zu diesem Fall anzustellen:
- Die Verwendung von
memory_order_relaxedverbessert die Leistung, behebt aber möglicherweise nicht das wichtigste Leistungsproblem: Jede Aktualisierung erfordert exklusiven Zugriff auf die Cache-Zeile, die den Zähler enthält. Dies führt jedes Mal zu einem Cache-Fehler, wenn ein neuer Thread auf den Zähler zugreift. Wenn Aktualisierungen häufig erfolgen und zwischen Threads wechseln, ist es viel schneller, den freigegebenen Zähler nicht jedes Mal zu aktualisieren, sondern beispielsweise Thread-lokale Zähler zu verwenden und sie am Ende zu summieren. - Diese Technik kann mit dem vorherigen Abschnitt kombiniert werden: Es ist möglich, gleichzeitig ungefähre und unzuverlässige Werte zu lesen, während sie aktualisiert werden, wobei alle Vorgänge
memory_order_relaxedverwenden. Es ist jedoch wichtig, die resultierenden Werte als völlig unzuverlässig zu betrachten. Nur weil die Anzahl einmal erhöht wurde, bedeutet das nicht, dass ein anderer Thread den Punkt erreicht hat, an dem die Erhöhung durchgeführt wurde. Stattdessen wurde das Inkrement möglicherweise mit früherem Code neu angeordnet. (Wie im ähnlichen Fall, den wir zuvor erwähnt haben, garantiert C++, dass ein zweites Laden eines solchen Zählers keinen Wert zurückgibt, der kleiner ist als ein früheres Laden im selben Thread. Es sei denn, der Zähler ist übergelaufen. - Es ist üblich, Code zu finden, der versucht, ungefähre Zählerwerte zu berechnen, indem er einzelne atomare (oder nicht atomare) Lese- und Schreibvorgänge ausführt, aber die Inkrementierung nicht als Ganzes atomar macht. Das übliche Argument ist, dass dies für Leistungszähler oder Ähnliches „nah genug“ ist. Normalerweise nicht. Wenn Aktualisierungen häufig genug erfolgen (ein Fall, der für Sie wahrscheinlich relevant ist), geht in der Regel ein großer Teil der Zählungen verloren. Auf einem Quad-Core-Gerät kann mehr als die Hälfte der Zählungen verloren gehen. (Einfache Übung: Erstellen Sie ein Szenario mit zwei Threads, in dem der Zähler eine Million Mal aktualisiert wird, der endgültige Zählerwert aber eins ist.)
Einfache Kommunikation per Flag
Ein memory_order_release-Speichervorgang (oder ein Read-Modify-Write-Vorgang) sorgt dafür, dass ein nachfolgender memory_order_acquire-Ladevorgang (oder ein Read-Modify-Write-Vorgang), der den geschriebenen Wert liest, auch alle Speichervorgänge (gewöhnliche oder atomare) berücksichtigt, die vor dem memory_order_release-Speichervorgang stattgefunden haben. Umgekehrt werden bei allen Ladevorgängen vor dem memory_order_release keine Speicherungen berücksichtigt, die nach dem memory_order_acquire-Ladevorgang erfolgten.
Im Gegensatz zu memory_order_relaxed können solche atomaren Vorgänge verwendet werden, um den Fortschritt eines Threads an einen anderen zu kommunizieren.
Beispiel: Wir können das obige Beispiel für die doppelt geprüfte Sperre in C++ so umschreiben:
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 Acquire-Load und der Release-Store sorgen dafür, dass wir bei einem helper-Wert, der nicht null ist, auch die Felder korrekt initialisiert sehen.
Wir haben auch die vorherige Beobachtung berücksichtigt, dass Nicht-Racing-Lasten memory_order_relaxed verwenden können.
Ein Java-Programmierer könnte helper als java.util.concurrent.atomic.AtomicReference<Helper> darstellen und lazySet() als Release-Speicher verwenden. Für die Ladevorgänge werden weiterhin einfache get()-Aufrufe verwendet.
In beiden Fällen konzentrierte sich unsere Leistungsoptimierung auf den Initialisierungspfad, der wahrscheinlich nicht leistungskritisch ist. Ein besser lesbarer Kompromiss könnte so 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 bietet denselben schnellen Pfad, greift aber auf standardmäßige, sequenziell konsistente Vorgänge auf dem nicht leistungskritischen langsamen Pfad zurück.
Selbst hier generiert helper.load(memory_order_acquire) wahrscheinlich denselben Code auf aktuellen von Android unterstützten Architekturen wie ein einfacher (sequenziell konsistenter) Verweis auf helper. Die vorteilhafteste Optimierung hier ist wahrscheinlich die Einführung von myHelper, um einen zweiten Ladevorgang zu vermeiden, obwohl ein zukünftiger Compiler dies möglicherweise automatisch tut.
Die Acquire/Release-Reihenfolge verhindert nicht, dass Speicherungen sichtbar verzögert werden, und sorgt nicht dafür, dass Speicherungen für andere Threads in einer konsistenten Reihenfolge sichtbar werden. Daher wird ein kniffliges, aber recht gängiges Codierungsmuster nicht unterstützt, das durch den Dekker-Algorithmus für den gegenseitigen Ausschluss veranschaulicht wird: Alle Threads setzen zuerst ein Flag, das angibt, dass sie etwas tun möchten. Wenn ein Thread t dann feststellt, dass kein anderer Thread versucht, etwas zu tun, kann er sicher fortfahren, da es keine Interferenzen gibt. Kein anderer Thread kann fortgesetzt werden, da das Flag von t noch gesetzt ist. Dies schlägt fehl, wenn auf das Flag mit der Reihenfolge „Acquire/Release“ zugegriffen wird, da dies nicht verhindert, dass das Flag eines Threads für andere zu spät sichtbar wird, nachdem sie fälschlicherweise fortgefahren sind. Der Standardwert memory_order_seq_cst verhindert dies.
Unveränderliche Felder
Wenn ein Objektfeld bei der ersten Verwendung initialisiert und dann nie geändert wird, kann es möglicherweise mit schwach geordneten Zugriffen initialisiert und anschließend gelesen werden. In C++ könnte sie als atomic deklariert und mit memory_order_relaxed aufgerufen werden. In Java könnte sie ohne volatile deklariert und ohne besondere Maßnahmen aufgerufen werden. Dazu müssen alle folgenden Bedingungen erfüllt sein:
- Am Wert des Felds selbst sollte erkennbar sein, ob es bereits initialisiert wurde. Für den Zugriff auf das Feld sollte der Test-and-Return-Wert des schnellen Pfads das Feld nur einmal lesen. In Java ist Letzteres unerlässlich. Auch wenn das Feld als initialisiert erkannt wird, kann ein zweiter Ladevorgang den früheren nicht initialisierten Wert lesen. In C++ ist die „Einmal lesen“-Regel lediglich eine bewährte Methode.
- Sowohl die Initialisierung als auch nachfolgende Ladevorgänge müssen atomar sein, d. h. Teilaktualisierungen dürfen nicht sichtbar sein. In Java darf das Feld kein
longoderdoublesein. Für C++ ist eine atomare Zuweisung erforderlich. Die Erstellung an Ort und Stelle funktioniert nicht, da die Erstellung einesatomicnicht atomar ist. - Wiederholte Initialisierungen müssen sicher sein, da mehrere Threads den nicht initialisierten Wert gleichzeitig lesen können. In C++ ergibt sich dies im Allgemeinen aus der Anforderung „trivially copyable“ (trivial kopierbar), die für alle atomaren Typen gilt. Typen mit verschachtelten eigenen Zeigern würden eine Freigabe im Kopierkonstruktor erfordern und wären nicht trivial kopierbar. Für Java sind bestimmte Referenztypen zulässig:
- Java-Referenzen sind auf unveränderliche Typen beschränkt, die nur endgültige Felder enthalten. Der Konstruktor des unveränderlichen Typs sollte keinen Verweis auf das Objekt veröffentlichen. In diesem Fall sorgen die Regeln für finale Java-Felder dafür, dass ein Leser, der die Referenz sieht, auch die initialisierten finalen Felder sieht. In C++ gibt es keine Entsprechung zu diesen Regeln. Zeiger auf eigene Objekte sind aus diesem Grund ebenfalls nicht zulässig (zusätzlich zu einem Verstoß gegen die Anforderungen für „trivially copyable“).
Schlussbemerkungen
Dieses Dokument geht zwar über die Oberfläche hinaus, aber nicht sehr tief. Das ist ein sehr breites und tiefes Thema. Hier sind einige Bereiche, die weiter untersucht werden könnten:
- Die tatsächlichen Java- und C++-Speichermodelle werden in Form einer Happens-before-Beziehung ausgedrückt, die angibt, wann zwei Aktionen garantiert in einer bestimmten Reihenfolge auftreten. Als wir eine Race Condition definiert haben, haben wir informell von zwei „gleichzeitig“ stattfindenden Speicherzugriffen gesprochen.
Offiziell ist dies so definiert, dass keines der beiden Ereignisse vor dem anderen eintritt.
Es ist hilfreich, die tatsächlichen Definitionen von happens-before und synchronizes-with im Java- oder C++-Speichermodell zu kennen.
Obwohl die intuitive Vorstellung von „gleichzeitig“ im Allgemeinen ausreicht, sind diese Definitionen aufschlussreich, insbesondere wenn Sie die Verwendung von schwach geordneten atomaren Vorgängen in C++ in Betracht ziehen. (In der aktuellen Java-Spezifikation wird
lazySet()nur sehr informell definiert.) - Erfahren Sie, was Compiler beim Neuanordnen von Code tun dürfen und was nicht. (Die JSR-133-Spezifikation enthält einige gute Beispiele für zulässige Transformationen, die zu unerwarteten Ergebnissen führen.)
- Finden Sie heraus, wie Sie unveränderliche Klassen in Java und C++ schreiben. (Es gehört mehr dazu als nur „nach der Erstellung nichts mehr ändern“.)
- Verinnerlichen Sie die Empfehlungen im Abschnitt „Concurrency“ von Effective Java, 2nd Edition. (Sie sollten beispielsweise vermeiden, Methoden aufzurufen, die in einem synchronisierten Block überschrieben werden sollen.)
- Lesen Sie die Informationen zu den APIs
java.util.concurrentundjava.util.concurrent.atomic, um zu sehen, was verfügbar ist. Ziehen Sie die Verwendung von Nebenläufigkeitsannotationen wie@ThreadSafeund@GuardedBy(aus net.jcip.annotations) in Betracht.
Der Abschnitt Weiterführende Literatur im Anhang enthält Links zu Dokumenten und Websites, die diese Themen näher erläutern.
Anhang
Synchronisierungsspeicher implementieren
(Die meisten Programmierer werden das nicht selbst implementieren, aber die Diskussion ist aufschlussreich.)
Bei kleinen integrierten Typen wie int und von Android unterstützter Hardware sorgen gewöhnliche 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 „Atomizität“ kostenlos bereitgestellt.
Wie wir gesehen haben, reicht das nicht aus. Um eine sequenzielle Konsistenz zu gewährleisten, müssen wir auch verhindern, dass Vorgänge neu angeordnet werden, und dafür sorgen, dass Speichervorgänge für andere Prozesse in einer konsistenten Reihenfolge sichtbar werden. Letzteres geschieht automatisch auf von Android unterstützter Hardware, sofern wir die Ersteren sinnvoll erzwingen. Daher ignorieren wir es hier weitgehend.
Die Reihenfolge der Speichervorgänge wird beibehalten, indem sowohl die Neuanordnung durch den Compiler als auch die Neuanordnung durch die Hardware verhindert wird. Hier konzentrieren wir uns auf Letzteres.
Die Speicherreihenfolge auf ARMv7, x86 und MIPS wird mit „Fence“-Anweisungen erzwungen, die grob verhindern, dass Anweisungen nach dem Fence vor Anweisungen vor dem Fence sichtbar werden. (Diese werden auch häufig als „Barrier“-Anweisungen bezeichnet, was jedoch zu Verwechslungen mit pthread_barrier-Barrieren führen kann, die viel mehr leisten.) Die genaue Bedeutung von Fence-Anweisungen ist ein ziemlich kompliziertes Thema, bei dem es darum geht, wie die von mehreren verschiedenen Arten von Fences bereitgestellten Garantien interagieren und wie diese mit anderen Garantien für die Reihenfolge kombiniert werden, die normalerweise von der Hardware bereitgestellt werden. Dies ist eine allgemeine Übersicht, daher werden wir diese Details überspringen.
Die grundlegendste Art der Reihenfolgegarantie ist die, die von atomaren C++-Vorgängen vom Typ memory_order_acquire und memory_order_release bereitgestellt wird: Speichervorgänge, die einem Release-Store vorangehen, sollten nach einem Acquire-Load sichtbar sein. Auf ARMv7 wird dies durch Folgendes erzwungen:
- Der Speicheranweisung eine geeignete Fence-Anweisung voranstellen. Dadurch wird verhindert, dass alle vorherigen Speicherzugriffe mit der Speicheranweisung neu angeordnet werden. (Außerdem wird dadurch unnötigerweise verhindert, dass die Reihenfolge mit einer späteren Speicheranweisung geändert wird.)
- Auf die Ladeanweisung folgt eine geeignete Fence-Anweisung, die verhindert, dass die Ladeanweisung bei nachfolgenden Zugriffen neu angeordnet wird. (Und wieder wird eine unnötige Reihenfolge mit zumindest früheren Ladevorgängen bereitgestellt.)
Zusammen reichen diese für die C++-Acquire/Release-Reihenfolge aus.
Sie sind notwendig, aber nicht ausreichend für sequenziell konsistente atomic in Java volatile oder C++.
Um zu sehen, was wir sonst noch benötigen, betrachten wir das Fragment des Dekker-Algorithmus, das wir zuvor kurz erwähnt haben.
flag1 und flag2 sind C++-atomic- oder Java-volatile-Variablen, die beide anfangs „false“ sind.
| Thread 1 | Thread 2 |
|---|---|
flag1 = true |
flag2 = true |
Sequenzielle Konsistenz bedeutet, dass eine der Zuweisungen zu flagn zuerst ausgeführt und vom Test im anderen Thread gesehen werden muss. Daher werden diese Threads niemals gleichzeitig „critical-stuff“ ausführen.
Die für die Acquire-Release-Reihenfolge erforderlichen Fences fügen jedoch nur Fences am Anfang und Ende jedes Threads hinzu, was hier nicht hilft. Außerdem müssen wir dafür sorgen, dass ein volatile/atomic-Speichervorgang, auf den ein volatile/atomic-Ladevorgang folgt, nicht neu angeordnet wird.
Dies wird normalerweise erzwungen, indem ein Fence nicht nur vor einem sequenziell konsistenten Store, sondern auch danach hinzugefügt wird.
(Dies ist wiederum viel stärker als erforderlich, da dieser Fence normalerweise alle früheren Speicherzugriffe im Verhältnis zu allen späteren anordnet.)
Stattdessen könnten wir den zusätzlichen Fence mit sequenziell konsistenten Ladevorgängen verknüpfen. Da Speicher seltener vorkommen, ist die von uns beschriebene Konvention gängiger und wird 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:
| volatile Last | flüchtiger Speicher |
|---|---|
reg = A |
fence for "release" (2) |
Reale Maschinenarchitekturen bieten in der Regel mehrere Arten von Fences, die verschiedene Arten von Zugriffen ordnen und unterschiedliche Kosten verursachen können. Die Wahl zwischen diesen ist subtil und wird von der Notwendigkeit beeinflusst, dafür zu sorgen, dass Speicher für andere Kerne in einer konsistenten Reihenfolge sichtbar gemacht werden und dass die Speicherreihenfolge, die durch die Kombination mehrerer Fences vorgegeben wird, korrekt zusammengesetzt wird. Weitere Informationen finden Sie auf der Seite der University of Cambridge mit gesammelten Zuordnungen von Atomics zu tatsächlichen Prozessoren.
Bei einigen Architekturen, insbesondere x86, sind die „Acquire“- und „Release“-Barrieren nicht erforderlich, da die Hardware immer implizit eine ausreichende Reihenfolge erzwingt. Daher wird auf x86 nur der letzte Fence (3) tatsächlich generiert. Ähnlich verhält es sich bei x86, wo atomare Read-Modify-Write-Vorgänge implizit einen starken Fence enthalten. Daher sind hier keine Zäune erforderlich. Auf ARMv7 sind alle oben beschriebenen Fences erforderlich.
ARMv8 bietet LDAR- und STLR-Anweisungen, die die Anforderungen von Java-flüchtigen oder C++-sequenziell konsistenten Lade- und Speichervorgängen direkt erzwingen. So werden die oben erwähnten unnötigen Einschränkungen bei der Neuanordnung vermieden. 64-Bit-Android-Code auf ARM verwendet diese. Wir haben uns hier auf die Platzierung von ARMv7-Fences konzentriert, da dies die tatsächlichen Anforderungen besser verdeutlicht.
Weitere Informationen
Webseiten und Dokumente, die mehr Tiefe oder Breite bieten. Die nützlicheren Artikel stehen weiter oben in der Liste.
- Shared Memory Consistency Models: A Tutorial
- Dieser Artikel wurde 1995 von Adve und Gharachorloo verfasst und 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 - Speicherbarrieren
- Ein guter Artikel, der die Probleme zusammenfasst.
https://en.wikipedia.org/wiki/Memory_barrier - Threads-Grundlagen
- Eine Einführung in die Multithread-Programmierung in C++ und Java von Hans Boehm. Diskussion über Daten-Races und grundlegende Synchronisierungsmethoden.
http://www.hboehm.info/c++mm/threadsintro.html - Java Concurrency In Practice
- Dieses 2006 veröffentlichte Buch deckt eine Vielzahl von Themen sehr detailliert ab. Sehr empfehlenswert für alle, die Multithread-Code in Java schreiben.
http://www.javaconcurrencyinpractice.com - JSR-133 (Java Memory Model) FAQ
- Eine einfache Einführung in das Java-Speichermodell, einschließlich einer Erklärung der Synchronisierung, flüchtiger Variablen und der Konstruktion von finalen Feldern.
(Etwas veraltet, insbesondere bei der Erörterung anderer Sprachen.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html - Validity of Program Transformations in the Java Memory Model
- Eine eher technische Erklärung der verbleibenden Probleme mit dem Java-Speichermodell. Diese Probleme gelten nicht für Programme ohne Datenrennen.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf - Übersicht über das Paket java.util.concurrent
- Die Dokumentation für das Paket
java.util.concurrent. Unten auf der Seite finden Sie einen Abschnitt mit dem Titel „Memory Consistency Properties“ (Eigenschaften der Speicherkonsistenz), in dem die Garantien der verschiedenen Klassen erläutert werden.java.util.concurrentPaketübersicht - Java Theory and Practice: Safe Construction Techniques in Java
- In diesem Artikel werden die Gefahren von Referenzen, die während der Objekterstellung verloren gehen, im Detail untersucht und Richtlinien für threadsichere Konstruktoren bereitgestellt.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - Java Theory and Practice: Managing Volatility
- Ein guter Artikel, der beschreibt, was Sie mit flüchtigen Feldern in Java erreichen können und was nicht.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html - Die Erklärung „Double-Checked Locking is Broken“
- Bill Pughs detaillierte Erklärung der verschiedenen Möglichkeiten, wie die doppelt geprüfte Sperre ohne
volatileoderatomicfehlschlägt. Umfasst C/C++ und Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html - [ARM] Barrier Litmus Tests and Cookbook
- Eine Erläuterung von ARM-SMP-Problemen, veranschaulicht mit kurzen ARM-Code-Snippets. Wenn Sie die Beispiele auf dieser Seite zu ungenau finden oder die formale Beschreibung der DMB-Anweisung lesen möchten, lesen Sie diesen Abschnitt. Außerdem werden die Anweisungen für Speicherbarrieren für ausführbaren Code beschrieben (möglicherweise nützlich, wenn Sie Code im laufenden Betrieb generieren). Beachten Sie, dass dies vor ARMv8 war, das auch zusätzliche Anweisungen zur Speicherreihenfolge unterstützt und zu einem etwas stärkeren Speichermodell übergegangen ist. Weitere Informationen finden Sie im „ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile“.
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf - Linux-Kernel-Speicherbarrieren
- 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 (C++-Programmiersprache), Abschnitt 1.10 und Klausel 29 („Atomic operations library“)
- Entwurf des Standards für atomare Vorgänge in C++. Diese Version entspricht fast dem C++14-Standard, der kleinere Änderungen in diesem Bereich von C++11 enthält.
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 (C-Programmiersprache), Kapitel 7.16 („Atomics <stdatomic.h>“)
- Entwurf des Standards für atomare ISO/IEC 9899-201x C-Vorgänge.
Weitere Informationen finden Sie auch in späteren Fehlerberichten.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf - C/C++11-Zuordnungen zu Prozessoren (University of Cambridge)
- Jaroslav Sevcik und Peter Sewell haben eine Sammlung von Übersetzungen von C++-Atomics in verschiedene gängige Prozessor-Befehlssätze zusammengestellt.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html - Dekker-Algorithmus
- Die „erste bekannte korrekte Lösung für das Problem des wechselseitigen Ausschlusses in der gleichzeitigen Programmierung“. Der Wikipedia-Artikel enthält den vollständigen Algorithmus und eine Erläuterung dazu, wie er aktualisiert werden müsste, um mit modernen optimierenden Compilern und SMP-Hardware zu funktionieren.
https://en.wikipedia.org/wiki/Dekker's_algorithm - Kommentare zu ARM im Vergleich zu Alpha und Adressabhängigkeiten
- Eine E-Mail von Catalin Marinas auf der arm-kernel-Mailingliste. Enthält eine gute Zusammenfassung der Adress- und Steuerungsabhängigkeiten.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html - What Every Programmer Should Know About Memory
- Ein sehr langer und detaillierter Artikel von Ulrich Drepper über verschiedene Arten von Arbeitsspeicher, insbesondere CPU-Caches.
http://www.akkadia.org/drepper/cpumemory.pdf - Reasoning about the ARM weakly consistent memory model
- Dieses Dokument wurde von Chong und Ishtiaq von ARM, Ltd. verfasst. Es beschreibt das ARM-SMP-Speichermodell auf eine strenge, aber zugängliche Weise. 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 - The JSR-133 Cookbook for Compiler Writers
- Doug Lea hat diesen Artikel als Ergänzung zur Dokumentation zu JSR-133 (Java Memory Model) verfasst. Es enthält die ersten Implementierungsrichtlinien für das Java-Speichermodell, das von vielen Compiler-Entwicklern verwendet wurde. Es wird immer noch häufig zitiert und bietet wahrscheinlich Einblicke.
Leider sind die vier hier beschriebenen Fence-Varianten nicht gut für von Android unterstützte Architekturen geeignet. Die oben genannten C++11-Zuordnungen sind jetzt eine bessere Quelle für genaue Rezepte, auch für Java.
http://g.oswego.edu/dl/jmm/cookbook.html - x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors
- Eine genaue Beschreibung des x86-Speichermodells. Präzise Beschreibungen des ARM-Speichermodells sind leider deutlich komplizierter.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf