Können Sie erklären, warum mehrere Threads Sperren für eine Single-Core-CPU benötigen?


18

Angenommen, diese Threads werden in einer Single-Core-CPU ausgeführt. Als CPU nur einen Befehl in einem Zyklus ausführen. Das heißt, obwohl sie die CPU-Ressource gemeinsam nutzen. aber der Computer stellt sicher, dass eine einmalige Anweisung. Ist die Sperre also für das Multi-Threading nicht erforderlich?


Weil Software-Transaktionsspeicher noch kein Mainstream ist.
Dan_waterworth

@dan_waterworth Weil der Software-Transaktionsspeicher bei nicht-trivialer Komplexität schwer ausfällt, meinen Sie? ;)
Mason Wheeler

Ich wette, Rich Hickey ist damit nicht einverstanden.
Robert Harvey

@MasonWheeler, während das nicht-triviale Sperren erstaunlich gut funktioniert und noch nie eine Quelle subtiler Fehler war, die schwer aufzuspüren sind? STM funktioniert gut mit nicht-trivialen Komplexitätsstufen, ist jedoch problematisch, wenn es zu Konflikten kommt. In diesen Fällen, so etwas wie diese , die eine restriktivere Form von STM ist , ist besser. Übrigens, mit der Titeländerung habe ich eine Weile gebraucht, um herauszufinden, warum ich so kommentiert habe.
Dan_waterworth

Antworten:


32

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:

  1. Lesen Sie die Anzahl der Treffer aus dem Speicher in ein Prozessorregister
  2. Erhöhe diese Zahl.
  3. 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:

  1. Liest den Wert einer flüchtigen Variablen direkt aus dem Speicher.
  2. Erhöhen Sie diesen Wert.
  3. Ä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.


aber was ist, wenn die Zählerhöhung atomar ist, war die Sperre notwendig?
Pythonee

@pythonee: Wenn das Zählerinkrement atomar ist, dann möglicherweise nicht. In jedem Multithread-Programm mit angemessener Größe müssen jedoch nicht-atomare Aufgaben auf einer gemeinsam genutzten Ressource ausgeführt werden.
Doc Brown

1
Es sei denn, Sie verwenden einen Compiler, um das Inkrement atomar zu machen, ist dies wahrscheinlich nicht der Fall.
Mike Larsen

Ja, wenn das Lesen / Ändern (Inkrementieren) / Schreiben atomar ist, ist die Sperre für diese Operation nicht erforderlich. Die Anweisung DEC-10 AOSE (Addiere Eins und Überspringe, wenn Ergebnis == 0) wurde atomarisiert, damit sie als Test-and-Set-Semaphor verwendet werden kann. Das Handbuch erwähnt, dass es gut genug war, da die Maschine mehrere Tage ununterbrochenes Zählen benötigte, um ein 36-Bit-Register vollständig zu rollen. JETZT wird jedoch nicht alles, was Sie tun, "eine zum Speicher hinzufügen".
John R. Strohm

Ich habe meine Antwort aktualisiert, um einige dieser Bedenken auszuräumen: Ja, Sie können die Operation atomar machen, aber nein, selbst auf Architekturen, die sie unterstützen, wird sie standardmäßig nicht atomar sein, und es gibt Situationen, in denen die Atomarität nicht vorhanden ist Es ist ausreichend und eine vollständige Serialisierung erforderlich. Das Sperren ist der einzige mir bekannte Mechanismus, um eine vollständige Serialisierung zu erreichen.
Theodore Murdock

4

Betrachten Sie dieses Zitat:

Einige Leute denken, wenn sie mit einem Problem konfrontiert werden: "Ich weiß, ich werde Threads verwenden", und dann haben sie zwei Poblesmen

Sie sehen, auch wenn 1 Befehl zu einem bestimmten Zeitpunkt auf einer CPU ausgeführt wird, umfassen Computerprogramme viel mehr als nur atomare Montageanweisungen. Wenn Sie beispielsweise in die Konsole (oder in eine Datei) schreiben, müssen Sie die Sperre aktivieren, um sicherzustellen, dass die Konsole wie gewünscht funktioniert.


Ich dachte, das Zitat war reguläre Ausdrücke, keine Threads?
user16764

3
Das Zitat ist für mich viel zutreffender (die Wörter / Zeichen werden aufgrund von Threading-Problemen nicht in der richtigen Reihenfolge gedruckt). Derzeit enthält die Ausgabe jedoch ein zusätzliches "s", was darauf hindeutet, dass der Code drei Probleme aufweist.
Theodore Murdock

1
Es ist eine Nebenwirkung. Sehr gelegentlich konnte man 1 plus 1 addieren und 4294967295 erhalten :)
gbjbaanb

3

Es scheint, dass viele Antworten versucht haben, das Sperren zu erklären, aber ich denke, dass OP eine Erklärung dafür benötigt, was Multitasking tatsächlich ist.

Wenn auf einem System mehr als ein Thread ausgeführt wird, auch wenn nur eine CPU vorhanden ist, gibt es zwei Hauptmethoden, die bestimmen, wie diese Threads geplant werden (dh in Ihre Single-Core-CPU eingefügt werden):

  • Kooperatives Multitasking - In Win9x musste jede Anwendung die Kontrolle explizit aufgeben. In diesem Fall brauchen Sie sich keine Gedanken über das Sperren zu machen, da Sie garantiert sind, dass Thread A niemals unterbrochen wird, solange er einen Algorithmus ausführt
  • Präventives Multitasking - Wird in den meisten modernen Betriebssystemen (Win2k und höher) verwendet. Dies verwendet Zeitscheiben und unterbricht Threads, selbst wenn sie noch arbeiten. Dies ist viel robuster, da ein einzelner Thread niemals Ihre gesamte Maschine hängen kann, was bei kooperativem Multitasking eine echte Möglichkeit war. Auf der anderen Seite müssen Sie sich jetzt um Sperren kümmern, da zu einem bestimmten Zeitpunkt einer Ihrer Threads unterbrochen (dh vorab freigegeben) werden kann und das Betriebssystem möglicherweise die Ausführung eines anderen Threads plant. Wenn Sie Multithread-Anwendungen mit diesem Verhalten codieren, MÜSSEN Sie berücksichtigen, dass zwischen jeder Codezeile (oder sogar jeder Anweisung) möglicherweise ein anderer Thread ausgeführt wird. Jetzt wird das Sperren auch mit einem einzigen Kern sehr wichtig, um einen konsistenten Zustand Ihrer Daten zu gewährleisten.

0

Das Problem liegt nicht bei den einzelnen Operationen, sondern bei den größeren Aufgaben, die die Operationen ausführen.

Viele Algorithmen werden unter der Annahme geschrieben, dass sie die volle Kontrolle über den Zustand haben, in dem sie arbeiten. Bei einem verschachtelten, geordneten Ausführungsmodell wie dem von Ihnen beschriebenen können die Operationen willkürlich miteinander verschachtelt werden, und wenn sie den Status gemeinsam haben, besteht das Risiko, dass der Status inkonsistent ist.

Sie können es mit Funktionen vergleichen, die eine Invariante vorübergehend unterbrechen, um das zu tun, was sie tun. Solange der Zwischenstaat von außen nicht einsehbar ist, können sie tun, was sie wollen, um ihre Aufgabe zu erfüllen.

Wenn Sie gleichzeitig Code schreiben, müssen Sie sicherstellen, dass der Konfliktstatus als unsicher eingestuft wird, es sei denn, Sie haben exklusiven Zugriff darauf. Der übliche Weg, um einen exklusiven Zugriff zu erzielen, ist die Synchronisation auf einem Synchronisationsprimitiv, wie das Halten einer Sperre.

Eine andere Tendenz, zu der Synchronisationsprimitive auf einigen Plattformen führen, besteht darin, dass sie Speicherbarrieren emittieren, die die Konsistenz des Arbeitsspeichers zwischen den CPUs sicherstellen.


0

Mit Ausnahme der Einstellung 'bool' gibt es keine Garantie (zumindest in c), dass das Lesen oder Schreiben einer Variablen nur eine Anweisung erfordert - oder vielmehr nicht während des Lesens / Schreibens unterbrochen werden kann


Wie viele Anweisungen würde das Setzen einer 32-Bit-Ganzzahl benötigen?
DXM

1
Können Sie Ihre erste Aussage etwas erweitern? Sie implizieren, dass nur ein Bool atomar gelesen / geschrieben werden kann, aber das ergibt keinen Sinn. Ein "Bool" ist in der Hardware eigentlich nicht vorhanden. Es wird normalerweise entweder als Byte oder als Wort implementiert. Wie kann man also nur booldiese Eigenschaft haben? Und reden Sie über das Laden aus dem Speicher, das Ändern und Zurückschieben in den Speicher, oder reden Sie auf Registerebene? Alle Lese- / Schreibvorgänge in die Register werden nicht unterbrochen, aber das Laden von Mem und das Speichern von Mem erfolgen nicht (da dies allein 2 Anweisungen sind, muss mindestens 1 weitere Anweisung ausgeführt werden, um den Wert zu ändern).
Corbin

1
Das Konzept eines einzelnen Befehls in einer Hyper-Threaded- / Multicore- / Branch-Predicted- / Multi-Cached-CPU ist etwas knifflig. Der Standard besagt jedoch, dass nur "bool" gegen einen Kontextwechsel während eines Lese- / Schreibvorgangs sicher sein muss einer einzelnen Variablen. Es gibt einen Boost :: Atomic, der Mutex um andere Typen wickelt, und ich denke, der C ++ 11 fügt einige weitere Threading-Garantien hinzu
Martin Beckett,

Die Erklärung the standard says that only 'bool' needs to be safe against a context switch in the middle of a read/write of a single variablesollte wirklich zur Antwort hinzugefügt werden.
Wolf

0

Geteilte Erinnerung.

Es ist die Definition von ... Threads : eine Reihe von gleichzeitigen Prozessen mit gemeinsamem Speicher.

Wenn kein gemeinsamer Speicher vorhanden ist, werden sie normalerweise als Old-School-UNIX- Prozesse bezeichnet.
Sie benötigen jedoch gelegentlich eine Sperre, wenn sie auf eine freigegebene Datei zugreifen.

(Shared Memory in UNIX-ähnlichen Kerneln wurde in der Tat normalerweise mit einem gefälschten Dateideskriptor implementiert, der die Shared Memory-Adresse darstellt.)


0

Eine CPU führt jeweils einen Befehl aus, aber was ist, wenn Sie zwei oder mehr CPUs haben?

Sie haben Recht, dass Sperren nicht benötigt werden, wenn Sie das Programm so schreiben können, dass es atomare Anweisungen ausnutzt: Anweisungen, deren Ausführung auf dem angegebenen Prozessor nicht unterbrechbar und frei von Störungen durch andere Prozessoren ist.

Sperren sind erforderlich, wenn mehrere Befehle vor Interferenzen geschützt werden müssen und es keinen äquivalenten atomaren Befehl gibt.

Das Einfügen eines Knotens in eine doppelt verknüpfte Liste erfordert beispielsweise die Aktualisierung mehrerer Speicherorte. Vor dem Einfügen und nach dem Einfügen behalten bestimmte Invarianten die Struktur der Liste bei. Während des Einfügens werden diese Invarianten jedoch vorübergehend unterbrochen: Die Liste befindet sich im Status "In Bearbeitung".

Wenn ein anderer Thread die Liste durchläuft, während die Invarianten oder auch versucht, sie zu ändern, während sie sich in einem solchen Zustand befinden, wird die Datenstruktur möglicherweise beschädigt und das Verhalten ist unvorhersehbar: Möglicherweise stürzt die Software ab oder es werden falsche Ergebnisse angezeigt. Es ist daher notwendig, dass Threads sich irgendwie einig sind, dass sie sich nicht im Weg stehen, wenn die Liste aktualisiert wird.

Entsprechend gestaltete Listen können mit atomaren Anweisungen manipuliert werden, so dass keine Sperren erforderlich sind. Algorithmen hierfür heißen "lock free". Beachten Sie jedoch, dass atomare Anweisungen tatsächlich eine Form der Verriegelung sind. Sie sind speziell in Hardware implementiert und arbeiten über die Kommunikation zwischen Prozessoren. Sie sind teurer als ähnliche Anweisungen, die nicht atomar sind.

Auf Multiprozessoren, denen der Luxus atomarer Anweisungen fehlt, müssen Grundelemente für den gegenseitigen Ausschluss aus einfachen Speicherzugriffen und Abfrageschleifen aufgebaut werden. An solchen Problemen haben Edsger Dijkstra und Leslie Lamport gearbeitet.


Zu Ihrer Information, ich habe von sperrenfreien Algorithmen gelesen, mit denen doppelt verknüpfte Listenaktualisierungen mit nur einem einzigen Compare-and-Swap verarbeitet werden können. Außerdem habe ich ein Whitepaper über eine Einrichtung gelesen, die anscheinend in Bezug auf Hardware viel billiger ist als ein Double-Compare-and-Swap-Verfahren (das im 68040 implementiert wurde, aber in anderen 68xxx-Prozessoren nicht durchgeführt wurde): Verlängern Sie die Auslastung -linked / store-conditional, um zwei verknüpfte Ladevorgänge und bedingte Speicher zuzulassen, mit der Maßgabe, dass ein Zugriff zwischen den beiden Speichern den ersten nicht zurücksetzt. Das ist viel einfacher zu implementieren als ein Double-Compare-and-Store ...
Supercat

... bietet jedoch ähnliche Vorteile, wenn Sie versuchen, Updates für doppelt verknüpfte Listen zu verwalten. Soweit ich das beurteilen kann, hat sich das Double-Linked-Load noch nicht durchgesetzt, aber die Hardwarekosten wären bei Nachfrage recht günstig.
Supercat
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.