SMP-Primer für Android

Die Plattformversionen Android 3.0 und höher sind für die Unterstützung Multiprozessor-Architekturen. In diesem Dokument werden Probleme beschrieben, die beim Schreiben von mehrstufigen Code für symmetrische Mehrprozessorsysteme in C, C++ und der Programmiersprache Java auftreten können (im Folgenden aus Gründen der Übersichtlichkeit einfach als „Java“ bezeichnet). Sie ist als Einführung für Android-App-Entwickler gedacht und nicht als umfassender Artikel zum Thema.

Einführung

SMP ist ein Akronym für „Symmetric Multi-Processor“. Es beschreibt ein Design, bei dem zwei oder mehr identische CPU-Kerne den Zugriff auf den Hauptspeicher teilen. Bis Vor einigen Jahren waren alle Android-Geräte UP (Uni-Prozessor).

Die meisten, wenn nicht alle Android-Geräte hatten schon immer mehrere CPUs. Bisher wurde jedoch nur eine davon zum Ausführen von Anwendungen verwendet, während andere verschiedene Hardwarekomponenten des Geräts verwalteten (z. B. das Radio). Die CPUs hatten möglicherweise unterschiedliche Architekturen und die darauf ausgeführten Programme konnten nicht den Hauptspeicher verwenden, um miteinander zu kommunizieren.

Die meisten heute verkauften Android-Geräte basieren auf SMP-Designs. was die Dinge für Softwareentwickler etwas komplizierter macht. Race-Bedingungen in einem Multithread-Programm keine sichtbaren Probleme auf einem aber es können regelmäßig Fehler auftreten, wenn zwei oder mehr Threads gleichzeitig auf verschiedenen Kernen ausgeführt werden. Darüber hinaus kann Code mehr oder weniger anfällig für Fehler sein, wenn er auf unterschiedlichen Prozessorarchitekturen oder sogar auf verschiedenen Implementierungen desselben Architektur. Code, der eingehend auf x86 getestet wurde, kann auf ARM fehlerhaft funktionieren. Code kann fehlschlagen, wenn er mit einem moderneren Compiler neu kompiliert wird.

Im weiteren Verlauf dieses Dokuments wird der Grund dafür erläutert und Sie erfahren, was Sie tun müssen. um sicherzustellen, dass sich der Code korrekt verhält.

Speicherkonsistenzmodelle: Warum sich soziale Netzwerke etwas unterscheiden

Dies ist eine schnelle, glänzende Übersicht über ein komplexes Thema. In einigen Regionen unvollständig sein, aber keines davon sollte irreführend oder falsch sein. Während Sie im nächsten Abschnitt angezeigt wird, sind die Details hier normalerweise nicht wichtig.

Unter Weitere Informationen am Ende dieses Dokuments finden Sie weitere Informationen zu und Hinweise auf eine gründlichere Behandlung der Probande.

Speicherkonsistenzmodelle oder oft einfach nur „Speichermodelle“ die Programmiersprache oder Hardwarearchitektur garantiert, über Arbeitsspeicherzugriffe. Beispiel: Wenn Sie einen Wert in Adresse A und dann einen Wert in Adresse B schreiben, dass jeder CPU-Kern diese Schreibvorgänge sieht, Reihenfolge.

Das Modell, mit dem die meisten Programmierer vertraut sind, ist sequentiell. Consistency (Konsistenz), der so beschrieben wird: (Adve & Gharachorloo):

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

Nehmen wir vorübergehend an, wir haben einen sehr einfachen Compiler oder Interpreter. bringt keine Überraschungen mit sich: Zuweisungen im Quellcode an, um Anweisungen genau im und eine Anweisung pro Zugriff. Wir gehen auch für dass jeder Thread auf seinem eigenen Prozessor ausgeführt wird.

Wenn Sie sich Code ansehen und sehen, dass er einige Lese- und Schreibvorgänge sequentiell konsistenter CPU-Architektur. Sie wissen, dass der Code die Lese- und Schreibvorgänge in der erwarteten Reihenfolge ab. Es ist möglich, dass das Die CPU ordnet Anweisungen neu und verzögert Lese- und Schreibvorgänge, aber es gibt Code, der auf dem Gerät ausgeführt wird, kann nicht erkennen, dass die CPU etwas tut als die direkte Ausführung von Anweisungen. (Wir ignorieren dem Arbeitsspeicher zugeordneter Gerätetreiber-E/A)

Zur Veranschaulichung dieser Punkte ist es hilfreich, kleine Code-Snippets zu betrachten, die allgemein als Liefertests bezeichnet werden.

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

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

In diesem und allen zukünftigen Lackus-Beispielen werden die Speicherstandorte durch Großbuchstaben (A, B, C) und CPU-Register beginnen mit "reg". Gesamter Arbeitsspeicher ist anfänglich auf null gesetzt ist. Die Anweisungen werden von oben nach unten ausgeführt. Hier, Thread 1 den Wert 3 an Ort A und dann den Wert 5 an Ort B. Thread 2 lädt den Wert aus Standort B in reg0 und lädt dann den Wert aus aus Standort A in reg1 ein. (Beachten Sie, dass wir in einer Reihenfolge schreiben und eine andere.)

Es wird davon ausgegangen, dass Thread 1 und Thread 2 auf verschiedenen CPU-Kernen ausgeführt werden. Ich immer von dieser Annahme aus, Multithread-Code.

Die sequenzielle Konsistenz garantiert, dass die Register nach Abschluss der Ausführung beider Threads in einem der folgenden Status sind:

Register Bundesstaaten
reg0=5, reg1=3 möglich (Thread 1 wurde zuerst angezeigt)
reg0=0, reg1=0 möglich (Thread 2 wurde zuerst angezeigt)
reg0=0, reg1=3 möglich (gleichzeitige Ausführung)
reg0=5, reg1=0 nie

Damit wir B = 5 sehen, bevor wir den Speicher für A sehen, müssten entweder die Lese- oder die Schreibvorgänge nicht in der richtigen Reihenfolge erfolgen. Auf einer und sequenziellen Konsistenz wird das nicht möglich.

Uni-Prozessoren, einschließlich x86 und ARM, sind normalerweise sequenziell konsistent. Threads scheinen verschränkt ausgeführt zu werden, während der Kernel des Betriebssystems wechselt. zwischen ihnen aufbauen. Die meisten SMP-Systeme, einschließlich x86 und ARM, nicht sequentiell konsistent sind. Zum Beispiel ist es üblich, um Speicher zwischenzuspeichern, den Arbeitsspeicher nicht sofort erreichen und für andere Kerne sichtbar werden.

Die Details können erheblich variieren. Beispielsweise wird bei x86 zwar nicht sequentiell konsistent gearbeitet, aber es wird trotzdem garantiert, dass reg0 = 5 und reg1 = 0 unmöglich ist. Geschäfte werden zwischengespeichert, aber ihre Reihenfolge bleibt erhalten. ARM hingegen nicht. Die Reihenfolge der gepufferten Speicher ist nicht verwaltet und die Geschäfte erreichen möglicherweise nicht alle anderen Kerne gleichzeitig. Diese Unterschiede sind für Assembler-Programmierer wichtig. Wie wir weiter unten sehen werden, können C-, C++- oder Java-Programmierer jedoch und sollte so programmiert werden, dass solche architektonischen Unterschiede verborgen sind.

Bisher sind wir unrealistisch davon ausgegangen, dass nur die Hardware Anweisungen zur Neuanordnung. Tatsächlich ordnet der Compiler die Anweisungen auch so an, die Leistung zu verbessern. In unserem Beispiel könnte der Compiler später entscheiden, Der Code in Thread 2 benötigte den Wert von „reg1“ vor „reg0“ und lädt reg1 an erster Stelle. Oder es ist bereits Code für A geladen worden, und der Compiler diesen Wert wiederverwenden, anstatt A erneut zu laden. In beiden Fällen können die Ladungen in reg0 und reg1 neu angeordnet werden.

Das Neuanordnen von Zugriffen auf verschiedene Speicherorte, entweder in der Hardware oder im Compiler, ist zulässig, da dies die Ausführung eines einzelnen Threads nicht beeinträchtigt und die Leistung erheblich verbessern kann. Wie wir sehen werden, können wir auch verhindern, dass die Ergebnisse von Multithread-Programmen beeinträchtigt werden.

Da Compiler Speicherzugriffe auch neu anordnen können, ist dieses Problem nicht neu für soziale Netzwerke. Selbst auf einem Uniprozessor könnte ein Compiler die Ladevorgänge „reg0“ und „reg1“ in unserem Beispiel. Thread 1 könnte dann Anweisungen neu angeordnet. Wenn unser Compiler die Anweisungen jedoch nicht neu anordnet, wird dieses Problem möglicherweise nie auftreten. Auf den meisten ARM SMPs, auch ohne Compiler erfolgt die Neuanordnung wahrscheinlich nach einer sehr großen erfolgreicher Ausführungen. Sofern Sie nicht in Assembler programmieren, erhöhen SMPs in der Regel nur die Wahrscheinlichkeit, dass Sie Probleme sehen, die schon immer da waren.

Programmieren ohne Datenrennen

Glücklicherweise gibt es in der Regel eine einfache Methode, diese Details. Wenn ihr euch an einige einfache Regeln haltet, um den gesamten vorherigen Abschnitt außer der "sequenziellen Konsistenz" zu vergessen, Die anderen Komplikationen können jedoch sichtbar werden, wenn Sie versehentlich gegen diese Regeln verstoßen.

Moderne Programmiersprachen fördern einen sogenannten „datenabgleichsfreien“ Programmierstil. Solange Sie versprechen, keine „Datenrennen“ einzuführen, und vermeiden Sie einige Konstrukte, die dem Compiler etwas anderes mitteilen, und Hardware versprechen, sequentiell konsistente Ergebnisse zu liefern. Das ist nicht dass sie den Arbeitsspeicherzugriff nicht neu anordnen müssen. Wenn Sie also Wenn Sie die Regeln befolgen, können Sie nicht erkennen, neu angeordnet. Das ist in etwa so, als würde ich Ihnen sagen, dass Wurst ein leckeres und appetitanregendes Lebensmittel ist, solange Sie versprechen, die Wurstfabrik nicht zu besuchen. Data Races lassen die hässliche Wahrheit über das Gedächtnis enthüllen Reihenfolge ändern.

Was ist ein „Datenrennen“?

Ein Datenrennen findet statt, wenn mindestens zwei Threads gleichzeitig auf dieselben gewöhnlichen Daten verarbeitet und sie von mindestens einem von ihnen geändert werden. Von „ordinary“ Daten“ meinen wir etwas, das kein Synchronisierungsobjekt ist, die für die Thread-Kommunikation vorgesehen ist. Mutexe, Bedingungsvariablen, Java flüchtige oder atomare C++-Objekte sind keine gewöhnlichen Daten und ihr Zugriff dürfen am Rennen teilnehmen. Tatsächlich werden sie verwendet, um Datenwettkämpfe auf anderen Objekte.

Um zu bestimmen, ob zwei Threads gleichzeitig auf denselben können wir die obige Diskussion zur Neuanordnung des Arbeitsspeichers ignorieren. sequenzielle Konsistenz. Im folgenden Programm kommt es nicht zu einem Datenübergriff, wenn A und B gewöhnliche boolesche Variablen sind, die anfangs den Wert „falsch“ haben:

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

Da die Vorgänge nicht neu angeordnet werden, werden beide Bedingungen als falsch ausgewertet und keine der Variablen wird aktualisiert. Daher kann es keinen Wettlauf mit den Daten geben. Sie müssen sich nicht überlegen, was passieren könnte, wenn die Ladung von A und das Speichern in B in Thread 1 irgendwie neu angeordnet würden. Der Compiler ist nicht berechtigt, Thread neu zu ordnen 1, indem es in "B = true; if (!A) B = false" umgeschrieben wird. Das wäre wie Wurst mitten in der Stadt bei Tageslicht Würstchen zu machen.

Data Races sind offiziell auf Basis integrierter Typen wie Ganzzahlen und Referenzen oder Verweise. Zuweisen zu einem int bei gleichzeitiger in einem anderen Thread zu lesen, ist eindeutig ein Datenwettkampf. Aber sowohl die C++- Standardbibliothek und Die Java-Sammlungsbibliotheken sind so geschrieben, dass Sie auch die Bedeutung auf Bibliotheksebene. Sie versprechen, keine Wettkämpfe im Datenbereich einzuführen. außer es gibt gleichzeitige Zugriffe auf denselben Container, wodurch er aktualisiert wird. Aktualisierung eines set<T> in einem Thread während das gleichzeitige Lesen in einer anderen Bibliothek ermöglicht, und kann daher informell als „Datenwettlauf auf Bibliotheksebene“ betrachtet werden. Umgekehrt wird ein set<T> in einem Thread beim Lesen nicht zu einem Wettlauf bei den Daten führen, verspricht, in diesem Fall kein (Low-Level-)Datenrennen einzuführen.

Normalerweise können durch gleichzeitigen Zugriff auf verschiedene Felder in einer Datenstruktur keine Datenkonflikte auftreten. Es gibt jedoch eine wichtige Ausnahme zusammenhängende Sequenzen von Bitfeldern in C oder C++ einen einzelnen "Speicherort" erstellen. Auf ein beliebiges Bitfeld in einer solchen Sequenz zugreifen wird als Zugriff auf alle angesehen, um zu bestimmen, das Vorhandensein eines Datenrennens besteht. Dies spiegelt die Unfähigkeit gängiger Hardware wider. um einzelne Bits zu aktualisieren, ohne auch benachbarte Bits zu lesen und neu zu schreiben. Java-Programmierer haben keine entsprechenden Bedenken.

Datenrennen vermeiden

Moderne Programmiersprachen bieten eine Reihe von Synchronisierungsfunktionen, um Datenwettkämpfe zu vermeiden. Die grundlegendsten Tools sind:

Schlösser oder Stummer
Mutexe (C++11 std::mutex oder pthread_mutex_t) oder synchronized-Blöcke in Java kann verwendet werden, um bestimmte nicht gleichzeitig mit anderen Codeabschnitten ausgeführt werden, dieselben Daten. Diese und ähnliche Einrichtungen werden allgemein erwähnt. als „Schlösser“. Sie erhalten dauerhaft eine bestimmte Sperre, bevor auf eine freigegebene Sperre zugegriffen wird. Datenstruktur und anschließende Freigabe verhindert Datenwettkämpfe beim Zugriff auf der Datenstruktur. Außerdem wird sichergestellt, dass Aktualisierungen und Zugriffe unteilbar sind, d.h. kann eine andere Aktualisierung der Datenstruktur in der Mitte ausgeführt werden. Das ist zuversichtlich das bei Weitem gängigste Tool zur Vermeidung von Datenwettkämpfen. Die Verwendung von Java synchronized Blöcke oder C++ lock_guard oder unique_lock dafür sorgen, dass die Verriegelungen im Ausnahmeereignis.
Flüchtige/atomare Variablen
Java bietet volatile-Felder, die den gleichzeitigen Zugriff unterstützen ohne Rastmöglichkeiten einzuführen. Unterstützung von C und C++ seit 2011 atomic-Variablen und -Felder mit ähnlicher Semantik. Dies sind in der Regel schwieriger zu verwenden als Schlösser, da sie nur dafür sorgen, einzelne Zugriffe auf eine einzelne Variable sehr klein sind. (In C++ ist dies normalerweise geht auf einfache Read-Change-Write-Vorgänge wie Inkrementierungen. Java erfordert hierfür spezielle Methodenaufrufe.) Im Gegensatz zu Sperren können volatile- oder atomic-Variablen nicht direkt verwendet werden, um zu verhindern, dass andere Threads in längere Codesequenzen eingreifen.

Beachten Sie, dass volatile sehr unterschiedliche Bedeutung in C++ und Java. In C++ verhindert volatile nicht, dass Daten obwohl ältere Codes häufig als Problemumgehung verwendet werden, um das Fehlen von atomic-Objekte. Dies wird nicht mehr empfohlen. Verwenden Sie in C++ atomic<T> für Variablen, auf die mehrere Threads gleichzeitig zugreifen können. C++ volatile ist für Geräteregister und ähnliches gedacht.

C/C++ atomic-Variablen oder Java volatile-Variablen kann verwendet werden, um Datenwettkämpfe bei anderen Variablen zu verhindern. Wenn flag gleich deklarierten Typ atomic<bool> oder atomic_bool(C/C++) oder volatile boolean (Java), und anfangs auf „false“ gesetzt ist, ist das folgende Snippet datenfrei:

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

Da Thread 2 wartet, bis flag festgelegt ist, wird der Zugriff auf A in Thread 2 muss nach dem und nicht gleichzeitig mit dem Zuweisung an A in Thread 1. Daher gibt es keinen Wettlauf A Das Rennen um flag zählt nicht als Datenwettkampf, da flüchtige/atomare Zugriffe keine „normalen Speicherzugriffe“ sind.

Die Implementierung ist erforderlich, um die Neuanordnung von Arbeitsspeicher zu verhindern oder zu verbergen damit sich Code wie im vorherigen Lackmus-Test wie erwartet verhält. Dadurch wird normalerweise auf flüchtige/atomare Speicher zugegriffen erheblich teurer als ein gewöhnlicher Zugriff.

Obwohl das obige Beispiel ohne Datenrennen ist, wird es mit Object.wait() in Java oder Bedingungsvariablen in C/C++ normalerweise eine bessere Lösung zu bieten, bei der nicht in einer Schleife gewartet werden muss, die den Akku stark belasten.

Wann wird die Speicherneuordnung sichtbar?

Eine datenrennenfreie Programmierung erspart uns normalerweise bei der Neuanordnung des Arbeitsspeicherzugriffs. Es gibt jedoch mehrere Fälle, in denen welche Neuordnung sichtbar wird:
  1. Wenn bei Ihrem Programm ein Fehler auftritt, der zu einem unbeabsichtigten Wettlauf der Daten führt, Compiler- und Hardwaretransformationen sichtbar werden können Ihres Programms überraschend sein könnte. Wenn wir zum Beispiel vergessen haben, flag im vorherigen Beispiel flüchtig ist, sieht Thread 2 möglicherweise eine A nicht initialisiert. Oder der Compiler könnte entscheiden, sich während der Schleife von Thread 2 möglicherweise ändern und das Programm
    Thread 1 Thread 2
    A = ...
      flag = true
    reg0 = Flag; während (!reg0) {}
    ... = A
    Bei der Fehlerbehebung sehen Sie, dass die Schleife trotz die Tatsache, dass flag wahr ist.
  2. C++ bietet Möglichkeiten zum expliziten Entspannen sequenzielle Konsistenz, auch wenn es keine Rennen gibt. Atomoperationen kann explizite memory_order_...-Argumente annehmen. In ähnlicher Weise Das Paket java.util.concurrent.atomic bietet eine stärker eingeschränkte ähnliche Einrichtungen, insbesondere lazySet(). und Java Programmierer nutzen gelegentlich vorsätzliche Data Races, um einen ähnlichen Effekt zu erzielen. All dies sorgt für Leistungsverbesserungen Programmierkomplexität. Wir besprechen sie nur kurz. unten.
  3. C- und C++-Code wird teilweise in einem älteren Stil geschrieben, der nicht ganz entspricht den aktuellen Sprachstandards, in denen volatile statt atomic-Variablen verwendet werden, und die Speicherreihenfolge durch Einfügen von so genannten Zäunen oder Hindernisse. Dies erfordert eine explizite Begründung für die Neusortierung des Zugriffs und ein Verständnis der Hardwarespeichermodelle. Ein solcher Programmierstil wird noch im Linux-Kernel verwendet. Sie sollte nicht in neuen Android-Anwendungen verwendet werden und wird hier auch nicht weiter behandelt.

Übung

Das Beheben von Problemen mit der Speicherkonsistenz kann sehr schwierig sein. Wenn eine fehlende Deklaration von „Sperren“, „atomic“ oder „volatile“ um veraltete Daten zu lesen, können Sie Speicher-Dumps mit einem Debugger untersuchen. Wenn Sie wissen, eine Debugger-Abfrage ausführen, haben die CPU-Kerne möglicherweise alle und der Speicherinhalt und die CPU-Register befinden sich einen „Unmöglichen“ Zustand haben.

Was ist in C nicht zu tun?

Hier präsentieren wir einige Beispiele für falschen Code sowie einfache Möglichkeiten, zu beheben. Vorher müssen wir jedoch über die Verwendung einer einfachen Sprache sprechen. .

C/C++ und „flüchtig“

C- und C++-volatile-Deklarationen sind ein sehr spezielles Tool. Sie verhindern, dass der Compiler flüchtig neu anordnen oder entfernen kann. Zugriffe. Dies kann hilfreich sein, wenn Code auf Hardware-Registerkarten zugreift, Arbeitsspeicher gespeichert, der mehreren Speicherorten zugeordnet ist, oder in Verbindung mit setjmp Aber C und C++ volatile, im Gegensatz zu Java volatile ist nicht für die Thread-Kommunikation vorgesehen.

Zugriff in C und C++ auf volatile Daten können neu angeordnet werden, indem auf nichtflüchtige Daten zugegriffen wird. Atomaritätsgarantien. Daher kann volatile nicht zum Teilen von Daten zwischen Threads in portablen Code, sogar auf einem Uniprozessor. C volatile verhindert in der Regel nicht die Neusortierung des Zugriffs durch die Hardware. Daher ist es in SMP-Umgebungen mit mehreren Threads an sich noch weniger nützlich. Aus diesem Grund unterstützen C11 und C++11 atomic-Objekte. Verwenden Sie stattdessen diese.

Ein großer Teil älterer C- und C++-Codes missbraucht volatile immer noch für Threads. Kommunikation. Dies funktioniert oft korrekt bei Daten, in einem Maschinenregister eingetragen, wenn sie mit expliziten Zäunen oder in denen die Speicherreihenfolge keine Rolle spielt. Es kann jedoch nicht garantiert werden, dass es mit zukünftigen Compilern korrekt funktioniert.

Beispiele

In den meisten Fällen ist ein Schloss besser geeignet (z. B. pthread_mutex_t oder C++11 std::mutex) anstelle eines atomaren Operation, aber wir verwenden Letzteres, um zu veranschaulichen, die in der Praxis verwendet werden.

MyThing* gGlobalThing = NULL;  // Wrong!  See below.
void initGlobalThing()    // runs in Thread 1
{
    MyStruct* thing = malloc(sizeof(*thing));
    memset(thing, 0, sizeof(*thing));
    thing->x = 5;
    thing->y = 10;
    /* initialization complete, publish */
    gGlobalThing = thing;
}
void useGlobalThing()    // runs in Thread 2
{
    if (gGlobalThing != NULL) {
        int i = gGlobalThing->x;    // could be 5, 0, or uninitialized data
        ...
    }
}

Die Idee dahinter ist, dass wir eine Struktur zuordnen, ihre Felder initialisieren und Am Ende "veröffentlichen" wir sie, indem wir sie in einer globalen Variablen speichern. Ab diesem Zeitpunkt kann jeder andere Thread sie sehen, aber das ist in Ordnung, da er vollständig initialisiert ist. oder?

Das Problem ist, dass der Speicher für gGlobalThing beobachtet werden konnte bevor die Felder initialisiert werden. Dies liegt in der Regel daran, dass entweder der Compiler oder der Der Verarbeiter hat die Geschäfte nach gGlobalThing und thing->x. Ein anderer Thread, der aus thing->x liest, könnte 5, 0 oder sogar nicht initialisierte Daten.

Das Kernproblem ist ein Wettlauf auf gGlobalThing. Wenn Thread 1 initGlobalThing() aufruft, während Thread 2 useGlobalThing() aufruft, kann gGlobalThing gelesen werden, während es geschrieben wird.

Sie können das Problem beheben, indem Sie gGlobalThing als atomar. In C++11:

atomic<MyThing*> gGlobalThing(NULL);

So wird sichergestellt, dass die Schreibvorgänge für andere Threads in der richtigen Reihenfolge sichtbar werden. Es garantiert auch, andere Fehler zu vermeiden, Modi, die normalerweise zulässig sind, aber in der realen Welt unwahrscheinlich sind Android-Hardware So wird beispielsweise sichergestellt, dass wir Zeiger gGlobalThing, der nur teilweise geschrieben wurde.

Was in Java nicht zu tun ist

Da wir einige relevante Java-Sprachfunktionen noch nicht besprochen haben, einen kurzen Blick darauf.

Aus technischer Sicht setzt Java voraus, dass Code nicht frei von Datenrennen ist. Und das war's auch schon eine kleine Menge sehr sorgfältig geschriebenem Java-Code, der auch bei Datenwettkämpfen. Das Schreiben solcher Codes ist jedoch extrem und werden im Folgenden kurz erläutert. Um alles im Blick zu behalten Schlimmer noch, die Experten, die die Bedeutung eines solchen Codes angegeben haben, die Spezifikation korrekt ist. (Die Spezifikation ist in Ordnung für Code.)

Vorerst werden wir uns an das Modell ohne Datenrennen halten, für das Java im Wesentlichen dieselben Garantien wie C und C++. Auch hier bietet die Sprache einige Primitive lockern, die die sequenzielle Konsistenz explizit lockern, insbesondere die lazySet()- und weakCompareAndSet()-Anrufe in java.util.concurrent.atomic. Wie bei C und C++ werden diese vorerst ignoriert.

Javas „synchronisiert“ und „volatil“, Keywords

Das Schlüsselwort „synchronized“ (synchronisiert) stellt die in die Java-Sprache integrierte Sperrfunktion Mechanismus zur Verfügung. Jedem Objekt ist ein „Monitor“ zugeordnet, über den sich gegenseitig ausschließende Zugriffe. Wenn zwei Threads versuchen, sich zu „synchronisieren“ am Objekt erstellen, wartet eines von ihnen, bis das andere abgeschlossen ist.

Wie bereits erwähnt, ist volatile T von Java das Analog zu atomic<T> von C++11 Gleichzeitige Zugriffe auf volatile-Felder sind zulässig und führen nicht zu einem Wettlauf der Daten. lazySet() et al. werden ignoriert. ist es Aufgabe der Java-VM, dass das Ergebnis weiterhin sequentiell konsistent ist.

Insbesondere, wenn Thread 1 in ein volatile-Feld schreibt und Thread 2 anschließend aus demselben Feld liest und den neu geschriebenen Wert sieht, sieht Thread 2 garantiert auch alle Schreibvorgänge, die zuvor von Thread 1 ausgeführt wurden. In Bezug auf den Gedächtniseffekt eine flüchtige Version ist analog zu einer Monitor-Version, und Daten von einer flüchtigen Quelle ist wie eine Monitor-Akquisition.

Es gibt einen nennenswerten Unterschied zur atomic in C++: Wenn wir volatile int x; schreiben in Java, dann ist x++ gleich x = x + 1. sie führt eine atomare Last aus, inkrementiert das Ergebnis und führt dann speichern. Im Gegensatz zu C++ ist das Inkrement als Ganzes nicht atomar. Atomar-Inkrement-Operationen werden stattdessen java.util.concurrent.atomic.

Beispiele

Hier ist eine einfache, falsche Implementierung eines monotonen Zählers: (Java Theorie und Praxis: Bewältigung von Volatilität).

class Counter {
    private int mValue;
    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

Angenommen, get() und incr() werden von mehreren Wir möchten, dass jeder Thread die aktuelle Anzahl sieht, get() wird aufgerufen. Das auffälligste Problem ist, mValue++ besteht aus drei Operationen:

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

Wenn zwei Threads gleichzeitig in incr() ausgeführt werden, geht möglicherweise eines der Updates verloren. Um das Inkrement atomar zu machen, müssen wir incr() „synchronisiert“.

Vor allem bei sozialen Netzwerken funktioniert sie jedoch immer noch nicht. Es gibt immer noch einen Wettlauf mit den Daten, da get() gleichzeitig auf mValue zugreifen kann mit incr() Unter Java-Regeln kann der get()-Aufruf in Bezug auf den anderen Code neu angeordnet werden. Wenn wir zum Beispiel zwei Zähler in einer Zeile erscheinen, können die Ergebnisse inkonsistent erscheinen. weil wir die get()-Aufrufe entweder durch die Hardware oder Compiler. Wir können das Problem beheben, indem wir get() als synchronisiert. Nach dieser Änderung ist der Code natürlich korrekt.

Leider haben wir die Möglichkeit von Sperrkonflikten eingeführt, was die Leistung beeinträchtigen kann. Anstatt get() als synchronisiert zu deklarieren, könnten wir mValue mit „volatile“ deklarieren. Hinweis: Für incr() muss weiterhin synchronize verwendet werden, da mValue++ sonst kein einzelner atomarer Vorgang ist. Dies vermeidet außerdem sämtliche Datenläufe, sodass die sequenzielle Konsistenz erhalten bleibt. incr() ist etwas langsamer, da bei diesem Vorgang sowohl Monitorein- und -ausstiege betroffen sind. und den Aufwand für einen flüchtigen Speicher. get() ist schneller. Das heißt, selbst wenn kein Konflikt besteht, ist dies wenn die Zahl der Schreibvorgänge größer als die Zahl der Schreibvorgänge ist. (Unter AtomicInteger erfahren Sie, wie Sie um den synchronisierten Block zu entfernen.)

Hier ist ein weiteres Beispiel, das in seiner Form den vorherigen C-Beispielen ähnelt:

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;
    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }
    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

Dies hat das gleiche Problem wie der C-Code, nämlich dass es ein Datenwettlauf am sGoodies. Daher ist die Zuweisung sGoodies = goods wird möglicherweise vor der Initialisierung des Felder in goods. Wenn Sie sGoodies mit dem Keyword volatile deklarieren, wird die sequenzielle Konsistenz wiederhergestellt und alles funktioniert wie erwartet.

Beachten Sie, dass nur der sGoodies-Verweis selbst flüchtig ist. Die auf die darin enthaltenen Felder nicht. Sobald sGoodies gleich volatile und die Speicherreihenfolge ordnungsgemäß beibehalten wird, werden die Felder kann nicht gleichzeitig aufgerufen werden. Die Anweisung z = sGoodies.x führt eine volatile Ladung von MyClass.sGoodies gefolgt von einer nichtflüchtigen Ladung von sGoodies.x aus. Wenn Sie einen lokalen Verweis-MyGoodies localGoods = sGoodies enthält, führt ein nachfolgendes z = localGoods.x keine flüchtigen Ladevorgänge aus.

Eine gängigere Redewendung in der Java-Programmierung ist die berüchtigte „überprüfte Sperren“:

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

Die Idee ist, dass wir eine einzelne Instanz einer Helper haben möchten, Objekt, das mit einer Instanz von MyClass verknüpft ist. Wir dürfen nur Also erstellen wir sie und geben sie über eine dedizierte getHelper() zurück. . Um einen Wettlauf zu vermeiden, in dem zwei Threads die Instanz erstellen, müssen wir und synchronisieren die Objekterstellung. Wir möchten jedoch nicht die Gemeinkosten für den „synchronisierten“ Block bei jedem Aufruf. Dieser Teil wird also nur ausgeführt, helper ist derzeit null.

Es gibt einen Wettlauf um das Feld helper. Es kann sein, wird gleichzeitig mit dem helper == null in einem anderen Thread festgelegt.

Um zu sehen, wie dies scheitern kann, denselben Code leicht umgeschrieben, als wäre er in eine C-ähnliche Sprache kompiliert. (Ich habe einige Ganzzahlfelder hinzugefügt, um Helper’s darzustellen Konstruktoraktivität):

if (helper == null) {
    synchronized() {
        if (helper == null) {
            newHelper = malloc(sizeof(Helper));
            newHelper->x = 5;
            newHelper->y = 10;
            helper = newHelper;
        }
    }
    return helper;
}

Weder die Hardware noch der Compiler können durch nichts verhindert werden. von der Neubestellung des Store über helper bis hin zur x/y-Feldern. Ein anderer Thread könnte feststellen, dass helper nicht null ist, seine Felder aber noch nicht festgelegt und zur Verwendung bereit sind. Weitere Details und Fehlermodi finden Sie im Abschnitt „Locking is Defekt“-Erklärung“ im Anhang finden Sie weitere Informationen oder 71 („Use lazy initialisierung judiciously“) in Josh Blochs Effective Java, 2nd Edition.

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

  1. Führen Sie die einfache Aktion aus und löschen Sie die äußere Prüfung. So wird sichergestellt, dass Untersuchen Sie den Wert von helper außerhalb eines synchronisierten Blocks.
  2. Deklarieren Sie helper als „volatile“. Mit dieser einen kleinen Änderung in Beispiel J-3 funktioniert auf Java 1.5 und höher korrekt. (Sie können sich um sich selbst davon zu überzeugen, dass dies richtig ist.)

Hier ist eine weitere Abbildung des Verhaltens von volatile:

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;
    void setValues() {    // runs in Thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }
    void useValues() {    // runs in Thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
}

Unter useValues() wird geprüft, falls Thread 2 die auf vol1 aktualisieren, dann kann nicht festgestellt werden, ob data1 oder data2 wurde noch festgelegt. Sobald das Update für vol1, sie weiß, dass auf data1 sicher zugegriffen werden kann korrekt zu lesen, ohne einen Wettlauf der Daten einzuleiten. Sie können jedoch Es können keine Annahmen über data2 getroffen werden, da dieses Geschäft die nach dem volatilen Speicher erfolgt.

Hinweis: Mit volatile kann nicht verhindert werden, dass andere Speicherzugriffe, die miteinander in Konflikt stehen, neu angeordnet werden. Es ist nicht garantiert, und generieren eine Fence-Anweisung für den Maschinenspeicher. Damit lässt sich verhindern, Wettlauf auf Daten, indem Code nur dann ausgeführt wird, wenn ein anderer Thread eine bestimmte Krankheit.

Deine Aufgabe:

In C/C++ C++11 bevorzugen Synchronisierungsklassen wie std::mutex. Falls nicht, verwenden Sie die entsprechenden pthread-Vorgänge. Dazu gehören die richtigen Speicherzäune, die korrekte und sequenzielle sofern nicht anders angegeben) und effizientes Verhalten auf allen Android-Plattformversionen zu ermöglichen. Nutzen Sie diese unbedingt korrekt sind. Denken Sie z. B. daran, dass Wartezeiten mit Bedingungsvariablen viel zu viel länger brauchen. zurückgegeben, ohne dass ein Signal erfolgt, und sollte daher in einer Schleife angezeigt werden.

Atomare Funktionen sollten nicht direkt verwendet werden, es sei denn, die Datenstruktur die Implementierung ist äußerst einfach, wie etwa ein Zähler. Sperren und das Entsperren eines pthread-mutex erfordert jeweils einen einzelnen atomaren Vorgang, und kosten oft weniger als einen Cache-Fehler. Konflikten. Sie sparen also nicht viel, wenn Sie Stummschaltungsanrufe atomic Ops. Sperrenfreie Designs für nicht triviale Datenstrukturen erfordern sehr viel wichtiger ist, dass Operationen auf höherer Ebene an der Datenstruktur durchgeführt werden, atomar erscheinen (als Ganzes, nicht nur ihre explizit atomaren Teile).

Wenn Sie atomare Vorgänge verwenden, kann eine lockere Sortierung mit memory_order… oder lazySet() Leistungsvorteile bieten, erfordert aber ein tieferes Verständnis als wir bisher vermittelt haben. Ein großer Teil des vorhandenen Codes mit dass sie im Nachhinein Fehler aufweisen. Vermeiden Sie diese, wenn möglich. Wenn Ihre Anwendungsfälle nicht genau zu einem der Anwendungsfälle im nächsten Abschnitt passen, ob Sie Experte sind oder sich beraten lassen.

Verwenden Sie volatile nicht für die Threadkommunikation in C/C++.

In Java lassen sich Nebenläufigkeitsprobleme oft am besten entsprechende Dienstprogrammklasse aus java.util.concurrent-Paket. Der Code ist gut geschrieben und gut die im SMP getestet wurden.

Vielleicht ist das sicherste, was Sie tun können, Ihre Objekte unveränderlich zu machen. Objekte wie String- und Integer-Hold-Daten in Java, die nicht mehr geändert werden können, -Objekt erstellt wird. Dadurch wird jegliches Potenzial für Datenüberläufe bei diesen Objekten vermieden. Das Buch Java, 2. Ed. enthält spezifische Anweisungen in „Punkt 15: Minimierbarkeit von Änderbarkeit“. Notiz in insbesondere die Bedeutung der Deklaration von Java-Feldern (Bloch)

Auch wenn ein Objekt unveränderlich ist, denken Sie daran, ohne Synchronisierung ist ein Datenwettlauf. Dies kann gelegentlich in Java akzeptabel sein (siehe unten), erfordert jedoch große Sorgfalt und führt wahrscheinlich und abfälliger Code. Wenn es nicht extrem leistungskritisch ist, fügen Sie eine volatile-Erklärung hinzu. In C++ ist die Kommunikation eines Zeigers oder einer Referenz auf ein unveränderliches Objekt ohne ordnungsgemäße Synchronisierung, wie bei jeder Datenübertragung, ein Fehler. In diesem Fall ist es relativ wahrscheinlich, dass es zu zeitweiligen Abstürzen kommt, da Zum Beispiel könnte der empfangende Thread eine nicht initialisierte Methodentabelle sehen, Zeiger aufgrund der Neuanordnung des Geschäfts.

Wenn weder eine vorhandene Bibliotheksklasse noch eine unveränderliche Klasse die Java-Anweisung synchronized oder die C++- lock_guard / unique_lock sollten zum Schutz Zugriff auf jedes Feld, auf das von mehr als einem Thread zugegriffen werden kann. Wenn Mutexe nicht für Ihre Situation geeignet, sollten Sie freigegebene Felder volatile oder atomic, aber Sie müssen darauf achten, um die Interaktionen zwischen Threads besser zu verstehen. Diese Deklarationen enthalten keine ersparen Sie sich häufige Programmierfehler, aber sie helfen Ihnen, die mysteriösen Fehler im Zusammenhang mit der Optimierung von Compilern und SMP vermeiden Missgeschicke passieren.

Sie sollten Folgendes vermeiden: „Veröffentlichung“ Verweis auf ein Objekt, d.h., es für andere Nutzer verfügbar zu machen Threads in ihrem Konstruktor. In C++ ist das weniger kritisch oder wenn Sie sich an unsere Empfehlung „Keine Datenrennen“ in Java halten. Dieser Rat ist jedoch immer gut und wird kritisch, wenn Ihr Java-Code in anderen Kontexten ausgeführt wird, in denen das Java-Sicherheitsmodell wichtig ist, und nicht vertrauenswürdiger Code durch Zugriff auf diese „geleakte“ Objektreferenz zu einem Datenrennen führen kann. Es ist auch wichtig, wenn Sie unsere Warnungen ignorieren und einige der Techniken im nächsten Abschnitt. Siehe (sichere Konstruktionstechniken in Java) für Details

Weitere Informationen zu schwachen Speicherbestellungen

C++11 und höher bieten explizite Mechanismen zum Lockern sequenzieller Elemente Konsistenz garantiert für Programme ohne Datenrennen. Explizit memory_order_relaxed, memory_order_acquire (Wird geladen) nur) und memory_order_release(nur speichert) Argumente für atomare Operationen bieten jeweils schwächere Garantien als der Standardwert, implizit: memory_order_seq_cst. memory_order_acq_rel gibt sowohl memory_order_acquire als auch memory_order_release garantiert für atomares Read-Change-Schreibvorgang Geschäftsabläufe. memory_order_consume ist noch nicht ausreichend spezifiziert oder implementiert, um nützlich zu sein, und sollte vorerst ignoriert werden.

Die lazySet-Methoden in Java.util.concurrent.atomic ähneln C++-memory_order_release-Speichern. Java werden mitunter als Ersatz für memory_order_relaxed-Zugriffe, obwohl es sich tatsächlich um noch schwächer werden. Im Gegensatz zu C++ gibt es keinen echten Mechanismus für unsortierte Zugriffe auf Variablen, die als volatile deklariert sind.

Sie sollten sie im Allgemeinen vermeiden, es sei denn, es gibt dringende Leistungsgründe, sie zu verwenden. Bei schwach geordneten Maschinenarchitekturen wie ARM für jeden atomaren Vorgang etwa ein paar Dutzend Maschinenzyklen einsparen. Auf x86 ist der Leistungsgewinn auf Geschäfte beschränkt und liegt wahrscheinlich geringer auffällig sind. Etwas kontraintuitiv kann der Vorteil bei einer größeren Kernanzahl sinken, da der Arbeitsspeicher dann eher ein begrenzender Faktor wird.

Die vollständige Semantik von schwach geordneten Atomen ist kompliziert. Im Allgemeinen benötigen sie die Sprachregeln genau zu verstehen. nicht hierhin gehen. Beispiel:

  • Der Compiler oder die Hardware kann memory_order_relaxed verschieben Zugang zu einem kritischen Bereich, der durch ein Schloss begrenzt ist (aber nicht aus einem heraus) Akquisition und Veröffentlichung. Das bedeutet, dass zwei memory_order_relaxed Geschäfte werden möglicherweise nicht in der richtigen Reihenfolge angezeigt, auch wenn sie durch kritische Abschnitte getrennt sind.
  • Wenn eine gewöhnliche Java-Variable als freigegebener Zähler missbraucht wird, kann sie für einen anderen Thread abnehmen, obwohl sie nur von einem anderen Thread erhöht wird. Dies gilt jedoch nicht für atomare C++- memory_order_relaxed

Mit dieser Warnung möchte ich Sie darauf hinweisen, Wir geben hier einige Redewendungen an, die viele der für schwach geordnete Atome. Viele davon gelten nur für C++.

Zugriffe außerhalb des Rennens

Eine Variable ist recht häufig atomar, weil sie manchmal aber nicht bei allen Zugriffen tritt dieses Problem auf. Eine Variable kann z. B. müssen möglicherweise atomar sein, da sie außerhalb eines kritischen Abschnitts gelesen werden, aber alle sind durch eine Sperre geschützt. In diesem Fall kann es bei einem Lesevorgang, der zufällig durch dieselbe Sperre geschützt ist, nicht zu einem Wettlauf kommen, da es keine gleichzeitigen Schreibvorgänge geben kann. In einem solchen Fall Zugriff ohne Rennen (in diesem Fall laden) kann mit memory_order_relaxed, ohne die Richtigkeit des C++-Codes zu ändern. Die Sperrimplementierung erzwingt bereits die erforderliche Arbeitsspeicherreihenfolge im Hinblick auf den Zugriff durch andere Threads und memory_order_relaxed gibt an, dass im Wesentlichen keine zusätzlichen für den atomischen Zugriff erzwungen.

In Java gibt es keine entsprechende Analogie.

Die Richtigkeit des Ergebnisses wird nicht zuverlässig ermittelt.

Wenn wir einen Rennlast nur zum Generieren eines Hinweises verwenden, ist das im Allgemeinen auch in Ordnung. um keine Speicherreihenfolge für die Last zu erzwingen. Wenn der Wert gleich nicht zuverlässig ist, können wir aus dem Ergebnis auch nicht Variablen enthalten. Daher ist es in Ordnung Speicheranordnung ist nicht garantiert und die Last ist mit einem memory_order_relaxed-Argument bereitgestellt.

Eine gemeinsame Instanz davon ist die Verwendung von C++ compare_exchange um x automatisch durch f(x) zu ersetzen. Der anfängliche Ladevorgang von x für die Berechnung von f(x) muss nicht zuverlässig sein. Wenn wir falsch liegen, compare_exchange schlägt fehl und wir versuchen es noch einmal. Der anfängliche Ladevorgang von x ist in Ordnung. Ein memory_order_relaxed-Argument; Nur Arbeitsspeicherreihenfolge für den tatsächlichen compare_exchange.

Atomar geänderte, aber ungelesene Daten

Gelegentlich werden Daten parallel durch mehrere Threads geändert, erst überprüft werden, wenn die Parallelberechnung abgeschlossen ist. Ein gutes Beispiel hierfür ist ein Zähler, der von mehreren Threads parallel atomisch erhöht wird (z. B. mit fetch_add() in C++ oder atomic_fetch_add_explicit() in C), das Ergebnis dieser Aufrufe jedoch immer ignoriert wird. Der sich daraus ergebende Wert wird nur am Ende gelesen. nachdem alle Aktualisierungen abgeschlossen sind.

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

Ein einfaches Beispiel hierfür sind Ereigniszähler. Da es sich um häufig vorkommen, lohnt es sich, einige Beobachtungen zu diesem Fall anzustellen:

  • Mit memory_order_relaxed wird die Leistung verbessert, aber nicht auf das wichtigste Leistungsproblem reagieren: bei jeder Aktualisierung erfordert exklusiven Zugriff auf die Cache-Zeile, in der sich der Zähler befindet. Dies führt jedes Mal zu einem Cache-Miss, wenn ein neuer Thread auf den Zähler zugreift. Wenn Aktualisierungen häufig sind und zwischen den Threads wechseln, ist es viel schneller, den freigegebenen Zähler nicht jedes Mal zu aktualisieren. Verwenden Sie dazu beispielsweise threadlokale Zähler und summieren Sie sie am Ende.
  • Diese Technik ist mit dem vorherigen Abschnitt kombinierbar: ungefähre und unzuverlässige Werte lesen, während sie aktualisiert werden, mit allen Vorgängen unter Verwendung von memory_order_relaxed. Es ist jedoch wichtig, die resultierenden Werte als völlig unzuverlässig zu betrachten. Nur weil der Zähler scheinbar einmalig erhöht wurde, dass ein anderer Thread den Punkt erreicht hat, an dem das Inkrement durchgeführt wurde. Das Inkrement kann stattdessen mit früherem Code neu angeordnet. (In unserem ähnlichen Fall garantiert, dass ein zweites Laden eines solchen Zählers einen Wert zurückgeben, der kleiner ist als ein früherer Ladevorgang im selben Thread. Es sei denn von ist der Zähler überlaufen.)
  • Häufig wird Code gefunden, der versucht, Zählerwerte durch Ausführung einzelner (oder nicht) atomarer Lese- und Schreibvorgänge, nicht als ganzes Inkrement. Das übliche Argument ist, ist das „nah genug“, für Leistungsindikatoren oder Ähnliches. Normalerweise nicht. Wenn genügend Updates vorhanden sind (ein Fall wahrscheinlich interessieren, ist ein großer Teil der Zahlen verloren. Auf einem Quad-Core-Gerät gehen häufig mehr als die Hälfte der Zählungen verloren. (Einfache Übung: Erstellen Sie ein Szenario mit zwei Threads, in dem der Zähler aber der letzte Zählerwert ist 1.

Einfache Flaggenkommunikation

Ein memory_order_release-Speicher (oder Read-Change-Write-Vorgang) sorgt dafür, dass, wenn anschließend ein memory_order_acquire-Ladevorgang (oder Read-Modify-Write-Vorgang) den geschriebenen Wert liest, wird er auch alle normalen oder atomaren Geschäfte Ein memory_order_release-Shop. Umgekehrt werden Ladevorgänge vor memory_order_release keine Auswirkungen auf die auf das Laden von memory_order_acquire folgten. Im Gegensatz zu memory_order_relaxed sind so atomare Vorgänge damit möglich. verwendet werden, um den Fortschritt eines Threads an einen anderen zu kommunizieren.

Beispielsweise können wir das Beispiel aus oben in C++ als

class MyClass {
  private:
    atomic<Helper*> helper {nullptr};
    mutex mtx;
  public:
    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper == nullptr) {
        lock_guard<mutex> lg(mtx);
        myHelper = helper.load(memory_order_relaxed);
        if (myHelper == nullptr) {
          myHelper = new Helper();
          helper.store(myHelper, memory_order_release);
        }
      }
      return myHelper;
    }
};

Der Speicher für Abrufe und Releases sorgt dafür, dass bei einem helper eingeben, werden auch die Felder korrekt initialisiert. Wir haben auch die vorherige Beobachtung berücksichtigt, dass Lasten ohne Rennen kann memory_order_relaxed verwenden.

Ein Java-Programmierer könnte helper daher als java.util.concurrent.atomic.AtomicReference<Helper> und lazySet() als Veröffentlichungsspeicher verwenden. Die Last Bei -Vorgängen würden weiterhin einfache get()-Aufrufe verwendet.

In beiden Fällen konzentrierte sich unsere Leistungsoptimierung auf die Initialisierung -Pfad, der wahrscheinlich keine leistungskritische Komponente aufweist. Eine besser lesbare Manipulation könnte folgendermaßen aussehen:

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

Dies ist der gleiche schnelle Weg, sequenzielle Konsistenz, Vorgänge auf der nicht leistungskritischen Pfad.

Auch hier wird helper.load(memory_order_acquire) auf aktuellen von Android unterstützten Architekturen wahrscheinlich denselben Code generieren wie eine einfache (sequentiell konsistente) Referenz auf helper. Die vorteilhafteste Optimierung ist hier die Einführung von myHelper, um eine zweite Ladung zu vermeiden. Ein zukünftiger Compiler könnte dies jedoch automatisch tun.

Kauf- und Freigabebestellungen verhindern nicht, dass Geschäfte sichtbar sind. verzögert sich und sorgt nicht dafür, dass Geschäfte für andere Threads sichtbar werden in einer einheitlichen Reihenfolge geordnet werden. Daher unterstützt es keine schwierigen, aber ein ziemlich verbreitetes Codierungsmuster, das durch Dekkers wechselseitigen Ausschluss verdeutlicht wird. Algorithmus: Alle Threads legen zuerst ein Flag fest, das darauf hinweist, dass sie eine Aktion ausführen möchten. etwas; Wenn ein Thread t feststellt, dass kein anderer Thread wenn jemand versucht, etwas zu tun, wird nicht gestört. Kein anderer Thread wird fortfahren können, da das Flag von t noch gesetzt ist. Fehlgeschlagen Der Zugriff auf das Flag erfolgt über die Reihenfolge von Akquisition/Veröffentlichung. verhindern, dass die Markierung eines Threads zu einem späteren Zeitpunkt für andere sichtbar ist, nachdem diese fälschlicherweise fortgefahren ist. Standard-memory_order_seq_cst verhindert.

Unveränderliche Felder

Wenn ein Objektfeld bei der ersten Verwendung initialisiert und dann nie geändert wird, kann es möglicherweise initialisiert und anschließend mit schwachem geordnete Zugriffe. In C++ könnte dies als atomic deklariert werden. und über memory_order_relaxed oder in Java aufgerufen wird, ohne volatile deklariert und ohne Zugriff besondere Maßnahmen. Hierfür müssen alle folgenden Holds angewendet werden:

  • Der Wert im Feld selbst sollte erkennbar sein. ob er bereits initialisiert wurde. Um auf das Feld zuzugreifen, sollte das Feld nur einmal gelesen werden. Letzteres ist in Java unerlässlich. Auch wenn die Feldtests wie initialisiert kann ein zweiter Ladevorgang den früheren nicht initialisierten Wert lesen. In C++ „Einmal lesen“ ist eine gute Praxis.
  • Sowohl die Initialisierung als auch nachfolgende Ladevorgänge müssen atomar sein. dass Teilaktualisierungen nicht sichtbar sein sollten. Für Java ist das Feld sollte kein long oder double sein. Für C++: eine atomare Zuweisung ist erforderlich. wäre es nicht möglich, sie zu errichten, Die Konstruktion eines atomic ist nicht atomar.
  • Wiederholte Initialisierungen müssen sicher sein, da mehrere Threads kann den nicht initialisierten Wert gleichzeitig lesen. In C++ folgt dies im Allgemeinen aus der Anforderung, dass alle atomaren Typen „trivial kopierbar“ sein müssen. Typen mit verschachtelten eigenen Pointern würden im Kopierkonstruktor eine Dealokalisierung erfordern und wären nicht trivial kopierbar. Bei Java sind bestimmte Referenztypen akzeptabel:
  • Java-Referenzen sind auf unveränderliche Typen beschränkt, die nur finale Felder enthalten. Der Konstruktor des unveränderlichen Typs darf nicht veröffentlichen auf das Objekt verweisen. In diesem Fall gilt für die endgültigen Java-Feldregeln damit Leser, die die Referenz sehen, auch die die initialisierten endgültigen Felder. C++ hat kein Analog zu diesen Regeln und Verweise auf eigene Objekte sind ebenfalls aus diesem Grund inakzeptabel (in gegen die Richtlinien, die Anforderungen).

Abschlusshinweise

Dieses Dokument dient nicht nur dazu, die Oberfläche zu kratzen, als ein flacher Riss. Dies ist ein sehr weit gefasstes und tiefgreifendes Thema. Einige Bereiche zur weiteren Erkundung:

  • Die tatsächlichen Java- und C++ Speichermodelle werden in Form eines happens-before-Beziehung, die angibt, wann zwei Aktionen garantiert sind in einer bestimmten Reihenfolge erfolgen. Als wir einen Wettlauf der Daten definiert haben, zwei Arbeitsspeicherzugriffe gesprochen, die „gleichzeitig“ erfolgen. Offiziell ist dies als kein Ereignis vor dem anderen definiert. Es ist hilfreich, die tatsächlichen Definitionen von happens-before und synchronizes-with im Java- oder C++-Speichermodell zu kennen. Obwohl das intuitive Konzept der gleichzeitigen ist im Allgemeinen gut sind diese Definitionen aufschlussreich, besonders, schwach geordnete atomare Operationen in C++ verwenden. (Die aktuelle Java-Spezifikation definiert nur lazySet(). sehr informell.)
  • Erfahren Sie, was Compiler bei der Neuordnung von Code tun dürfen und was nicht. (Die JSR-133-Spezifikation enthält einige großartige Beispiele für rechtliche Transformationen, die zu unerwartete Ergebnisse.)
  • Informieren Sie sich, wie Sie unveränderliche Klassen in Java und C++ schreiben. (Es geht dabei um mehr als nur „nach der Erstellung nichts mehr ändern“.)
  • Internieren Sie die Empfehlungen im Abschnitt Nebenläufigkeit des Artikels Effektive Java, 2. Edition. So sollten Sie beispielsweise Methoden, die überschrieben werden sollen, nicht innerhalb eines synchronisierten Blocks aufrufen.
  • Lies dir die java.util.concurrent- und java.util.concurrent.atomic-APIs durch, um zu sehen, was verfügbar ist. Erwägen Sie die Verwendung Nebenläufigkeitsannotationen wie @ThreadSafe und @GuardedBy (aus net.jcip.annotations).

Der Abschnitt Weitere Informationen im Anhang enthält Links zu Dokumenten und Websites, die diese Themen besser verdeutlichen.

Anhang

Synchronisierungsspeicher implementieren

Das ist bei den meisten Programmierern nicht nötig, aber die Diskussion ist aufschlussreich.)

Bei kleinen integrierten Typen wie int und von Android unterstützter Hardware sorgen normale Lade- und Speicheranweisungen dafür, dass ein Speicher entweder vollständig oder gar nicht für einen anderen Prozessor sichtbar ist, der denselben Speicherort lädt. So wird ein grundlegendes Konzept der „Atomarität“ kostenlos zur Verfügung gestellt.

Wie wir bereits gesehen haben, reicht das nicht aus. Um eine sequentielle Konsistenz zu gewährleisten, müssen wir auch die Neuanordnung von Vorgängen verhindern und dafür sorgen, dass Speichervorgänge für andere Prozesse in einer konsistenten Reihenfolge sichtbar werden. Bei Android-Geräten erfolgt die letztere automatisch Voraussetzung ist, dass wir bei der Durchsetzung der ersten Deshalb ignorieren wir ihn hier weitgehend.

Die Reihenfolge der Speichervorgänge wird beibehalten, da beides die Neuanordnung verhindert. vom Compiler erstellt und eine Neuanordnung durch die Hardware verhindert wird. Hier konzentrieren wir uns zu Letzterem.

Speicheranordnung unter ARMv7, x86 und MIPS wird erzwungen mit "zaun" Anweisungen, die verhindern grob, dass Anweisungen, die dem Zaun folgen, sichtbar werden vor den Anweisungen vor dem Zaun. (Dies geschieht auch häufig namens „Barriere“ Anweisungen geben, aber es besteht die Gefahr von Verwechslungen mit Hürden im Stil von pthread_barrier, die viel mehr können als dieses.) Die genaue Bedeutung von Zaunanleitungen sind ein ziemlich kompliziertes Thema, die Garantien für verschiedene Arten von Zäunen. interagieren und wie diese mit anderen Bestellgarantien kombiniert werden, die von der Hardware bereitgestellt werden. Dies ist eine allgemeine Übersicht. diese Details nicht zu beeindrucken.

Die einfachste Art der Bestellgarantie ist die durch die C++- memory_order_acquire und memory_order_release Atomare Vorgänge: Speichervorgänge vor einem Releasespeicher sollte nach einem Download-Ladevorgang sichtbar sein. Bei ARMv7 ist dies erzwungen durch:

  • Vor der Anweisung für das Geschäft muss eine geeignete Anweisung für den Zaun eingefügt werden. Dadurch wird verhindert, dass alle vorherigen Speicherzugriffe mit der Store-Anweisung neu angeordnet werden. Außerdem wird unnötigerweise die Neubestellung mit einer späteren Anweisung für den Händler verhindert.
  • Folgen Sie den Lastanweisungen mit einem geeigneten Zaun. Dadurch wird verhindert, dass die Ladevorgänge bei nachfolgenden Zugriffen neu angeordnet werden. (Und wieder eine unnötige Bestellung mit mindestens früheren Ladevorgängen.)

Zusammen reichen diese Werte für die Bestellung von C++-Akquisition/-Veröffentlichung aus. Sie sind für Java erforderlich, aber nicht ausreichend. volatile oder atomic mit sequenzieller Konsistenz in C++.

Um zu sehen, was wir sonst noch brauchen, betrachten wir das Fragment des Dekker-Algorithmus die wir zuvor kurz erwähnt haben. flag1 und flag2 sind C++ atomic oder Java volatile, die beide anfänglich "false" sind.

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

Sequential Consistency bedeutet, dass eine der Aufgaben flagn muss zuerst ausgeführt werden und ist für den im anderen Thread testen können. Daher werden wir nie diese Threads die „kritischen Sachen“ gleichzeitig ausführen.

Die für die Erwerb-Freigabe-Reihenfolge erforderliche Absicherung fügt jedoch nur am Anfang und Ende jedes Threads Absicherungen hinzu, was hier nicht weiterhilft. Außerdem müssen wir dafür sorgen, dass die beiden nicht neu angeordnet werden, wenn auf einen volatile/atomic-Speicher eine volatile/atomic-Ladung folgt. Dies wird normalerweise durch das Einbauen eines Zauns und nicht nur vor einem des sequenziellen Konsistenten, aber auch danach. (Dies ist wiederum viel stärker als erforderlich, da dieser Zaun in der Regel alle früheren Speicherzugriffe im Hinblick auf alle späteren.)

Wir könnten den zusätzlichen Zaun stattdessen mit sequenzieller konstante Ladevorgänge. Da Geschäfte seltener sind, gilt die Konvention häufiger unter Android verwendet.

Wie wir in einem früheren Abschnitt gesehen haben, müssen wir zwischen den beiden Vorgängen eine Speicher-/Ladebarriere einfügen. Der Code, der in der VM für einen flüchtigen Zugriff ausgeführt wird, sieht in etwa so aus:

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

Echte Maschinenarchitekturen bieten in der Regel mehrere Arten von Zäune, die unterschiedliche Zugangsmöglichkeiten bieten und unterschiedliche Kosten. Die Wahl zwischen ihnen ist subtil und dass Speicher für andere Kerne in einheitliche Reihenfolge und dass die Speicherreihenfolge, Kombination mehrerer Zäune korrekt aufgebaut. Weitere Informationen finden Sie auf der Seite der University of Cambridge mit Zuordnungen von Atomaren zu tatsächlichen Prozessoren gesammelt.

Bei einigen Architekturen, insbesondere x86, und „Release“ unnötig sind, da die Hardware immer implizit erzwingt eine ausreichende Reihenfolge. Daher ist auf x86 nur der letzte Fence (3) generiert wird. Ähnlich verhält es sich bei x86-, atomischen Read-Modify-Write- die Operationen implizit einen starken Zaun umfassen. Daher sind diese niemals Zäune erforderlich sind. Unter ARMv7 sind alle oben besprochenen Fechten erforderlich.

ARMv8 bietet LDAR- und STLR-Anweisungen, die direkt Erzwingen der Anforderungen von Java volatile oder sequenzieller Konsistenz in C++ lädt und speichert. Sie vermeiden die unnötigen Einschränkungen bei der Neuordnung, wie oben erwähnt. Der 64-Bit-Android-Code auf ARM verwendet folgende Funktionen: haben wir entschieden, konzentrieren wir uns auf die Platzierung von ARMv7-Zäunen, da sie mehr die tatsächlichen Anforderungen.

Weitere Informationen

Webseiten und Dokumente, die detaillierter oder breiter sind. Je allgemeiner nützlicher ist, weiter oben auf der Liste stehen.

Konsistenzmodelle für gemeinsam genutzten Speicher: Eine Anleitung
Dieser Artikel von Adve und Gharachorloo aus dem Jahr 1995 ist ein guter Ausgangspunkt, wenn Sie sich eingehender mit Speicherkonsistenzmodellen befassen möchten.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Gedächtnisbarrieren
In diesem Artikel werden die Probleme zusammengefasst.
https://en.wikipedia.org/wiki/Memory_barrier
Grundlagen von Unterhaltungen
Eine Einführung in die Multithread-Programmierung in C++ und Java von Hans Böhm. Diskussion von Datenrennen und grundlegenden Synchronisierungsmethoden.
http://www.hboehm.info/c++mm/threadsintro.html
Java-Nebenläufigkeit in der Praxis
Dieses Buch wurde 2006 veröffentlicht und deckt eine Vielzahl von Themen sehr detailliert ab. Sehr empfehlenswert für alle, die Multithread-Code in Java schreiben.
http://www.javaconcurrencyinpractice.com
(in englischer Sprache)
Häufig gestellte Fragen zu JSR-133 (Java-Speichermodell)
Eine kurze Einführung in das Java-Speichermodell, einschließlich einer Erläuterung der Synchronisierung, veränderlicher Variablen und der Konstruktion der endgültigen Felder. (Ein bisschen veraltet, vor allem, wenn darin andere Sprachen behandelt werden.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
Gültigkeit von Programmtransformationen im Java-Speichermodell
Eine eher technische Erklärung der verbleibenden Probleme mit dem Java-Speichermodell Diese Probleme gelten nicht für Daten ohne Datenrennen. Programmen.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
Paket java.util.concurrent
Die Dokumentation für das Paket java.util.concurrent. Ganz unten auf der Seite finden Sie den Abschnitt „Eigenschaften der Speicherkonsistenz“, in dem die Garantien der verschiedenen Klassen erläutert werden.
Paketübersicht für java.util.concurrent
Java-Theorie und -Praxis: Sichere Konstruktionstechniken in Java
In diesem Artikel werden die Risiken von Verweisen, die bei der Objektkonstruktion verworfen werden, ausführlich untersucht. Außerdem finden Sie Richtlinien für threadsichere Konstruktoren.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
Java-Theorie und -Praxis: Bewältigung von Volatilität
In diesem Artikel wird beschrieben, was mit flüchtigen Feldern in Java möglich ist und was nicht.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
Erklärung „Doppelt überprüftes Sperren ist fehlerhaft“
Bill Pugh hat eine detaillierte Erklärung der verschiedenen Möglichkeiten, wie die überprüfte Verriegelung ohne volatile oder atomic aufgehoben werden kann, erklärt. Beinhaltet C/C++ und Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] Barrier Litmus-Tests und Cookbook
Eine Beschreibung von ARM SMP-Problemen, die durch kurze ARM-Code-Snippets erklärt wird. Wenn Sie die Beispiele auf dieser Seite zu ungenau finden oder die formale Beschreibung der DMB-Anweisung lesen möchten, lesen Sie diesen Artikel. Beschreibt auch die Anweisungen für Speicherbarrieren bei ausführbarem Code (möglicherweise nützlich, wenn Sie schnell Code generieren). Dies ist eine Vorversion von ARMv8, unterstützt zusätzliche Anweisungen zur Speicheranordnung und wurde zu einer etwas stärkeren Speichermodells. Weitere Informationen finden Sie im „ARM®-Architektur-Referenzhandbuch ARMv8 für das ARMv8-A-Architekturprofil“.
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
(in englischer Sprache)
Speicherbarrieren des Linux-Kernels
Dokumentation zu Linux-Kernel-Speicherbarrieren. Enthält einige nützliche Beispiele und ASCII-Art.
http://www.kernel.org/doc/Documentation/memory-barriers.txt
.
ISO/IEC JTC1 SC22 WG21 (C++-Standards) 14882 (Programmiersprache C++), Abschnitt 1.10 und Klausel 29 („Atomic Operations Library“)
Entwurf von Standards für atomare C++-Funktionen Diese Version ist Sie entspricht dem C++14-Standard, was kleinere Änderungen in diesem Bereich einschließt. aus C++11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(Einführung: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf)
ISO/IEC JTC1 SC22 WG14 (C-Standards) 9899 (Programmiersprache C) Kapitel 7.16 („Atomics <stdatomic.h>“)
Entwurf einer Norm für Funktionen für atomare Vorgänge gemäß ISO/IEC 9899-201x C. Weitere Informationen finden Sie auch in späteren Mängelberichten.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
C/C++11-Zuordnungen zu Prozessoren (University of Cambridge)
Die Übersetzungen von Jaroslav Sevcik und Peter Sewell von C++-Atomaren in verschiedene gängige Prozessor-Anweisungssätze.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
Dekker-Algorithmus
Die „erste bekannte korrekte Lösung für das Problem der gegenseitigen Ausschließung bei der parallelen Programmierung“. Der Wikipedia-Artikel enthält den vollständigen Algorithmus mit einer Diskussion darüber, wie er aktualisiert werden muss, um mit modernen optimierenden Compilern und SMP-Hardware zu funktionieren.
https://de.wikipedia.org/wiki/Dekker's_algorithm
Kommentare zu ARM und Alpha sowie Adressabhängigkeiten
Eine E-Mail für die Arm-Kernel-Mailingliste von Catalin Marinas. Enthält eine schöne Zusammenfassung der Adress- und Steuerungsabhängigkeiten.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
Was jeder Programmierer über den Arbeitsspeicher wissen sollte
Ein sehr langer und detaillierter Artikel von Ulrich Drepper über verschiedene Arbeitsspeichertypen, insbesondere CPU-Caches.
http://www.akkadia.org/drepper/cpumemory.pdf
Begründung für das ARM-Modell mit schwacher Konsistenz
Dieser Artikel wurde von Chong und Ishtiaq von ARM, Ltd. versucht, das ARM SMP-Speichermodell auf strenge, aber zugängliche Weise zu beschreiben. Die hier verwendete Definition von „Beobachtbarkeit“ stammt aus diesem Artikel. Auch dies ist älter als ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
JSR-133 Cookbook für Compiler-Autoren
Doug Lea hat dieses Buch als Begleiter zur Dokumentation von JSR-133 (Java Memory Model) geschrieben. Sie enthält die ersten Implementierungsrichtlinien. für das Java-Speichermodell, das von vielen Compiler-Schreibern verwendet wurde und immer noch viel zitiert werden und wahrscheinlich einen Einblick geben. Leider sind die hier besprochenen vier Zaunvarianten nicht gut für Android-unterstützte Architekturen und die oben genannten C++11-Zuordnungen sind jetzt eine bessere Quelle für präzise Rezepte, sogar für Java.
http://g.oswego.edu/dl/jmm/cookbook.html
,
x86-TSO: Ein strenges und nutzbares Programmer-Modell für x86-Multiprozessoren
Eine genaue Beschreibung des x86-Speichermodells. Genaue Beschreibungen der ARM-Arbeitsspeichermodelle sind leider deutlich komplizierter.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf
(in englischer Sprache)