SMP-Primer für Android

Plattformversionen von Android 3.0 und höher sind für die Unterstützung von Multiprozessor-Architekturen optimiert. In diesem Dokument werden Probleme vorgestellt, die beim Schreiben von Multithread-Code für symmetrische Multiprozessorsysteme in C, C++ und der Programmiersprache Java (der Einfachheit halber im Folgenden einfach als „Java“ bezeichnet) auftreten können. Er ist als Einstieg für Entwickler von Android-Apps gedacht und nicht als vollständige Diskussion zu diesem Thema gedacht.

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 teilen. Bis vor ein paar Jahren waren alle Android-Geräte UP (Uni-Processor).

Die meisten – wenn nicht alle – Android-Geräte hatten immer mehrere CPUs, aber früher wurde nur eine davon zum Ausführen von Anwendungen verwendet, während andere verschiedene Bits der Gerätehardware verwalten (z. B. das Radio). Die CPUs hatten möglicherweise unterschiedliche Architekturen und die darauf ausgeführten Programme konnten den Hauptarbeitsspeicher nicht 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 verursachen möglicherweise keine sichtbaren Probleme auf einem Uniprozessor, können jedoch regelmäßig fehlschlagen, 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 in verschiedenen Prozessorarchitekturen oder sogar in verschiedenen Implementierungen derselben Architektur ausgeführt wird. Code, der auf x86 gründlich getestet wurde, kann in ARM zu Fehlern führen. Code kann fehlschlagen, wenn er mit einem moderneren Compiler neu kompiliert wird.

Im weiteren Verlauf dieses Dokuments werden die Gründe dafür erläutert und Sie erfahren, was Sie tun müssen, um sicherzustellen, dass sich Ihr Code korrekt verhält.

Speicherkonsistenzmodelle: Warum sich SMPs etwas unterscheiden

Hier erhältst du einen schnellen, übersichtlichen Überblick über ein komplexes Thema. Einige Bereiche sind unvollständig, aber keiner davon sollte irreführend oder falsch sein. Wie Sie im nächsten Abschnitt sehen werden, sind die Details hier normalerweise nicht wichtig.

Hinweise zu einer ausführlicheren Behandlung des Themas finden Sie unter Weitere Lesematerialien am Ende des Dokuments.

Speicherkonsistenzmodelle, oft auch nur „Speichermodelle“, beschreiben die Garantien, die die Programmiersprache oder die Hardwarearchitektur für den Speicherzugriff bietet. Wenn Sie beispielsweise einen Wert für Adresse A und dann einen Wert für Adresse B schreiben, garantiert das Modell möglicherweise, dass jeder CPU-Kern diese Schreibvorgänge in dieser Reihenfolge sieht.

Das Modell, an das die meisten Programmierer gewöhnt sind, ist sequentielle Konsistenz, die so beschrieben wird (Adve und Gharachorloo):

  • Alle Speichervorgänge scheinen nacheinander ausgeführt zu werden.
  • Alle Vorgänge in einem einzelnen Thread scheinen in der vom Programm dieses Prozessors beschriebenen Reihenfolge ausgeführt zu werden.

Angenommen, wir haben vorübergehend einen sehr einfachen Compiler oder Interpreter, der keine Überraschungen mit sich bringt: Aufgaben im Quellcode werden so übersetzt, dass die Anweisungen genau in der entsprechenden Reihenfolge geladen und gespeichert werden (eine Anweisung pro Zugriff). Außerdem nehmen wir der Einfachheit halber an, dass jeder Thread auf seinem eigenen Prozessor ausgeführt wird.

Wenn Sie sich einen Codeausschnitt ansehen und feststellen, dass er einige Lese- und Schreibvorgänge aus dem Arbeitsspeicher ausführt, wissen Sie auf einer sequentiell konsistenten CPU-Architektur, dass der Code diese Lese- und Schreibvorgänge in der erwarteten Reihenfolge ausführt. Es ist möglich, dass die CPU Anweisungen neu ordnet und Lese- und Schreibvorgänge verzögert. Code, der auf dem Gerät ausgeführt wird, kann jedoch nicht feststellen, dass die CPU etwas anderes tut, als Anweisungen auf einfache Weise auszuführen. (Dem Arbeitsspeicher zugeordnete Gerätetreiber-E/A wird ignoriert.)

Zur Veranschaulichung sind kleine Code-Snippets sinnvoll, die allgemein als Lackmus-Tests bezeichnet werden.

Hier ist ein einfaches Beispiel, bei dem Code in zwei Threads ausgeführt wird:

Thread 1 Thread 2
A = 3
B = 5
reg0 = B
reg1 = A

In diesem und allen zukünftigen Lackmus-Beispielen werden Arbeitsspeicherstandorte durch Großbuchstaben (A, B, C) dargestellt und CPU-Register beginnen mit „reg“. Der gesamte Arbeitsspeicher ist anfänglich null. Die Anweisungen werden von oben nach unten ausgeführt. Hier speichert Thread 1 den Wert 3 an Position A und den Wert 5 an Position B. Thread 2 lädt den Wert aus Standort B in reg0 und lädt dann den Wert von Standort A in reg1. (Beachten Sie, dass wir in einer Reihenfolge schreiben und in einer anderen lesen.)

Es wird angenommen, dass Thread 1 und Thread 2 auf verschiedenen CPU-Kernen ausgeführt werden. Bei Multithread-Code sollten Sie immer von dieser Annahme ausgehen.

Die sequentielle Konsistenz garantiert, dass sich die Register nach der Ausführung beider Threads in einem der folgenden Status befinden:

Registrieren 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

Um in eine Situation zu kommen, in der wir B=5 sehen, bevor wir den Speicher für A sehen, müssen entweder die Lese- oder Schreibvorgänge in falscher Reihenfolge erfolgen. Auf einem Computer mit sequenzieller Konsistenz ist das nicht möglich.

Uni-Prozessoren, einschließlich x86 und ARM, sind normalerweise sequenziell konsistent. Threads scheinen verschränkt ausgeführt zu werden, da der Betriebssystem-Kernel zwischen ihnen wechselt. Die meisten SMP-Systeme, einschließlich x86 und ARM, sind nicht sequenziell konsistent. Es ist beispielsweise üblich, dass Hardware Speicher auf dem Weg zum Speicher zwischenspeichert, damit sie den Arbeitsspeicher nicht sofort erreichen und für andere Kerne sichtbar werden.

Die Details variieren stark. Zum Beispiel garantiert x86, dass reg0 = 5 und reg1 = 0 unmöglich bleibt, auch wenn es nicht sequenziell konsistent ist. Geschäfte werden gepuffert, aber ihre Reihenfolge wird beibehalten. ARM hingegen nicht. Die Reihenfolge der zwischengespeicherten Speicher wird nicht eingehalten und die Speicher erreichen möglicherweise nicht alle anderen Kerne gleichzeitig. Diese Unterschiede sind wichtig für die Zusammenstellung von Programmierern. Wie Sie weiter unten sehen werden, können und sollten Programmierer in C, C++ oder Java jedoch so programmieren, dass solche Architekturunterschiede verborgen bleiben.

Bisher sind wir unrealistisch davon ausgegangen, dass die Anweisungen nur durch die Hardware neu angeordnet werden. In Wirklichkeit 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 benötigt, bevor reg0 benötigt wird, und daher zuerst reg1 laden. Es kann auch sein, dass ein vorheriger Code bereits A geladen hat und der Compiler beschließt, diesen Wert wiederzuverwenden, anstatt A noch einmal zu laden. In beiden Fällen können die Ladevorgänge für reg0 und reg1 neu angeordnet werden.

Die Neuanordnung des Zugriffs auf verschiedene Speicherbereiche, 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. Mit ein wenig Sorgfalt können wir, wie wir sehen, auch verhindern, dass dies die Ergebnisse von Multithread-Programmen beeinträchtigt.

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 unserem Beispiel in reg0 und reg1 neu anordnen und Thread 1 könnte zwischen den neu geordneten Anweisungen geplant werden. Wenn unser Compiler jedoch keine Neuanordnung vorgenommen hat, tritt dieses Problem möglicherweise nie auf. Auf den meisten ARM-SMPs wird die Neuanordnung wahrscheinlich auch ohne Neuanordnung des Compilers auftreten, möglicherweise nach einer sehr großen Anzahl erfolgreicher Ausführungen. Sofern Sie nicht in Assembly-Sprache programmieren, erhöhen SMPs im Allgemeinen nur die Wahrscheinlichkeit, dass Sie schon immer Probleme sehen.

Datenrennenfreie Programmierung

Glücklicherweise lassen sich diese Details in der Regel einfach vermeiden. Wenn Sie einige einfache Regeln befolgen, können Sie in der Regel den gesamten vorherigen Abschnitt mit Ausnahme des Teils "Sequenzielle Konsistenz" vergessen. Wenn Sie versehentlich gegen diese Regeln verstoßen, können leider auch die anderen Komplikationen sichtbar werden.

Moderne Programmiersprachen fördern einen sogenannten „datenrennenfreien“ Programmstil. Solange Sie versprechen, keine „Datenrennen“ einzuführen und einige Konstrukte zu vermeiden, die dem Compiler etwas anderes mitteilen, versprechen der Compiler und die Hardware, sequenziell konsistente Ergebnisse zu liefern. Dies bedeutet nicht wirklich, dass eine Neuanordnung des Arbeitsspeicherzugriffs vermieden wird. Wenn Sie die Regeln befolgen, können Sie jedoch nicht feststellen, ob Arbeitsspeicherzugriffe neu angeordnet werden. Es ist vergleichbar mit der Vorstellung, dass die Wurst ein leckeres und appetitives Essen ist, solange Sie versprechen, nicht in die Wurstfabrik zu gehen. Datenrennen machen die hässliche Wahrheit über die Neuanordnung von Erinnerungen aufdecken.

Was ist ein „Datenrennen“?

Eine Datenrennen tritt auf, wenn mindestens zwei Threads gleichzeitig auf dieselben gewöhnlichen Daten zugreifen und mindestens einer von ihnen sie ändert. Mit "gewöhnlichen Daten" meinen wir etwas, das kein speziell für die Thread-Kommunikation vorgesehenes Synchronisierungsobjekt ist. Mutexe, Bedingungsvariablen, Java-Flüchtige oder atomare C++-Objekte sind keine gewöhnlichen Daten und ihre Zugriffe sind erlaubt. Sie werden sogar verwendet, um Datenrennen bei anderen Objekten zu verhindern.

Um festzustellen, ob zwei Threads gleichzeitig auf denselben Arbeitsspeicherstandort zugreifen, können wir die obige Diskussion über die Neuanordnung des Arbeitsspeichers ignorieren und von sequenzieller Konsistenz ausgehen. Das folgende Programm hat keine Datenrennen, wenn A und B gewöhnliche boolesche Variablen sind, die anfangs falsch sind:

Thread 1 Thread 2
if (A) B = true if (B) A = true

Da Vorgänge nicht neu angeordnet werden, werden beide Bedingungen als falsch ausgewertet und keine Variable wird aktualisiert. Daher darf es kein Datenrennen geben. Sie müssen nicht darüber nachdenken, was passieren könnte, wenn das Laden von A und dem Speicher zu B in Thread 1 irgendwie neu angeordnet wird. Der Compiler darf Thread 1 nicht neu ordnen, indem er ihn in „B = true; if (!A) B = false“ umschreibt. Das wäre wie eine Wurst bei hellem Tageslicht mitten in der Stadt zu machen.

Datenrennen werden offiziell durch grundlegende integrierte Typen wie Ganzzahlen und Verweise oder Zeiger definiert. Die Zuweisung zu einem int während des gleichzeitigen Lesens in einem anderen Thread stellt eindeutig ein Datenrennen dar. Sowohl die C++-Standardbibliothek als auch die Java-Sammlungsbibliotheken sind jedoch geschrieben, damit Sie auch auf Bibliotheksebene über Datenrennen eine Einbeziehung herstellen können. Sie versprechen, keine Datenrennen einzuführen, es sei denn, ein gleichzeitiger Zugriff auf denselben Container erfolgt, wobei mindestens einer davon den Container aktualisiert. Wenn ein set<T> in einem Thread aktualisiert und gleichzeitig in einem anderen gelesen wird, kann die Bibliothek eine Datenrasse einführen und kann somit informell als „Datenrennen auf Bibliotheksebene“ betrachtet werden. Umgekehrt führt das Aktualisieren einer set<T> in einem Thread und das Lesen einer anderen in einem anderen nicht zu einem Datenwettkampf, da die Bibliothek in diesem Fall verspricht, in diesem Fall keine (untergeordnete) Datenrasse einzuführen.

Normalerweise kann der gleichzeitige Zugriff auf verschiedene Felder in einer Datenstruktur nicht zu einer Datenrasse führen. Es gibt jedoch eine wichtige Ausnahme zu dieser Regel: Fortlaufende Sequenzen von Bitfeldern in C oder C++ werden als einzelner "Arbeitsspeicher" behandelt. Der Zugriff auf ein Bitfeld in einer solchen Sequenz wird so behandelt, als würde auf alle von ihnen zugegriffen, um die Existenz einer Datenrennen zu ermitteln. Dies spiegelt die Unfähigkeit einer gemeinsamen Hardware wider, einzelne Bits zu aktualisieren, ohne auch benachbarte Bits zu lesen und umzuschreiben. Java-Programmierer haben keine derartigen Bedenken.

Datenrennen vermeiden

Moderne Programmiersprachen bieten eine Reihe von Synchronisierungsmechanismen, um Datenrennen zu vermeiden. Dies sind die grundlegendsten Tools:

Sperren oder Mutexe
Mit
Mutexen (C++11 std::mutex oder pthread_mutex_t) oder synchronized-Blöcken in Java können Sie dafür sorgen, dass bestimmte Codeabschnitte nicht gleichzeitig mit anderen Codeabschnitten ausgeführt werden, die auf dieselben Daten zugreifen. Diese und ähnliche Einrichtungen bezeichnen wir allgemein als „Schlösser“. Wenn vor dem Zugriff auf eine freigegebene Datenstruktur eine bestimmte Sperre eingerichtet und anschließend freigegeben wird, werden Datenrennen beim Zugriff auf die Datenstruktur verhindert. Außerdem wird sichergestellt, dass Aktualisierungen und Zugriffe atomar sind, d.h., in der Mitte kann keine weitere Aktualisierung der Datenstruktur ausgeführt werden. Dies ist zu Recht das weitaus gängigste Werkzeug zur Verhinderung von Datenrennen. Durch die Verwendung von synchronized-Java-Blöcken oder lock_guard oder unique_lock in C++ wird sichergestellt, dass Sperren im Ausnahmefall korrekt freigegeben werden.
Flüchtige/atomare Variablen
Java bietet volatile-Felder, die den gleichzeitigen Zugriff ohne Datenrennen unterstützen. 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++ bezieht sich dies normalerweise auf einfache Read-Modify-Write-Vorgänge wie Inkremente. Java erfordert dazu spezielle Methodenaufrufe.) Im Gegensatz zu Sperren können die Variablen volatile oder atomic nicht direkt verwendet werden, um zu verhindern, dass andere Threads längere Codesequenzen beeinträchtigen.

Wichtig: volatile hat in C++ und Java sehr unterschiedliche Bedeutungen. In C++ verhindert volatile keine Datenrennen, obwohl älterer Code ihn oft als Umgehung 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 vorgesehen.

atomic- und C++-Variablen sowie volatile-Variablen von Java können verwendet werden, um Datenrennen bei anderen Variablen zu verhindern. Wenn flag als Typ atomic<bool>, atomic_bool(C/C++) oder volatile boolean (Java) deklariert ist und anfänglich „false“ ist, ist das folgende Snippet frei von Datenrennen:

Thread 1 Thread 2
A = ...
  flag = true
while (!flag) {}
... = A

Da Thread 2 wartet, bis flag festgelegt wird, muss der Zugriff auf A in Thread 2 nach der Zuweisung zu A in Thread 1 erfolgen und nicht gleichzeitig mit der Zuweisung. Daher gibt es bei A kein Datenrennen. Das Rennen unter flag zählt nicht als Datenrennen, da flüchtige/atomare Zugriffe keine „normalen Arbeitsspeicherzugriffe“ sind.

Die Implementierung ist erforderlich, um eine ausreichende Neuanordnung des Arbeitsspeichers zu verhindern oder auszublenden, damit Code wie der vorherige Lackmus-Test wie erwartet funktioniert. Dadurch sind Zugriffe auf flüchtige/atomare Speicher in der Regel deutlich teurer als normale Zugriffe.

Obwohl das vorherige Beispiel keine Datenrennen ist, sind 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.

Wenn die Neuanordnung der Erinnerung sichtbar wird

Durch eine datenrennenfreie Programmierung müssen wir uns normalerweise nicht mehr mit Problemen bei der Neuordnung des Arbeitsspeicherzugriffs befassen. Es gibt jedoch mehrere Fälle, in denen eine Neuanordnung sichtbar wird:
  1. Wenn in Ihrem Programm ein Fehler auftritt, der zu einem unbeabsichtigten Datenwettkampf führt, können Compiler- und Hardwaretransformationen sichtbar werden. Das Verhalten Ihres Programms kann dann überraschend sein. Wenn wir beispielsweise im vorherigen Beispiel vergessen haben, flag flüchtig zu deklarieren, wird Thread 2 möglicherweise eine nicht initialisierte A angezeigt. Oder der Compiler entscheidet sich, dass das Flag sich während der Schleife von Thread 2 nicht ändern kann, und transformiert das Programm in
    Thread 1 Thread 2
    A = ...
      flag = true
    reg0 = flag; while (!reg0) {}
    ... = A
    Beim Debuggen kann es passieren, dass die Schleife trotz der Tatsache, dass flag wahr ist, endlos fortgesetzt wird.
  2. C++ bietet Möglichkeiten zum expliziten Lockern von sequenzieller Konsistenz, auch wenn es keine Rennen gibt. Atomare Vorgänge können explizite memory_order_...-Argumente annehmen. In ähnlicher Weise bietet das Paket java.util.concurrent.atomic einen eingeschränkteren Satz ähnlicher Einrichtungen, insbesondere lazySet(). Java-Programmierer nutzen gelegentlich vorsätzliche Datenrennen für einen ähnlichen Effekt. All dies führt zu Leistungsverbesserungen, wobei die Programmierkomplexität mit hohen Kosten verbunden ist. Sie werden unten nur kurz erläutert.
  3. Ein Teil von C- und C++-Code wurde in einem älteren Stil geschrieben, der nicht vollständig mit aktuellen Sprachstandards übereinstimmt, in denen volatile-Variablen anstelle von atomic-Variablen verwendet werden und die Speicheranordnung durch das Einfügen sogenannter Zäune oder Barriere explizit untersagt ist. Dies erfordert explizite Überlegungen zur Zugriffsneuanordnung und das Verständnis von Hardware-Speichermodellen. Ein entsprechender Codierungsstil wird im Linux-Kernel weiterhin verwendet. Sie sollte nicht in neuen Android-Apps verwendet werden und wird an dieser Stelle ebenfalls nicht weiter erläutert.

Üben

Die Behebung von Problemen mit der Speicherkonsistenz kann sehr schwierig sein. Wenn ein fehlendes Schloss, eine atomic- oder volatile-Deklaration dazu führt, dass Code veraltete Daten liest, können Sie die Ursache möglicherweise nicht durch Untersuchung von Speicherdumps mit einem Debugger herausfinden. Bis Sie eine Debugger-Abfrage ausführen können, haben die CPU-Kerne möglicherweise alle Zugriffe erfasst und der Inhalt des Arbeitsspeichers und der CPU-Register scheint „unmöglich“ zu sein.

Was in C nicht erlaubt ist

Im Folgenden finden Sie einige Beispiele für falschen Code sowie einfache Möglichkeiten zur Fehlerbehebung. Bevor wir damit beginnen, müssen wir uns die Verwendung einer einfachen Sprachfunktion ansehen.

C/C++ und „flüchtig“

C- und C++-volatile-Deklarationen sind ein spezielles Tool. Sie verhindern, dass der Compiler flüchtige Zugriffe neu ordnet oder entfernt. Dies kann hilfreich sein bei Code, der auf Hardwaregeräteregistern, Speicher, der mehreren Standorten zugeordnet ist, oder in Verbindung mit setjmp zugreift. C- und C++-volatile sind jedoch im Gegensatz zu Java-volatile nicht für die Thread-Kommunikation vorgesehen.

In C und C++ kann der Zugriff auf volatile-Daten neu angeordnet werden, wobei der Zugriff auf nichtflüchtige Daten neu angeordnet werden kann. Es gibt keine Atomaritätsgarantien. Daher kann volatile nicht für die Freigabe von Daten zwischen Threads in portablem Code verwendet werden, auch nicht auf einem Uniprozessor. C volatile verhindert normalerweise nicht die Neuanordnung des Zugriffs durch die Hardware. Daher ist es in Multi-Threaded-SMP-Umgebungen noch weniger nützlich. Aus diesem Grund unterstützen C11 und C++11 atomic-Objekte. Sie sollten stattdessen diese verwenden.

Vieler älterer C- und C++-Code missbraucht volatile immer noch für die Thread-Kommunikation. Dies funktioniert oft richtig bei Daten, die in ein Maschinenregister passen, sofern sie entweder mit expliziten Zäunen verwendet werden oder in Fällen, in denen die Speichersortierung nicht wichtig ist. Es kann jedoch nicht garantiert werden, dass es mit zukünftigen Compilern korrekt funktioniert.

Beispiele

In den meisten Fällen wäre es besser, eine Sperre (z. B. pthread_mutex_t oder C++11 std::mutex) anstelle eines atomaren Vorgangs zu verwenden. Wir verwenden den zweiten Vorgang jedoch, um zu veranschaulichen, wie sie in der Praxis eingesetzt 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 zuweisen, ihre Felder initialisieren und am Ende durch Speichern in einer globalen Variablen "veröffentlichen". Dann kann er von jedem anderen Thread gesehen werden, aber das ist in Ordnung, da er vollständig initialisiert ist.

Das Problem besteht darin, dass der Speicher in gGlobalThing beobachtet werden konnte, bevor die Felder initialisiert werden. Dies liegt in der Regel daran, dass entweder der Compiler oder der Prozessor die Speicher in 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 hier ist ein Datenrennen am gGlobalThing. Wenn Thread 1 initGlobalThing() und Thread 2 useGlobalThing() aufruft, kann gGlobalThing während des Schreibens gelesen werden.

Dies kann behoben werden, indem Sie gGlobalThing als atomar deklarieren. In C++11:

atomic<MyThing*> gGlobalThing(NULL);

Dadurch wird sichergestellt, dass die Schreibvorgänge für andere Threads in der richtigen Reihenfolge sichtbar werden. Außerdem wird sichergestellt, dass einige andere Fehlermodi verhindert werden, die ansonsten zulässig sind, auf echter Android-Hardware aber unwahrscheinlich sind. So wird beispielsweise sichergestellt, dass kein gGlobalThing-Zeiger angezeigt wird, der nur teilweise geschrieben wurde.

Was in Java nicht erlaubt ist

Wir haben einige relevante Funktionen der Java-Sprache noch nicht besprochen, daher werfen wir einen kurzen Blick darauf.

Technisch gesehen erfordert Java keinen Code, der ohne Datenrhythmus ist. Außerdem gibt es eine geringe Menge an sorgfältig geschriebenem Java-Code, der bei Vorhandensein von Datenrennen ordnungsgemäß funktioniert. Das Schreiben von Code ist jedoch extrem schwierig und wird im Folgenden nur kurz erläutert. Erschwerend kommt hinzu, dass die Experten, die die Bedeutung eines solchen Codes angegeben haben, nicht mehr der Meinung sind, dass die Spezifikation korrekt ist. (Die Spezifikation eignet sich für Code ohne Datenrennen.)

Wir halten uns vorerst an das Modell ohne Datenrennen, bei dem Java im Wesentlichen die gleichen Garantien wie C und C++ bietet. Auch hier bietet die Sprache einige Primitive, die explizit sequentielle Konsistenz lockern, insbesondere die Aufrufe lazySet() und weakCompareAndSet() in java.util.concurrent.atomic. Wie bei C und C++ werden wir diese vorerst ignorieren.

"Synchronisierte" und "flüchtige" Keywords in Java

Das Keyword "synchronized" stellt den integrierten Sperrmechanismus der Java-Sprache bereit. Jedem Objekt ist ein „Monitor“ zugeordnet, mit dem sich der Zugriff gegenseitig ausschließen kann. Wenn zwei Threads versuchen, mit demselben Objekt zu „synchronisieren“, wartet einer von ihnen, bis der andere abgeschlossen ist.

Wie oben erwähnt, ist volatile T von Java ein Analog zu atomic<T> von C++11. Der gleichzeitige Zugriff auf volatile-Felder ist zulässig und führt nicht zu Datenrennen. Wenn lazySet() u. a. und Datenrennen ignoriert werden, ist es die Aufgabe der Java-VM, dafür zu sorgen, dass das Ergebnis weiterhin sequenziell konsistent angezeigt wird.

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 Speichereffekt ist das Schreiben in eine flüchtige Datei analog zu einem Monitor-Release und das Lesen aus einer flüchtigen Datei ist wie ein Monitor-Datensatz.

Es gibt einen nennenswerten Unterschied zu atomic in C++: Wenn wir volatile int x; in Java schreiben, ist x++ mit x = x + 1 identisch. Sie führt ein atomares Laden aus, erhöht das Ergebnis und führt dann einen atomaren Speicher aus. Im Gegensatz zu C++ ist das Inkrement als Ganzes nicht atomar. Vorgänge für atomare Inkremente werden stattdessen von java.util.concurrent.atomic bereitgestellt.

Beispiele

Hier sehen Sie 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 aus mehreren Threads aufgerufen. Wir möchten sicherstellen, dass jeder Thread die aktuelle Anzahl sieht, wenn get() aufgerufen wird. Das größte Problem ist, dass mValue++ eigentlich drei Vorgänge ist:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

Wenn zwei Threads gleichzeitig in incr() ausgeführt werden, kann eine der Aktualisierungen verloren gehen. Damit das Inkrement atomar wird, muss incr() als „synchronisiert“ deklariert werden.

Sie funktioniert jedoch weiterhin nicht, vor allem auf sozialen Netzwerken. Es gibt immer noch ein Datenrennen, in dem get() gleichzeitig mit incr() auf mValue zugreifen kann. Unter Java-Regeln kann es so aussehen, als sei der get()-Aufruf in Bezug auf anderen Code neu angeordnet. Wenn wir beispielsweise zwei Zähler hintereinander lesen, könnten die Ergebnisse inkonsistent erscheinen, weil die get()-Aufrufe, die von der Hardware oder vom Compiler neu angeordnet wurden, möglicherweise nicht einheitlich sind. Wir können das Problem beheben, indem wir get() für die Synchronisierung deklarieren. Mit dieser Änderung ist der Code natürlich korrekt.

Leider haben wir die Möglichkeit von Sperrenkonflikten eingeführt, die die Leistung beeinträchtigen können. Anstatt get() für die Synchronisierung zu deklarieren, können Sie mValue als „flüchtig“ deklarieren. (Hinweis: incr() muss weiterhin synchronize verwenden, da mValue++ ansonsten kein einzelner atomarer Vorgang ist.) Dadurch werden auch alle Datenrennen vermieden, sodass die sequenzielle Konsistenz erhalten bleibt. incr() ist etwas langsamer, da sowohl der Aufwand für die Überwachung von Ein- und Ausgang als auch der Aufwand verursacht, der mit einem flüchtigen Speicher verbunden ist. get() ist jedoch schneller. Daher ist dies auch ohne Konflikt ein Gewinn, wenn die Anzahl der Schreibvorgänge erheblich übersteigen sollte. Unter AtomicInteger finden Sie eine Möglichkeit, den synchronisierten Block vollständig 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 Datenrennen auf sGoodies gibt. Daher wird die Zuweisung sGoodies = goods möglicherweise vor der Initialisierung der Felder in goods beobachtet. Wenn Sie sGoodies mit dem Schlüsselwort volatile deklarieren, wird die sequenzielle Konsistenz wiederhergestellt und alles funktioniert wie erwartet.

Nur der sGoodies-Verweis selbst ist flüchtig. Der Zugriff auf die darin enthaltenen Felder ist jedoch nicht der Fall. Sobald sGoodies den Wert volatile hat und die Speicherreihenfolge korrekt beibehalten wurde, kann nicht gleichzeitig auf die Felder zugegriffen werden. Die Anweisung z = sGoodies.x führt eine flüchtige Last von MyClass.sGoodies gefolgt von einer nichtflüchtigen Last von sGoodies.x aus. Wenn Sie eine lokale Referenz-MyGoodies localGoods = sGoodies erstellen, führt eine nachfolgende z = localGoods.x keine flüchtigen Ladevorgänge aus.

Eine gebräuchlichere Redewendung in der Java-Programmierung ist die berühmt-berüchtigte „Double-Checked-Locking“:

class MyClass {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

Die Idee dahinter ist, dass eine einzelne Instanz eines Helper-Objekts mit einer Instanz von MyClass verknüpft ist. Er darf nur einmal erstellt werden. Deshalb wird er über eine dedizierte getHelper()-Funktion erstellt und zurückgegeben. Um ein Rennen zu vermeiden, bei dem die Instanz von zwei Threads erstellt wird, müssen wir die Objekterstellung synchronisieren. Wir möchten den Aufwand für den „synchronisierten“ Block jedoch nicht bei jedem Aufruf zahlen. Daher machen wir diesen Teil nur, wenn helper derzeit null ist.

Es gibt einen Datenwettkampf im Feld helper. Er kann gleichzeitig mit dem helper == null in einem anderen Thread festgelegt werden.

Um zu sehen, wie dies fehlschlagen kann, stellen Sie sich den gleichen Code leicht um, als wäre er in eine C-ähnliche Sprache kompiliert (ich habe einige Ganzzahlfelder hinzugefügt, um die Konstruktoraktivität Helper’s darzustellen):

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 die Neuanordnung des Speichers in helper mit denen in den x/y-Feldern verhindern. Ein anderer Thread könnte helper als „null“ finden, aber seine Felder sind noch nicht festgelegt und sind nicht einsatzbereit. Weitere Details und weitere Fehlermodi finden Sie im Anhang unter dem Link "Double Checked Locking is Defekt" oder Punkt 71 ("Use lazy initialisierung judiciously" in Josh Blochs Effective Java, 2nd Edition).

Es gibt zwei Möglichkeiten, dieses Problem zu beheben:

  1. Führen Sie die einfache Sache aus und löschen Sie die äußere Prüfung. Dadurch wird sichergestellt, dass der Wert von helper außerhalb eines synchronisierten Blocks nie untersucht wird.
  2. helper als flüchtig deklarieren. Mit dieser kleinen Änderung funktioniert der Code in Beispiel J-3 ab Java 1.5 korrekt. (Sie sollten sich einen Moment Zeit nehmen, um sich selbst davon zu überzeugen.)

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
        }
    }
}

Sehen wir uns useValues() an. Wenn Thread 2 die Aktualisierung von vol1 noch nicht beobachtet hat, kann nicht festgestellt werden, ob data1 oder data2 noch festgelegt wurde. Sobald das Update für vol1 erkannt wird, weiß er, dass der sichere Zugriff auf data1 möglich ist und die Daten korrekt gelesen werden können, ohne dass es zu einem Datenrennen kommt. Es können jedoch keine Annahmen zu data2 getroffen werden, da dieser Speicher nach dem flüchtigen Speicher ausgeführt wurde.

Beachten Sie, dass volatile nicht verwendet werden kann, um die Neuanordnung anderer Arbeitsspeicherzugriffe zu verhindern, die im Wettlauf miteinander konkurrieren. Es ist nicht garantiert, dass ein Maschinenspeicher-Fencing-Befehl generiert wird. Damit können Datenrennen vermieden werden, indem Code nur dann ausgeführt wird, wenn ein anderer Thread eine bestimmte Bedingung erfüllt hat.

So funktionierts

Bevorzugen Sie in C/C++ Synchronisierungsklassen für C++11, z. B. std::mutex. Wenn nicht, verwenden Sie die entsprechenden pthread-Vorgänge. Dazu gehören die richtigen Speicherzäune, die korrekte (sequentielle Konsistenz) und effizientes Verhalten auf allen Android-Plattformversionen ermöglichen, sofern nicht anders angegeben. Achten Sie darauf, sie richtig zu verwenden. Denken Sie beispielsweise daran, dass Wartezeiten bei Bedingungsvariablen skurril zurückgegeben werden können, ohne dass sie signalisiert wird, und daher in einer Schleife angezeigt werden sollten.

Atomare Funktionen sollten nach Möglichkeit nicht direkt verwendet werden, es sei denn, die von Ihnen implementierte Datenstruktur ist extrem einfach, z. B. ein Zähler. Das Sperren und Entsperren eines pthread-Mutex erfordern jeweils einen einzelnen atomaren Vorgang und kosten oft weniger als einen einzelnen Cache-Fehler, wenn es keine Konflikte gibt. Sie sparen also nicht viel, wenn Sie Mutex-Aufrufe durch atomare Operationen ersetzen. Sperrenfreie Designs für nicht triviale Datenstrukturen erfordern viel Sorgfalt, um sicherzustellen, dass übergeordnete Vorgänge in der Datenstruktur atomar erscheinen (als Ganzes und nicht nur ihre explizit atomaren Teile).

Wenn Sie atomare Operationen verwenden, kann die Entspannung der Sortierung 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, in dem diese Codes verwendet werden, hat im Nachhinein Fehler. Vermeiden Sie diese nach Möglichkeit. Wenn Ihre Anwendungsfälle zu keinem der im nächsten Abschnitt genannten passen, sollten Sie entweder ein Experte sein oder sich mit einem Experten beraten lassen.

Verwenden Sie volatile nicht für die Thread-Kommunikation in C/C++.

In Java lassen sich Parallelitätsprobleme oft am besten mit einer geeigneten Dienstprogrammklasse aus dem java.util.concurrent-Paket lösen. Der Code ist gut geschrieben und gut auf SMP getestet.

Am sichersten ist es, Ihre Objekte unveränderlich zu machen. Objekte aus Klassen wie String und Ganzzahl in Java enthalten Daten, die nach dem Erstellen eines Objekts nicht mehr geändert werden können. Dadurch wird das Risiko von Datenrennen bei diesen Objekten vermieden. Das Buch Effective Java, 2nd Ed. enthält spezifische Anweisungen unter „Item 15: Minimierbarkeit der Veränderlichkeit“. Beachten Sie insbesondere, wie wichtig es ist, Java-Felder als „final“ (Bloch) zu deklarieren.

Auch wenn ein Objekt unveränderlich ist, denken Sie daran, dass die Kommunikation mit einem anderen Thread ohne irgendeine Art von Synchronisierung ein Datenrennen ist. Dies kann in Java gelegentlich akzeptabel sein (siehe unten), erfordert jedoch große Vorsicht und führt wahrscheinlich zu fehlerhaftem Code. Wenn die Leistung nicht besonders wichtig ist, fügen Sie eine volatile-Deklaration hinzu. In C++ stellt die Kommunikation eines Zeigers oder eines Verweises auf ein unveränderliches Objekt ohne ordnungsgemäße Synchronisierung, wie bei jeder Datenrennen, ein Fehler dar. In diesem Fall ist es recht wahrscheinlich, dass es zu zeitweiligen Abstürzen kommt, da der empfangende Thread beispielsweise aufgrund der Neuanordnung des Speichers einen nicht initialisierten Methodentabellenzeiger sieht.

Wenn weder eine vorhandene Bibliotheksklasse noch eine unveränderliche Klasse geeignet ist, sollten die Java-synchronized-Anweisung oder C++ lock_guard / unique_lock verwendet werden, um den Zugriff auf jedes Feld zu schützen, auf das von mehr als einem Thread zugegriffen werden kann. Wenn Mutexe in Ihrer Situation nicht funktionieren, sollten Sie die freigegebenen Felder volatile oder atomic deklarieren. Sie müssen jedoch genau darauf achten, die Interaktionen zwischen Threads zu verstehen. Diese Deklarationen retten Sie nicht vor häufigen gleichzeitigen Programmierfehlern, helfen Ihnen aber dabei, mysteriöse Fehler im Zusammenhang mit der Optimierung von Compilern und SMP-Fehlern zu vermeiden.

Sie sollten in seinem Konstruktor keinen Verweis auf ein Objekt "veröffentlichen", d.h. es anderen Threads zur Verfügung stellen. In C++ ist dies weniger kritisch oder wenn Sie sich an unseren Rat „No Data Races“ in Java halten. Dies ist jedoch immer eine gute Empfehlung und wird besonders dann von entscheidender Bedeutung, wenn Ihr Java-Code in anderen Kontexten ausgeführt wird, in denen das Java-Sicherheitsmodell eine Rolle spielt, und nicht vertrauenswürdiger Code durch Zugriff auf diese „gehackte“ Objektreferenz zu Datenwettkämpfen führen kann. Das ist auch wichtig, wenn Sie unsere Warnungen ignorieren und die im nächsten Abschnitt beschriebenen Verfahren anwenden. Weitere Informationen finden Sie unter Sichere Bautechniken in Java.

Weitere Informationen zu Bestellungen bei schwachem Arbeitsspeicher

C++11 und höher bieten explizite Mechanismen zum Lockern der sequentiellen Konsistenzgarantien für Programme, bei denen es keine Datenrennen gibt. Explizite Argumente memory_order_relaxed, memory_order_acquire (nur Ladevorgänge) und memory_order_release (nur Speicher) bieten für atomare Vorgänge jeweils strikt schwächere Garantien als der Standardwert (in der Regel impliziter Wert: memory_order_seq_cst). memory_order_acq_rel bietet sowohl memory_order_acquire- als auch memory_order_release-Garantien für atomare Lese-/Änderungsschreibvorgä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 den memory_order_release-Speichern in C++. Die normalen Java-Variablen werden manchmal als Ersatz für memory_order_relaxed-Zugriffe verwendet, obwohl sie in Wirklichkeit noch schwächer sind. Im Gegensatz zu C++ gibt es keinen echten 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, sie zu verwenden. Bei schwach geordneten Maschinenarchitekturen wie ARM sparen Sie mit ihrer Verwendung in der Regel mehrere Dutzend Maschinenzyklen pro atomarer Operation. Bei x86 ist die Leistungssteigerung auf Geschäfte beschränkt und wahrscheinlich weniger auffällig. Der Vorteil kann mit einer größeren Anzahl von Kernen abnehmen, da das Speichersystem zu einem begrenzenden Faktor wird.

Die vollständige Semantik schwach geordneter Atome ist kompliziert. Im Allgemeinen erfordern sie ein genaues Verständnis der Sprachregeln, auf die wir hier nicht eingehen werden. Beispiele:

  • Der Compiler oder die Hardware kann memory_order_relaxed-Zugriffe in einen kritischen Bereich verschieben, der durch eine erworbene Sperre und einen entsprechenden Release begrenzt ist, jedoch nicht aus diesem Bereich. Das bedeutet, dass zwei memory_order_relaxed-Speicher möglicherweise in falscher Reihenfolge sichtbar werden, auch wenn sie durch einen kritischen Abschnitt getrennt sind.
  • Wenn eine gewöhnliche Java-Variable als gemeinsam genutzter Zähler missbraucht wird, kann der Eindruck entstehen, dass ein anderer Thread abnimmt, obwohl sie nur von einem einzigen anderen Thread erhöht wird. Dies gilt jedoch nicht für das atomare C++-Element memory_order_relaxed.

Vor diesem Hintergrund möchten wir Sie darauf hinweisen, dass wir hier einige Redewendungen anführen, die viele Anwendungsfälle für schwach geordnete Atome abdecken. Viele davon gelten nur für C++.

Zugriffe außerhalb von Rennen

Es ist relativ üblich, dass eine Variable atomar ist, da sie manchmal gleichzeitig mit einem Schreibvorgang gelesen wird. Dieses Problem tritt jedoch nicht bei allen Zugriffen auf. Beispielsweise kann eine Variable atomar sein, da sie außerhalb eines kritischen Abschnitts gelesen wird. Alle Updates sind jedoch durch eine Sperre geschützt. In diesem Fall kann ein Lesevorgang, der zufällig durch dieselbe Sperre geschützt wird, nicht als Race-Roll eingestuft werden, da keine gleichzeitigen Schreibvorgänge möglich sind. In einem solchen Fall kann der Zugriff, der nicht im Rennen ist (in diesem Fall Last), mit memory_order_relaxed annotiert werden, ohne dass sich dies an der Richtigkeit des C++-Codes ändert. Die Sperrimplementierung erzwingt bereits die erforderliche Speicherreihenfolge in Bezug auf den Zugriff durch andere Threads und memory_order_relaxed gibt an, dass im Wesentlichen keine zusätzlichen Einschränkungen für die Reihenfolge für den atomaren Zugriff erzwungen werden müssen.

In Java gibt es dazu keine wirklich vergleichbare Vorgehensweise.

Die Richtigkeit des Ergebnisses wird nicht als zuverlässiger Wert herangezogen

Wenn wir eine Lauflast nur verwenden, um einen Hinweis zu generieren, ist es im Allgemeinen auch in Ordnung, keine Speicheranordnung für die Last zu erzwingen. Wenn der Wert nicht zuverlässig ist, können wir das Ergebnis auch nicht zuverlässig nutzen, um Rückschlüsse auf andere Variablen zu ziehen. Daher ist es in Ordnung, wenn die Reihenfolge der Arbeitsspeicher nicht garantiert ist und die Auslastung mit einem memory_order_relaxed-Argument übergeben wird.

Ein gängiges Beispiel ist die Verwendung von compare_exchange in C++, um x in kleinstmöglichen Schritten durch f(x) zu ersetzen. Die anfängliche Ladung von x zum Berechnen von f(x) muss nicht zuverlässig sein. Wenn ein Fehler auftritt, schlägt compare_exchange fehl und wir versuchen es noch einmal. Für den anfänglichen Ladevorgang von x kann ein memory_order_relaxed-Argument verwendet werden. Nur die Speichersortierung für die tatsächliche compare_exchange ist wichtig.

Unvollständig geänderte, aber ungelesene Daten

Gelegentlich werden Daten von mehreren Threads parallel geändert, jedoch erst überprüft, wenn die parallele Berechnung abgeschlossen ist. Ein gutes Beispiel hierfür ist ein Zähler, der durch mehrere Threads parallel atomar inkrementiert wird (z. B. mit fetch_add() in C++ oder atomic_fetch_add_explicit() in C). Das Ergebnis dieser Aufrufe wird jedoch immer ignoriert. Der resultierende Wert wird erst am Ende gelesen, nachdem alle Aktualisierungen abgeschlossen sind.

In diesem Fall ist nicht ersichtlich, ob Zugriffe auf diese Daten neu angeordnet wurden. Daher kann C++-Code ein memory_order_relaxed-Argument verwenden.

Einfache Ereigniszähler sind ein gängiges Beispiel dafür. Da dies häufig vorkommt, sollten Sie einige Beobachtungen zu diesem Fall anstellen:

  • Die Verwendung von memory_order_relaxed verbessert 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 bei jedem Zugriff eines neuen Threads auf den Zähler zu einem Cache-Fehler. Wenn Aktualisierungen häufig erfolgen und zwischen Threads gewechselt werden, lässt sich viel schneller vermeiden, den gemeinsamen Zähler jedes Mal zu aktualisieren, indem Sie beispielsweise Thread-lokale Zähler verwenden und am Ende summieren.
  • Diese Technik kann mit dem vorherigen Abschnitt kombiniert werden: Es ist möglich, während der Aktualisierung ungefähre und unzuverlässige Werte gleichzeitig zu lesen. Für alle Vorgänge wird memory_order_relaxed verwendet. Es ist jedoch wichtig, die resultierenden Werte als völlig unzuverlässig zu behandeln. Die Tatsache, dass die Anzahl einmal erhöht worden zu sein scheint, bedeutet nicht, dass ein anderer Thread den Punkt erreicht hat, an dem das Inkrement ausgeführt wurde. Das Inkrement kann stattdessen mit früherem Code neu angeordnet worden sein. (Wie im bereits erwähnten Fall, garantiert C++, dass beim zweiten Laden eines solchen Zählers kein Wert zurückgegeben wird, der kleiner ist als ein früherer Ladevorgang im selben Thread. Es sei denn, der Zähler ist überlaufen.
  • Es ist üblich, Code zu finden, der versucht, ungefähre Zählerwerte zu berechnen, indem einzelne atomare (oder nicht) Lese- und Schreibvorgänge ausgeführt werden, ohne das Inkrement als Ganzes atomar zu machen. Das übliche Argument ist, dass dies „nah genug“ für Leistungszähler oder Ähnliches ist. Normalerweise ist das nicht der Fall. Wenn Updates häufig genug durchgeführt werden (was für Sie wahrscheinlich von Interesse ist), geht in der Regel ein großer Teil der Zählungen verloren. Auf einem Quad-Core-Gerät kann es passieren, dass 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 Flag-Kommunikation

Ein memory_order_release-Speicher (oder Read-Modify-Write-Vorgang) sorgt dafür, dass, wenn anschließend ein memory_order_acquire-Ladevorgang (oder Read-Ändern-Write-Vorgang) den geschriebenen Wert liest, auch alle Speicher (gewöhnlich oder atomar) erfasst werden, die dem memory_order_release-Speicher vorausgingen. Umgekehrt beobachten alle Ladevorgänge vor dem memory_order_release keine Speicher, die dem Last memory_order_acquire gefolgt sind. Im Gegensatz zu memory_order_relaxed können solche atomaren Vorgänge verwendet werden, um den Fortschritt eines Threads an einen anderen zu kommunizieren.

Wir können z. B. das oben genannte Sperrbeispiel in C++ in C++ 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 Abruf- und Releasespeicher sorgt dafür, dass bei einem helper-Objekt, das nicht null ist, auch seine Felder korrekt initialisiert sind. Wir haben auch die vorherige Beobachtung aufgenommen, dass Nicht-Rennlasten 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 wäre:

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

Dies bietet den gleichen schnellen Pfad, greift jedoch auf standardmäßige, sequenziell konsistente Vorgänge auf dem nicht leistungskritischen langsamen Pfad zurück.

Selbst hier generiert helper.load(memory_order_acquire) wahrscheinlich den gleichen Code auf aktuellen von Android unterstützten Architekturen als einfacher (sequentiell konsistenter) Verweis auf helper. Die vorteilhafteste Optimierung könnte hier die Einführung von myHelper sein, um ein zweites Laden zu eliminieren. Zukünftige Compiler würden dies jedoch automatisch tun.

Die Beschaffungs-/Freigabereihenfolge verhindert nicht, dass Geschäfte sichtbar verzögert werden, und sorgt nicht dafür, dass Speicher für andere Threads in einer konsistenten Reihenfolge sichtbar werden. Daher unterstützt es kein schwieriges, aber ziemlich verbreitetes Codierungsmuster, das durch den Algorithmus von Dekker zum gegenseitigen Ausschluss veranschaulicht wird: Alle Threads setzen zuerst ein Flag, das angibt, dass sie eine Aktion ausführen möchten. Wenn ein Thread t dann feststellt, dass kein anderer Thread etwas tun möchte, kann er sicher fortfahren, in dem Wissen, dass es keine Beeinträchtigung gibt. Es kann kein anderer Thread fortgesetzt werden, da das Flag von t noch gesetzt ist. Dies schlägt fehl, wenn auf das Flag über die Abruf-/Freigabereihenfolge zugegriffen wird, da dadurch nicht verhindert wird, dass das Flag eines Threads später für andere sichtbar wird, nachdem sie fehlerhaft fortfahren. 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, ist es unter Umständen mit schwach geordneten Zugriffen möglich, es zu initialisieren und anschließend zu lesen. In C++ kann sie als atomic deklariert und mit memory_order_relaxed oder in Java aufgerufen werden. Sie könnte ohne volatile deklariert und ohne spezielle Maßnahmen aufgerufen werden. Hierfür müssen alle folgenden Holds erfüllt sein:

  • Am Wert des Felds selbst sollte ersichtlich sein, ob es bereits initialisiert wurde. Um auf das Feld zuzugreifen, sollte der Schnellpfad-Test- und -Rückgabewert das Feld nur einmal lesen. In Java ist Letzteres unerlässlich. Selbst wenn das Feld wie initialisiert getestet wird, kann ein zweiter Ladevorgang den früheren nicht initialisierten Wert lesen. In C++ ist die "Einmal lesen"-Regel lediglich eine gute Praxis.
  • Sowohl die Initialisierung als auch die nachfolgenden Ladevorgänge müssen atomar sein, da Teilaktualisierungen nicht sichtbar sein sollen. Bei Java sollte das Feld kein long oder double sein. Für C++ ist eine atomare Zuweisung erforderlich. Sie kann nicht direkt erstellt werden, da die Konstruktion eines atomic nicht atomar ist.
  • Wiederholte Initialisierungen müssen sicher sein, da mehrere Threads den nicht initialisierten Wert gleichzeitig lesen können. In C++ entspricht dies im Allgemeinen der Anforderung „einfach kopierbar“, die für alle atomaren Typen auferlegt wird. Typen mit verschachtelten Zeigern erfordern eine Zuordnung im Kopierkonstruktor und sind nicht einfach kopierbar. Für Java sind bestimmte Referenztypen akzeptabel:
  • 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 stellen die finalen Java-Feldregeln sicher, dass ein Leser, wenn er die Referenz sieht, auch die initialisierten finalen Felder sieht. C++ hat kein Analog zu diesen Regeln und Verweise auf eigene Objekte sind aus diesem Grund ebenfalls inakzeptabel (zusätzlich zur Verletzung der Anforderungen an das Kopieren von Daten).

Schlussnotizen

Dieses Dokument dient zwar nicht nur dem Kratzen an der Oberfläche, aber es reicht nicht mehr aus als ein flacher Ausstecher. Dies ist ein sehr weit gefasstes und tiefgehendes Thema. Hier sind einige Bereiche, die noch genauer untersucht werden sollten:

  • Die tatsächlichen Java- und C++-Speichermodelle werden in einer happens-before-Beziehung ausgedrückt, die angibt, wann zwei Aktionen garantiert in einer bestimmten Reihenfolge auftreten. Als wir eine Datenvermittlung definiert haben, haben wir informell über zwei Speicherzugriffe gesprochen, die „gleichzeitig“ stattfinden. Offiziell ist dies so definiert, dass keiner der beiden vor dem anderen geschieht. Es ist sinnvoll, die tatsächlichen Definitionen von happens-before und synchronizes-with im Java- oder C++-Speichermodell zu erlernen. Obwohl die intuitive Vorstellung von "gleichzeitig" im Allgemeinen gut genug ist, sind diese Definitionen informativ, insbesondere wenn Sie die Verwendung schwach geordneter atomarer Operationen in C++ in Betracht ziehen. (In der aktuellen Java-Spezifikation wird lazySet() nur sehr informell definiert.)
  • Untersuchen Sie, was Compiler bei der Neuanordnung von Code tun dürfen und was nicht. Die Spezifikation JSR-133 enthält einige gute Beispiele für Rechtsänderungen, die zu unerwarteten Ergebnissen führen.
  • Hier erfahren Sie, wie Sie unveränderliche Klassen in Java und C++ schreiben. (Sie brauchen mehr als nur „Nach der Konstruktion nichts ändern“.)
  • Internalisieren Sie die Empfehlungen im Abschnitt Nebenläufigkeit von Effektive Java, 2. Edition. So sollten Sie beispielsweise vermeiden, Methoden aufzurufen, die in einem synchronisierten Block überschrieben werden sollen.
  • Sehen Sie sich die Informationen zu den verfügbaren APIs java.util.concurrent und java.util.concurrent.atomic an. Erwägen Sie die Verwendung von Anmerkungen zur Gleichzeitigkeit wie @ThreadSafe und @GuardedBy (aus net.jcip.annotations).

Der Abschnitt Weitere Informationen im Anhang enthält Links zu Dokumenten und Websites, in denen diese Themen besser behandelt werden.

Anhang

Synchronisierungsspeicher implementieren

(Die meisten Programmierer werden dies nicht implementieren, aber die Diskussion ist sehr 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, der am selben Ort lädt, sichtbar wird. Daher wird ein grundlegender Begriff der „Atomarität“ kostenlos zur Verfügung gestellt.

Wie bereits erwähnt, reicht dies nicht aus. Um sequenzielle 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. Letzteres wird auf Android-unterstützter Hardware automatisch angewendet, sofern wir überlegte Entscheidungen bei der Durchsetzung von Ersteres treffen. Deshalb ignorieren wir sie hier weitgehend.

Die Reihenfolge der Speichervorgänge wird beibehalten, indem sowohl die Neuanordnung durch den Compiler als auch eine Neuanordnung durch die Hardware verhindert wird. Wir konzentrieren uns hier auf Letzteres.

Die Speicherreihenfolge in ARMv7, x86 und MIPS wird mit "Fence"-Anweisungen erzwungen, die in etwa verhindern, dass Anweisungen, die dem Zaun folgen, vor Anweisungen vor dem Fence angezeigt werden. (Diese Anweisungen werden auch als "Barriere"-Anweisungen bezeichnet, dies kann jedoch zu Verwechslungen mit Hindernissen im pthread_barrier-Stil führen, da diese weitaus mehr Möglichkeiten bieten.) Die genaue Bedeutung von Zaunanweisungen ist ein ziemlich kompliziertes Thema, in dem es darum geht, wie die Garantien, die durch mehrere verschiedene Arten von Zäunen bereitgestellt werden, interagieren und wie diese mit anderen Bestellgarantien kombiniert werden, die normalerweise von der Hardware gegeben werden. Dies ist eine allgemeine Übersicht, die wir einen Überblick über diese Details geben.

Die grundlegendste Art der Abfolgegarantie ist die von C++-memory_order_acquire und memory_order_release atomaren Vorgängen: Arbeitsspeichervorgänge, die einem Release-Speicher vorangehen, sollten nach einem übernommenen Ladevorgang sichtbar sein. In ARMv7 wird dies durch Folgendes erzwungen:

  • Vor der Ladenanweisung mit einer geeigneten Umzäunungsanweisung gehen. Dadurch wird verhindert, dass alle vorherigen Speicherzugriffe mit der Speicheranweisung neu angeordnet werden. Außerdem wird damit unnötigerweise eine Neuanordnung durch spätere Store-Anweisungen verhindert.
  • Folgen Sie der Ladeanweisung mit einer geeigneten Fence-Anweisung, um zu verhindern, dass die Last bei nachfolgenden Zugriffen neu angeordnet wird. (Und wieder eine unnötige Bestellung mit mindestens früheren Ladevorgängen bereitstellen.)

Diese beiden Werte reichen für die Bestellung/Veröffentlichung von C++-Versionen aus. Sie sind erforderlich, aber nicht ausreichend, für die sequenzielle Methode atomic mit Java-volatile oder C++.

Um zu sehen, was wir sonst noch benötigen, schauen wir uns das Fragment des Dekkers-Algorithmus an, den wir zuvor kurz erwähnt haben. flag1 und flag2 sind C++-atomic- oder Java-volatile-Variablen, die beide anfangs falsch sind.

Thread 1 Thread 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

Die sequenzielle Konsistenz impliziert, dass eine der Zuweisungen für flagn zuerst ausgeführt werden muss und für den Test im anderen Thread sichtbar ist. Daher werden diese Threads nie gleichzeitig „wichtige Dinge“ ausführen.

Durch das für die Reihenfolge des Abrufs und der Freigabe erforderliche Fencing werden jedoch nur Zäune am Anfang und Ende jedes Threads hinzugefügt, was hier nicht weiterhilft. Außerdem müssen die beiden nicht neu angeordnet werden, wenn auf einen volatile/atomic-Speicher ein volatile/atomic-Ladevorgang folgt. Dies wird normalerweise dadurch erzwungen, dass nicht direkt vor einem Speicher mit sequenzieller Konsistenz, sondern auch danach ein Zaun hinzugefügt wird. (Dies ist wieder viel stärker als erforderlich, da durch diesen Hinweis in der Regel alle früheren Speicherzugriffe in Bezug auf alle späteren Zugriffe angeordnet werden.)

Wir könnten den zusätzlichen Zaun stattdessen mit sequenziellen Ladevorgängen verknüpfen. Da Shops weniger verkehren, ist die von uns beschriebene Konvention gebräuchlicher und wird unter Android verwendet.

Wie wir in einem vorherigen Abschnitt gesehen haben, müssen wir zwischen den beiden Vorgängen eine Laden-/Ladebarriere platzieren. Der in der VM für einen flüchtigen Zugriff ausgeführte Code sieht in etwa so aus:

flüchtige Last Volatiler Speicher
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

Echte Maschinenarchitekturen bieten in der Regel mehrere Arten von Zäunen, die unterschiedliche Zugriffsarten erfordern und unterschiedliche Kosten verursachen können. Die Wahl zwischen diesen Optionen ist subtil und wird dadurch beeinflusst, dass die Speicher für andere Kerne in einer konsistenten Reihenfolge sichtbar gemacht werden und dass die Speicherreihenfolge durch die Kombination mehrerer Zäune korrekt zusammengesetzt wird. Weitere Informationen finden Sie auf der Seite der University of Cambridge Zuordnungen von Atomen zu tatsächlichen Prozessoren.

Bei einigen Architekturen, insbesondere bei x86, sind die Hindernisse „Übernahme“ und „Release“ nicht erforderlich, da die Hardware immer eine ausreichende Reihenfolge erzwingt. Daher wird auf x86 nur der letzte Zaun (3) generiert. Ebenso ist bei x86-Vorgängen bei atomaren Lese-Ändern-Schreib-Vorgängen implizit ein strikter Rahmen gegeben. Daher sind sie nie Zäune erforderlich. Bei ARMv7 sind alle oben beschriebenen Zäune erforderlich.

ARMv8 bietet LDAR- und STLR-Anweisungen, die die Anforderungen von flüchtigen oder sequentiell konsistenten Java-Ladevorgängen und -Speichern in C++ direkt erzwingen. Damit vermeiden Sie die oben erwähnten Einschränkungen bei der Neuanordnung. Der 64-Bit-Android-Code auf ARM verwendet diese. Wir haben uns hier auf die Platzierung von ARMv7-Zäunen konzentriert, da dieser mehr Informationen zu den tatsächlichen Anforderungen liefert.

Weitere Informationen

Webseiten und Dokumente, die mehr Tiefe oder Bandbreite bieten. Allgemeine nützliche Artikel finden Sie weiter oben in der Liste.

Konsistenzmodelle für gemeinsam genutzten Speicher: Anleitung
Das 1995 von Adve & Gharachorloo verfasste Dokument ist ein guter Ausgangspunkt, wenn Sie sich genauer mit Speicherkonsistenzmodellen befassen möchten.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Speicherbarrieren
Dieser kleine Artikel fasst die Probleme zusammen.
https://de.wikipedia.org/wiki/Memory_barrier
Threads – Grundlagen
Eine Einführung in die Multithread-Programmierung in C++ und Java von Hans Böhm. Besprechung von Datenrennen und grundlegenden Synchronisierungsmethoden.
http://www.hboehm.info/c++mm/threadsintro.html
Java-Parallelität in der Praxis
Das 2006 veröffentlichte Buch behandelt eine Vielzahl von Themen sehr ausführlich. Besonders empfehlenswert für alle, die Multithread-Code in Java schreiben.
http://www.javaconcurrencyinpractice.com
FAQs zu JSR-133 (Java-Speichermodell)
Eine sanfte Einführung in das Java-Arbeitsspeichermodell, einschließlich einer Erläuterung der Synchronisierung, flüchtigen Variablen und der Erstellung finaler Felder. (Etwas veraltet, vor allem, wenn es um andere Sprachen geht.)
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 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.Genauso
Die Dokumentation für das Paket java.util.concurrent. Unten auf der Seite finden Sie einen Abschnitt mit dem Titel „Eigenschaften der Speicherkonsistenz“, in dem die Garantien der verschiedenen Klassen erläutert werden.
java.util.concurrent – Paketzusammenfassung
Java-Theorie und -Praxis: Sichere Konstruktionstechniken in Java
Dieser Artikel befasst sich ausführlich mit den Gefahren der Maskierung von Verweisen bei der Objekterstellung und bietet Richtlinien für Thread-sichere Konstruktoren.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
Java-Theorie und -Praxis: Volatilität managen
Dieser Artikel 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, dass das überprüfte Schloss defekt ist
Bill Pugh erklärt ausführlich die verschiedenen Ursachen, wie die doppelte Verriegelung ohne volatile oder atomic funktioniert. Umfasst C/C++ und Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] Barrierefreiheitstests und Kochbuch
Eine Diskussion über Probleme mit ARM-SMP, beleuchtet mit kurzen ARM-Code-Snippets. Wenn Sie die Beispiele auf dieser Seite zu unspezifisch finden oder die formale Beschreibung der DMB-Anweisung lesen möchten, lesen Sie die folgenden Informationen. Hier finden Sie auch die Anleitungen für Speicherbarrieren bei ausführbarem Code. Dies ist möglicherweise hilfreich, wenn Sie Code spontan generieren. Beachten Sie, dass dies vor ARMv8 liegt, das auch zusätzliche Anweisungen zur Arbeitsspeicheranordnung unterstützt und zu einem etwas leistungsfähigeren Arbeitsspeichermodell verschoben wurde. Weitere Informationen finden Sie im ARM® Architecture Reference Handbuch ARMv8 für ARMv8-A-Architekturprofil.
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
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“)
Standardentwurf für Funktionen von atomaren C++-Vorgängen Diese Version entspricht 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/200)
ISO/IEC JTC1 SC22 WG14 (C-Standards) 9899 (Programmiersprache C), Kapitel 7.16 („Atomics <stdatomic.h>“)
Entwurf einer Norm für ISO/IEC 9899-201x atomare C-Funktionen. Details finden Sie in den Mängeln in den nachfolgenden Mängeln.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
C/C++11-Zuordnungen zu Prozessoren (Universität von Cambridge)
Jaroslav Sevcik und Peter Sewells Übersetzungssammlung von C++-Atomelementen in verschiedene gängige Prozessoranweisungssätze.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
Dekker-Algorithmus
Die „erste bekannte richtige Lösung des gegenseitigen Ausschlussproblems bei gleichzeitiger Programmierung“. Der Wikipedia-Artikel enthält den vollständigen Algorithmus und eine Diskussion darüber, wie er aktualisiert werden muss, damit er mit modernen optimierenden Compilern und SMP-Hardware funktioniert.
https://en.wikipedia.org/wiki/Dekker-Algorithmus
Kommentare zu ARM- im Vergleich zur Alpha- und Adressabhängigkeiten
Eine E-Mail von der arm-kernel-Mailingliste von Catalin Marinas. Enthält eine Zusammenfassung der Adress- und Steuerabhängigkeiten.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
Was jeder Programmierer über das Gedächtnis wissen sollte
Ein sehr langer und ausführlicher Artikel über verschiedene Speichertypen, insbesondere CPU-Caches, von Ulrich Drepper.
http://www.akkadia.org/drepper/cpumemory.pdf
Gründe für das schwach konsistente ARM-Arbeitsspeichermodell
Dieser Artikel wurde von Chong & Ishtiaq von ARM, Ltd. verfasst. Er 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-Rezepte für Compiler Writer
Doug Lea schrieb dieses Dokument als Ergänzung zur Dokumentation zu JSR-133 (Java Memory Model). Sie enthält die anfänglichen Implementierungsrichtlinien für das Java-Speichermodell, das von vielen Compiler-Autoren verwendet wurde. Es wird immer noch weit zitiert und bietet wahrscheinlich einen Einblick. Leider sind die hier besprochenen vier Zaunvarianten keine gute Ergänzung für von Android unterstützte Architekturen und die oben genannten C++11-Zuordnungen sind jetzt eine bessere Quelle für präzise Rezepte, selbst für Java.
http://g.oswego.edu/dl/jmm/cookbook.html
x86-TSO: Ein anspruchsvolles und nutzungsfreundliches Programmer-Modell für x86-Multiprozessoren
Eine genaue Beschreibung des x86-Arbeitsspeichermodells. Genaue Beschreibungen des ARM-Arbeitsspeichermodells sind leider wesentlich komplizierter.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf