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 kann auftreten, wenn Multithread-Code für symmetrische Multiprozessorsysteme in C, C++ und Java geschrieben wird. Programmiersprache (nachfolgend als "Java" bezeichnet, Kürze). Sie ist nur als Einführung für Android-App-Entwickler gedacht, nicht als vollständige Diskussion zu diesem Thema.

Einführung

SMP ist ein Akronym für „Symmetric Multi-Processor“. Sie beschreibt ein Design in die zwei oder mehr identische CPU-Kerne gemeinsam auf den Hauptarbeitsspeicher zugreifen. Bis Vor einigen Jahren waren alle Android-Geräte UP (Uni-Prozessor).

Die meisten – wenn nicht sogar alle – Android-Geräte hatten immer mehrere CPUs. Früher wurde nur eine von ihnen zum Ausführen von Anwendungen verwendet, während andere verschiedene Geräte verwalten können. Hardware (z. B. Funkschnittstelle). Die CPUs hatten möglicherweise unterschiedliche Architekturen. Programme, die darauf ausgeführt wurden, konnten den Hauptspeicher nicht für die Kommunikation mit den einzelnen Sonstiges.

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 gründlich 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, erkennen Sie, dass er einige Lese- und 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 berücksichtigen, kurz als Lackustests bezeichnet.

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 sequentielle Konsistenz garantiert, dass, nachdem beide Threads beendet sind, ausgeführt wird, befinden sich die Register in einem der folgenden Status:

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

Um in eine Situation zu gelangen, in der wir B=5 sehen, bevor wir den Store zu A sehen, müssen die Lese- oder Schreibvorgänge nicht in der richtigen Reihenfolge stattfinden. 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. Beispiel: x86, wenn auch nicht sequenziell konsistent, garantiert trotzdem, dass reg0 = 5 und reg1 = 0 unmöglich bleibt. Die Geschäfte werden gepuffert, aber ihre Reihenfolge wird beibehalten. ARM hingegen tut dies 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 Ladevorgänge in reg0 und reg1 neu angeordnet werden.

Die Neuanordnung von Zugriffen auf verschiedene Speicherspeicherorte, in der Hardware oder im Compiler zulässig, da sie sich nicht auf die Ausführung eines Threads auswirkt kann die Leistung erheblich verbessert werden. 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 jedoch keine Neuanordnung vorschlägt, dass wir dieses Problem nie bemerken. Auf den meisten ARM SMPs, auch ohne Compiler erfolgt die Neuanordnung wahrscheinlich nach einer sehr großen erfolgreicher Ausführungen. Es sei denn, Sie programmieren in Assembly Sprache sprechen, erhöhen soziale Netzwerke in der Regel die Wahrscheinlichkeit, dass Sie auf Probleme stoßen, immer da.

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 das sogenannte „Datenrennen-kostenlos“ Programmierstil haben. 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. Es ist fast so, als würden Sie eine Wurst als leckeres und nur leckeres Essen, solange du versprochen hast, Würstchenfabrik. 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 an einem 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. Das folgende Programm hat keinen Datenwettlauf wenn A und B gewöhnliche boolesche Variablen sind, die anfänglich falsch:

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

Da Vorgänge nicht neu angeordnet werden, werden beide Bedingungen als „false“ ausgewertet, und wird keine der beiden Variablen aktualisiert. Daher kann es keinen Wettlauf mit den Daten geben. Es gibt Sie müssen nicht darüber nachdenken, was passieren könnte, wenn das Laden von A und speichern bis B in Thread 1 wurde irgendwie neu angeordnet. 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 wurden so geschrieben, dass Sie auch die Bedeutung auf Bibliotheksebene. Sie versprechen, keine Wettkämpfe im Datenbereich einzuführen. es sei denn, 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 gleichzeitiger Zugriff auf verschiedene Felder in einer Datenstruktur kein Datenrennen einleiten kann. 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. Das 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.

Wettkämpfe mit Daten 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 Razzien 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 die Variablen volatile und atomic nicht direkt verwendet werden, um zu verhindern, dass andere Threads längere Codesequenzen beeinträchtigen.

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. in Verwenden Sie in C++ atomic<T> für Variablen, die gleichzeitig verwendet werden können. auf die von mehreren Threads zugegriffen wird. C++ volatile ist für folgende Zwecke gedacht: Geräteregister und Ähnliches.

C/C++ atomic-Variablen oder Java volatile-Variablen kann verwendet werden, um Risiken bei anderen Variablen zu vermeiden. 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.

Wenn die Neuanordnung von Arbeitsspeicher sichtbar wird

Eine datenrennenfreie Programmierung erspart uns normalerweise bei der Neuanordnung des Arbeitsspeicherzugriffs. Es gibt jedoch mehrere Fälle, in denen welche Neuordnung sichtbar wird: <ph type="x-smartling-placeholder">
    </ph>
  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 des Zugriffs Hardware-Speichermodelle neu anordnen und verstehen Einen Programmierstil im Linux-Kernel verwendet. Es sollte keine die in neuen Android-Apps verwendet werden, und wird hier auch nicht näher 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 hat normalerweise nicht verhindern, dass die Hardware den Zugriff neu ordnen kann. Daher ist es an sich sogar noch weniger nützlich für SMP-Umgebungen mit mehreren Threads. Aus diesem Grund werden C11 und C++11 atomic-Objekte. Sie sollten stattdessen diese verwenden.

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

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, 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 Anrufe bei useGlobalThing(), gGlobalThing können während des Schreibens gelesen werden.

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

atomic<MyThing*> gGlobalThing(NULL);

Dadurch wird sichergestellt, dass die Schreibvorgänge für andere Threads sichtbar werden in die richtige Reihenfolge bringen. 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 das wird 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, sicherstellen, dass das Ergebnis weiterhin sequentiell konsistent ist.

Das gilt insbesondere, wenn Thread 1 in ein volatile-Feld schreibt und Thread 2 liest anschließend aus demselben Feld und sieht die neu geschriebenen dann kann Thread 2 auch garantiert alle Schreibvorgänge sehen, die zuvor von Thread 1. 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 sehen Sie 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, wird einer der könnten Aktualisierungen verloren gehen. Um das Inkrement atomar zu machen, müssen wir incr() „synchronisiert“.

Es funktioniert jedoch immer noch nicht, vor allem im sozialen Netzwerk. 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. Mit dieser Änderung ist der Code offensichtlich korrekt.

Leider haben wir die Möglichkeit eines Sperrenkonflikts eingeführt, die Leistung beeinträchtigen könnte. Anstatt get() als können Sie mValue mit "volatile" deklarieren. (Hinweis Für incr() muss synchronize weiterhin verwendet werden, da Andernfalls ist mValue++ kein einzelner atomarer Vorgang.) 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 synchronisierten Block 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 du sGoodies mit dem Parameter volatile Keyword, sequenzielle Konsistenz wird 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 einen flüchtigen Ladevorgang von MyClass.sGoodies aus gefolgt von einer nichtflüchtigen Last von sGoodies.x. 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 neu geschrieben, 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 helper ist nicht null, aber die zugehörigen Felder sind noch nicht festgelegt und einsatzbereit. 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. Dadurch wird sichergestellt, Untersuchen Sie den Wert von helper außerhalb eines synchronisierten Blocks.
  2. Deklariere helper als flüchtig. 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 wahr 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.

Beachte, dass volatile nicht verwendet werden kann, um eine Neuanordnung zu verhindern der anderen Speicherzugriffe, die im Wettlauf miteinander stehen. Es ist nicht garantiert, und generieren eine Fence-Anweisung für den Maschinenspeicher. Damit lässt sich verhindern, Wettläufen 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 überflüssig werden können. 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 Operationen verwenden, memory_order... oder lazySet() bietet möglicherweise Leistung Vorteile bietet, 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. Falls dies nicht extrem leistungskritisch ist, fügen Sie volatile-Deklaration. In C++ kann die Übermittlung eines Zeigers oder auf ein unveränderliches Objekt ohne ordnungsgemäße Synchronisierung verweisen, wie jeder Wettlauf an Daten ein Fehler ist. 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. Dies ist weniger kritisch in C++ oder wenn Sie sich unseren Wettkämpfen ohne Daten in Java zu lesen. Aber es ist immer ein guter Rat. wenn Ihr Java-Code in anderen Kontexten ausgeführt werden, in denen das Java-Sicherheitsmodell wichtig und nicht vertrauenswürdig ist zu einem Datenwettlauf führen, indem er auf die „durchgesickerten“ Objektreferenz. 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 wurde, 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 ungeordnete Zugriff auf Variablen, die als volatile deklariert sind.

Sie sollten dies im Allgemeinen vermeiden, es sei denn, es gibt dringende Leistungsgründe, verwenden können. 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. Der Vorteil kann bei einer größeren Anzahl von Kernen abnehmen, da das Speichersystem 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.
  • Wird eine gewöhnliche Java-Variable als gemeinsamer Zähler missbraucht, zu einem anderen Thread hinzu, um ihn zu verringern, obwohl er nur um ein einziges in einem anderen Thread. 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 ist ein Lesevorgang, durch dieselbe Sperre geschützt darf nicht rennen, da keine gleichzeitigen Schreibvorgänge möglich sind. In einem solchen Fall nicht-Rennsport-Zugriff (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. Eine gute Ein Beispiel hierfür ist ein Zähler, der schrittweise erhöht wird (z.B. mit fetch_add() in C++ oder atomic_fetch_add_explicit() in C) durch mehrere Threads gleichzeitig, aber das Ergebnis dieser Aufrufe wird immer ignoriert. Der sich daraus ergebende Wert wird nur am Ende gelesen. nachdem alle Aktualisierungen abgeschlossen sind.

In diesem Fall kann nicht festgestellt werden, ob auf diese Daten zugegriffen wird. neu angeordnet. Daher kann C++ Code eine memory_order_relaxed verwenden. .

Ein gängiges Beispiel hierfür sind einfache 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. Dieses führt jedes Mal zu einem Cache-Fehler, wenn ein neuer Thread auf den Zähler zugreift. Wenn häufig Updates durchgeführt werden und zwischen Threads wechseln, geht es viel schneller. damit der gemeinsame Zähler nicht jedes Mal aktualisiert wird, indem beispielsweise durch die Verwendung von Thread-lokalen Zählern und der Addition 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 geht in der Regel 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 Flag-Kommunikation

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.

Selbst hier ist helper.load(memory_order_acquire) wahrscheinlich denselben Code auf den aktuellen, von Android Architekturen als einfacher (sequentiell einheitlicher) Verweis auf helper. Die vorteilhafteste Optimierung hier kann die Einführung von myHelper sein, um ein Das kann bei einem zukünftigen Compiler automatisch geschehen.

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++ ist dies im Allgemeinen folgt aus dem „trivilegable kopierbaren“ Anforderung für alle atomare Typen; mit verschachtelten eigenen Zeigern erfordern, Deallocation in der Copy-Konstruktor und wären nicht einfach kopierbar. Bei Java sind bestimmte Referenztypen akzeptabel:
  • Java-Referenzen sind auf unveränderliche Typen beschränkt, die nur endgültige . 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 Verletzung der sogenannten 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, sich mit den tatsächlichen Definitionen des Begriffs happen-vorher und synchronizes-with im Java- oder C++ Memory Model. 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.)
  • Unveränderliche Klassen in Java und C++ schreiben (Es gibt noch mehr als einfach nur „Nach der Bauarbeit nichts ändern“.)
  • Internieren Sie die Empfehlungen im Abschnitt Nebenläufigkeit des Artikels Effektive Java, 2. Edition. Vermeiden Sie es beispielsweise, Methoden aufzurufen, die in einem synchronisierten Block überschrieben werden sollen.)
  • Sehen Sie sich die java.util.concurrent und java.util.concurrent.atomic APIs an, um mehr über die verfügbaren Funktionen zu erfahren. 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.)

Für kleine integrierte Typen wie int sowie für Hardware, die von Android-Anweisungen zum Laden und Speichern stellen sicher, dass ein Store für eine andere Person entweder vollständig oder gar nicht sichtbar sind. dass der Prozessor denselben Standort lädt. Daher gibt es eine grundlegende Vorstellung davon, der Atomarität wird kostenlos zur Verfügung gestellt.

Wie wir bereits gesehen haben, reicht dies nicht aus. Um sequenzielle Anzeigen Konsistenz zu schaffen, müssen wir auch eine Neuanordnung von Vorgängen verhindern und sicherstellen, dass Speichervorgänge einheitlich für andere Prozesse Reihenfolge. 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 einen Umzäunung vorhanden sein. Dadurch wird verhindert, dass alle vorherigen Zugriffe im Arbeitsspeicher mit der Anweisungen zum Geschäft. Außerdem verhindert sie unnötigerweise eine Neuordnung mit zu einem späteren Zeitpunkt speichern.)
  • Folgen Sie den Lastanweisungen mit einer geeigneten Zaunangabe. 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 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 Fechtung, die für die Bestellung von Akquisition und Veröffentlichung erforderlich ist, Zäune am Anfang und Ende jedes Threads, was nicht hilft, hier. Außerdem müssen wir sicherstellen, volatile/atomic Händler gefolgt von volatile/atomic geladen wurde, werden die beiden nicht neu angeordnet. 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 in einem vorherigen Abschnitt gesehen, müssen wir eine Laden-/Ladeschranke einfügen. zwischen den beiden Operationen. 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 Arten von Zugang ermöglichen 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 mit einem atomischen 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 es ist, weiter oben auf der Liste stehen.

Konsistenzmodelle für gemeinsam genutzten Speicher: Eine Anleitung
1995 von Adve & Gharachorloo, dies ist ein guter Ausgangspunkt, wenn Sie sich eingehender mit Modellen für die Speicherkonsistenz befassen möchten.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Gedächtnisbarrieren
Schöner kleiner Artikel, in dem die Probleme zusammengefasst sind.
https://de.wikipedia.org/wiki/Speicherbarriere
Grundlagen zu Threads
Eine Einführung in die Multithread-Programmierung in C++ und Java von Hans Böhm. Erläuterung von Data Races 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
Übersicht über das 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. Falls Ihnen die Beispiele auf dieser Seite zu unspezifisch sind oder Sie die formale Beschreibung der DMB-Anleitung lesen möchten, lesen Sie dies. 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 richtige Lösung für das Problem des gegenseitigen Ausschlusses bei gleichzeitiger Programmierung“. Der Wikipedia-Artikel enthält den vollständigen Algorithmus, einschließlich einer Diskussion darüber, wie er aktualisiert werden müsste, damit er mit modernen Optimierungs-Compilern und SMP-Hardware funktioniert.
https://de.wikipedia.org/wiki/Dekker-Algorithmus
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 dies als Ergänzung zur Dokumentation zu 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)