Dies lässt sich am besten anhand eines Beispiels veranschaulichen.
Angenommen, wir haben eine einfache Aufgabe, die wir mehrmals parallel ausführen möchten, und wir möchten global verfolgen, wie oft die Aufgabe ausgeführt wurde, z. B. um Zugriffe auf eine Webseite zu zählen.
Wenn jeder Thread den Punkt erreicht, an dem er die Anzahl erhöht, sieht seine Ausführung folgendermaßen aus:
- Lesen Sie die Anzahl der Treffer aus dem Speicher in ein Prozessorregister
- Erhöhe diese Zahl.
- Schreiben Sie diese Nummer zurück in den Speicher
Denken Sie daran, dass jeder Thread zu jedem Zeitpunkt in diesem Prozess angehalten werden kann. Wenn also Thread A Schritt 1 ausführt und dann angehalten wird, gefolgt von Thread B, der alle drei Schritte ausführt, werden seine Register bei Wiederaufnahme von Thread A die falsche Anzahl von Treffern aufweisen: seine Register werden wiederhergestellt, und die alte Anzahl wird glücklich erhöht von Treffern, und speichern Sie diese erhöhte Zahl.
Darüber hinaus könnte während der Zeit, in der Thread A angehalten wurde, eine beliebige Anzahl anderer Threads ausgeführt worden sein, sodass die Anzahl, die Thread A am Ende schreibt, möglicherweise deutlich unter der korrekten Anzahl liegt.
Aus diesem Grund muss sichergestellt werden, dass ein Thread, der Schritt 1 ausführt, Schritt 3 ausführen muss, bevor ein anderer Thread Schritt 1 ausführen darf. Dies kann von allen Threads durchgeführt werden, die darauf warten, eine einzelne Sperre zu erhalten, bevor sie mit diesem Prozess beginnen , und Freigabe der Sperre erst nach Abschluss des Vorgangs, damit dieser "kritische Abschnitt" des Codes nicht falsch verschachtelt werden kann, was zu einer falschen Zählung führt.
Was aber, wenn die Operation atomar wäre?
Ja, im Land der magischen Einhörner und Regenbogen, in dem die Inkrementierungsoperation atomar ist, wäre für das obige Beispiel keine Verriegelung erforderlich.
Es ist jedoch wichtig zu wissen, dass wir nur sehr wenig Zeit in der Welt der magischen Einhörner und Regenbögen verbringen. In fast jeder Programmiersprache ist die Inkrementierungsoperation in die obigen drei Schritte unterteilt. Dies liegt daran, dass selbst wenn der Prozessor eine atomare Inkrementoperation unterstützt, diese Operation erheblich teurer ist: Sie muss aus dem Speicher lesen, die Nummer ändern und zurück in den Speicher schreiben ... und normalerweise ist die atomare Inkrementoperation eine Operation, die kann fehlschlagen, was bedeutet, dass die einfache Sequenz oben durch eine Schleife ersetzt werden muss (wie wir unten sehen werden).
Da selbst in Multithread-Code viele Variablen für einen einzelnen Thread lokal gehalten werden, sind Programme viel effizienter, wenn sie davon ausgehen, dass jede Variable für einen einzelnen Thread lokal ist, und die Programmierer den gemeinsamen Status zwischen Threads schützen. Insbesondere angesichts der Tatsache, dass Atomoperationen normalerweise nicht ausreichen, um Threading-Probleme zu lösen, wie wir später sehen werden.
Flüchtige Variablen
Wenn wir Sperren für dieses bestimmte Problem vermeiden möchten, müssen wir zuerst erkennen, dass die in unserem ersten Beispiel dargestellten Schritte nicht wirklich das sind, was in modern kompiliertem Code geschieht. Da Compiler davon ausgehen, dass nur ein Thread die Variable ändert, behält jeder Thread seine eigene zwischengespeicherte Kopie der Variablen, bis das Prozessorregister für etwas anderes benötigt wird. Solange die zwischengespeicherte Kopie vorhanden ist, muss sie nicht in den Speicher zurückgeschrieben und erneut gelesen werden (was teuer wäre). Sie schreiben die Variable auch nicht zurück in den Speicher, solange sie in einem Register gespeichert ist.
Wir können zu der Situation zurückkehren, die wir im ersten Beispiel angegeben haben (mit denselben Threading-Problemen, die wir oben identifiziert haben), indem wir die Variable als flüchtig markieren , was dem Compiler mitteilt, dass diese Variable von anderen geändert wird und daher gelesen werden muss oder in den Speicher geschrieben, wenn darauf zugegriffen oder geändert wird.
Eine Variable, die als flüchtig markiert ist, führt uns also nicht in das Land der atomaren Inkrementierungsoperationen, sondern bringt uns nur so nahe, wie wir es bereits dachten.
Das Inkrement atomar machen
Sobald wir eine flüchtige Variable verwenden, können wir unsere Inkrementierungsoperation atomar machen, indem wir eine bedingte Set-Operation auf niedriger Ebene verwenden, die von den meisten modernen CPUs unterstützt wird (häufig als Vergleichen und Setzen oder Vergleichen und Tauschen bezeichnet ). Dieser Ansatz wird beispielsweise in der AtomicInteger- Klasse von Java verwendet :
197 /**
198 * Atomically increments by one the current value.
199 *
200 * @return the updated value
201 */
202 public final int incrementAndGet() {
203 for (;;) {
204 int current = get();
205 int next = current + 1;
206 if (compareAndSet(current, next))
207 return next;
208 }
209 }
Die obige Schleife führt die folgenden Schritte wiederholt aus, bis Schritt 3 erfolgreich ist:
- Liest den Wert einer flüchtigen Variablen direkt aus dem Speicher.
- Erhöhen Sie diesen Wert.
- Ändern Sie den Wert (im Hauptspeicher) genau dann, wenn sein aktueller Wert im Hauptspeicher mit dem Wert übereinstimmt, den wir ursprünglich mithilfe einer speziellen atomaren Operation gelesen haben.
Wenn Schritt 3 fehlschlägt (weil der Wert nach Schritt 1 von einem anderen Thread geändert wurde), liest er die Variable erneut direkt aus dem Hauptspeicher und versucht es erneut.
In diesem Fall ist das Vergleichen und Austauschen zwar teuer, aber etwas besser als das Sperren. Wenn ein Thread nach Schritt 1 angehalten wird, müssen andere Threads, die Schritt 1 erreichen, nicht blockieren und auf den ersten Thread warten kann kostspielige Kontextumschaltung verhindern. Wenn der erste Thread fortgesetzt wird, schlägt der erste Versuch, die Variable zu schreiben, fehl, er kann jedoch fortfahren, indem er die Variable erneut liest. Dies ist wiederum wahrscheinlich kostengünstiger als der Kontextwechsel, der beim Sperren erforderlich gewesen wäre.
So können wir durch Vergleichen und Tauschen in das Land der atomaren Inkremente (oder anderer Operationen an einer einzelnen Variablen) gelangen, ohne tatsächliche Sperren zu verwenden.
Wann ist das Sperren also unbedingt erforderlich?
Wenn Sie mehr als eine Variable in einer atomaren Operation ändern müssen, ist eine Sperrung erforderlich, für die Sie keine spezielle Prozessoranweisung finden.
Solange Sie an einer einzelnen Variablen arbeiten und auf die fehlgeschlagene Arbeit vorbereitet sind und die Variable lesen und erneut beginnen müssen, ist Compare-and-Swap jedoch ausreichend.
Angenommen, jeder Thread addiert zuerst 2 zu einer Variablen X und multipliziert dann X mit zwei.
Wenn X anfänglich eins ist und zwei Threads ausgeführt werden, erwarten wir, dass das Ergebnis (((1 + 2) * 2) + 2) * 2 = 16 ist.
Wenn jedoch die Threads verschachteln, können wir, auch wenn alle Operationen atomar sind, stattdessen beide Additionen zuerst auftreten lassen und die Multiplikationen folgen, was zu (1 + 2 + 2) * 2 * 2 = 20 führt.
Dies geschieht, weil Multiplikation und Addition keine kommutativen Operationen sind.
Also, die Operationen selbst sind nicht atomar genug, wir müssen die Kombination von Operationen atomar machen.
Wir können dies entweder durch Sperren zur Serialisierung des Prozesses tun, oder wir können eine lokale Variable verwenden, um den Wert von X zu Beginn unserer Berechnung zu speichern, eine zweite lokale Variable für die Zwischenschritte und dann Compare-and-Swap to verwenden Legen Sie einen neuen Wert nur dann fest, wenn der aktuelle Wert von X mit dem ursprünglichen Wert von X übereinstimmt. Wenn dies fehlschlägt, müssen Sie X erneut lesen und die Berechnungen erneut durchführen.
Es gibt mehrere Kompromisse: Je länger die Berechnungen werden, desto wahrscheinlicher wird es, dass der laufende Thread angehalten wird und der Wert von einem anderen Thread geändert wird, bevor wir fortfahren, was bedeutet, dass Fehler viel wahrscheinlicher werden und zu einer Verschwendung führen Prozessorzeit. Im Extremfall einer großen Anzahl von Threads mit sehr langen Berechnungen können 100 Threads die Variable lesen und an Berechnungen teilnehmen. In diesem Fall kann der neue Wert nur vom ersten bis zum Ende geschrieben werden, die anderen 99 Threads bleiben bestehen Vervollständigen Sie ihre Berechnungen, aber stellen Sie nach Abschluss fest, dass sie den Wert nicht aktualisieren können. An diesem Punkt wird jeder den Wert lesen und die Berechnung von vorne beginnen. Wahrscheinlich werden die verbleibenden 99 Threads das gleiche Problem wiederholen und viel Prozessorzeit verschwenden.
Eine vollständige Serialisierung des kritischen Abschnitts über Sperren wäre in dieser Situation viel besser: 99 Threads würden angehalten, wenn sie die Sperre nicht bekämen, und wir würden jeden Thread in der Reihenfolge ihrer Ankunft am Sperrpunkt ausführen.
Wenn die Serialisierung nicht kritisch ist (wie in unserem inkrementellen Fall) und die Berechnungen, die verloren gehen würden, wenn die Aktualisierung der Nummer fehlschlägt, minimal sind, kann die Verwendung der Compare-and-Swap-Operation aufgrund dieser Operation einen erheblichen Vorteil bringen ist billiger als das Schließen.