6 Stimmen

Thread-Synchronisation. Warum genau diese Sperre nicht ausreicht, um Threads zu synchronisieren

Mögliches Duplikat:
Synchronisierung der Threads. Wie genau macht Sperren den Zugriff auf den Speicher "korrekt"?

Diese Frage ist inspiriert von diese.

Wir haben folgende Testklasse

class Test
{
    private static object ms_Lock=new object();
    private static int ms_Sum = 0;

    public static void Main ()
    {
        Parallel.Invoke(HalfJob, HalfJob);
        Console.WriteLine(ms_Sum);
        Console.ReadLine();
    }

    private static void HalfJob()
    {
        for (int i = 0; i < 50000000; i++) {
            lock(ms_Lock) { }// empty lock

            ms_Sum += 1;
        }
    }
}

Das tatsächliche Ergebnis liegt sehr nahe am erwarteten Wert 100 000 000 (50 000 000 x 2, da 2 Schleifen gleichzeitig laufen), mit einer Differenz von etwa 600 - 200 (der Fehler beträgt auf meinem Rechner ca. 0,0004 %, was sehr gering ist). Keine andere Art der Synchronisierung kann eine derartige Annäherung bieten (entweder ist der Fehlerprozentsatz viel größer oder er ist 100% korrekt).

Wir gehen derzeit davon aus, dass dieses Maß an Genauigkeit darauf zurückzuführen ist, dass das Programm auf folgende Weise läuft:

enter image description here

Die Zeit läuft von links nach rechts, und 2 Threads werden durch zwei Zeilen dargestellt.

wobei

  • Die Blackbox steht für den Prozess des Erfassens, Haltens und Freigebens der

  • Sperre plus steht für Additionsoperation ( Schema steht für Skalierung auf meinem PC, Sperren dauert schätzungsweise 20 Mal länger als Addieren)

  • Das weiße Kästchen stellt den Zeitraum dar, in dem versucht wird, eine Sperre zu erreichen, und dem weiteren Warten auf die Verfügbarkeit der Sperre

Außerdem bietet das Schloss einen vollständigen Speicherschutz.

Die Frage ist nun: Wenn das obige Schema das darstellt, was vor sich geht, was ist dann die Ursache für einen so großen Fehler (jetzt sieht das Schema mit der großen Ursache wie ein sehr starkes Syncrhonisierungsschema aus)? Wir könnten den Unterschied zwischen 1-10 an den Grenzen verstehen, aber das ist eindeutig nicht der einzige Grund für den Fehler? Wir können nicht sehen, wann Schreibvorgänge in ms_Sum gleichzeitig stattfinden können, um den Fehler zu verursachen.

EDIT。 Viele Menschen ziehen gerne vorschnelle Schlüsse. Ich weiß, was Synchronisation ist, und das obige Konstrukt ist kein echter oder annähernd guter Weg, um Threads zu synchronisieren, wenn wir ein korrektes Ergebnis benötigen. Haben Sie etwas Vertrauen in den Poster oder lesen Sie vielleicht zuerst die verlinkte Antwort. Ich brauche keine Möglichkeit, 2 Threads zu synchronisieren, um Additionen parallel durchzuführen, ich erforsche diese extravagante und dennoch effiziente Methode, verglichen mit jede möglich und ungefähre alternativ, Synchronisationskonstrukt (es synchronisiert bis zu einem gewissen Grad, so dass seine nicht bedeutungslos wie vorgeschlagen)

7voto

Andrey Punkte 57704

lock(ms_Lock) { } das ist ein sinnloses Konstrukt. lock garantiert die ausschließliche Ausführung des darin enthaltenen Codes.

Lassen Sie mich erklären, warum diese leere lock verringert (aber nicht eliminiert!) das Risiko der Datenbeschädigung. Lassen Sie uns das Threading-Modell ein wenig vereinfachen:

  1. Der Thread führt eine Codezeile pro Zeitscheibe aus.
  2. Die Thread-Planung erfolgt nach dem strikten Round-Robin-Prinzip (A-B-A-B).
  3. Die Ausführung von Monitor.Enter/Exit dauert wesentlich länger als die von Arithmetik. (Sagen wir mal 3 mal länger. Ich habe den Code mit Nop s, die bedeuten, dass die vorherige Zeile noch ausgeführt wird).
  4. Real += braucht 3 Schritte. Ich habe sie in atomare Schritte unterteilt.

In der linken Spalte ist angegeben, welche Zeile in der Zeitscheibe der Threads (A und B) ausgeführt wird. In der rechten Spalte - das Programm (nach meinem Modell).

A   B           
1           1   SomeOperation();
    1       2   SomeOperation();
2           3   Monitor.Enter(ms_Lock);
    2       4   Nop();
3           5   Nop();
4           6   Monitor.Exit(ms_Lock);
5           7   Nop();
7           8   Nop();
8           9   int temp = ms_Sum;
    3       10  temp++;
9           11  ms_Sum = temp;                          
    4           
10              
    5           
11              

A   B           
1           1   SomeOperation();
    1       2   SomeOperation();
2           3   int temp = ms_Sum;
    2       4   temp++;
3           5   ms_Sum = temp;                          
    3           
4               
    4           
5               
    5           

Wie Sie im ersten Szenario sehen, kann Thread B Thread A nicht einholen und A hat genug Zeit, die Ausführung von ms_Sum += 1; . Im zweiten Szenario wird die ms_Sum += 1; ist verschachtelt und verursacht eine ständige Datenverfälschung. In Wirklichkeit ist die Planung der Threads stochastisch, aber das bedeutet, dass Thread A mehr ändern um die Erhöhung zu beenden, bevor ein anderer Thread dort ankommt.

6voto

Branko Dimitrijevic Punkte 48944

Dies ist eine sehr enge Schleife, in der nicht viel los ist. ms_Sum += 1 eine vernünftige Chance hat, von den parallelen Threads "genau im falschen Moment" ausgeführt zu werden.

Warum sollten Sie einen solchen Code in der Praxis überhaupt schreiben?

Warum nicht?

lock(ms_Lock) { 
    ms_Sum += 1;
}

oder einfach:

Interlocked.Increment(ms_Sum);

?

-- EDIT ---

Einige Anmerkungen dazu, warum Sie den Fehler trotz des Speicherbarriere-Aspekts der Sperre sehen würden... Stellen Sie sich das folgende Szenario vor:

  • Thread A betritt die lock lässt die lock und wird dann vom Betriebssystem-Scheduler vorgezogen.
  • Thread B betritt und verlässt die lock (möglicherweise einmal, möglicherweise mehr als einmal, möglicherweise millionenfach).
  • Zu diesem Zeitpunkt wird der Thread A wieder eingeplant.
  • Sowohl A als auch B haben die ms_Sum += 1 zur gleichen Zeit, was dazu führt, dass einige Inkremente verloren gehen (weil Inkrement = Laden + Addieren + Speichern ).

2voto

Nicholas Carey Punkte 64738

Wie bereits erwähnt: lock(ms_Lock) { } sperrt einen leeren Block und tut daher nichts. Sie haben immer noch eine Race Condition mit ms_Sum += 1; . Sie benötigen:

lock( ms_Lock )
{
  ms_Sum += 1 ;
}

[Anm. d. Red:]

Wenn Sie den Zugriff auf ms_Sum nicht ordnungsgemäß serialisieren, haben Sie eine Race Condition. Ihr Code, wie geschrieben tut das folgende (vorausgesetzt, der Optimierer nicht einfach wegwerfen die nutzlos lock-Anweisung:

  • Sperre erwerben
  • Sperre freigeben
  • Wert von ms_Sum abrufen
  • Wert von ms_Sum inkrementieren
  • Wert von ms_Sum speichern

Jeder Thread kann zu jedem Zeitpunkt unterbrochen werden, auch mitten in einer Anweisung. Sofern nicht ausdrücklich dokumentiert ist, dass es sich um eine atomare Anweisung handelt, kann jede Maschinenanweisung, die mehr als einen Taktzyklus zur Ausführung benötigt, mitten in der Ausführung unterbrochen werden.

Nehmen wir also an, dass Ihre Sperre die beiden Threads tatsächlich serialisiert. Es gibt immer noch nichts, was verhindern könnte, dass ein Thread angehalten wird (und damit dem anderen den Vorrang gibt), während er sich mitten in der Ausführung der letzten drei Schritte befindet.

Der erste Thread kommt also herein, sperrt, gibt frei, erhält den Wert von ms_Sum und wird dann angehalten. Der zweite Thread kommt herein, sperrt, gibt frei, holt sich den [gleichen] Wert von ms_Sum, inkrementiert ihn und speichert den neuen Wert wieder in ms_Sum, dann wird er angehalten. Der 1. Thread inkrementiert seinen Jetzt-ist-die-Zeit-Wert und speichert ihn.

Das ist die Bedingung für das Rennen.

2voto

Wie bereits erwähnt, ist die Erklärung

lock(ms_Lock) {}

wird eine volle Speicherbarriere . Kurz gesagt bedeutet dies, dass der Wert von ms_Sum wird zwischen allen Caches gespült und in allen Threads aktualisiert ("sichtbar").

Allerdings, ms_Sum += 1 es immer noch nicht atomar denn es ist nur eine Abkürzung für ms_Sum = ms_Sum + 1 : ein Lesen, eine Operation und eine Zuweisung. In diesem Konstrukt gibt es noch eine Race Condition - die Anzahl der ms_Sum sein könnte etwas niedriger als erwartet. Ich würde auch erwarten, dass der Unterschied mehr ohne die Speichersperre.

Hier ist eine hypothetische Situation, warum sie niedriger sein könnte (A und B stehen für Threads und a und b für Thread-lokale Register):

A: read ms\_Sum -> a
B: read ms\_Sum -> b
A: write a + 1 -> ms\_Sum
B: write b + 1 -> ms\_Sum // change from A "discarded"

Dies hängt von einer ganz bestimmten Reihenfolge der Verschachtelung ab und ist abhängig von Faktoren wie der Granularität der Thread-Ausführung und der relativen Zeit, die in der genannten nichtatomaren Region verbracht wird. Ich vermute, dass die lock selbst verringert (aber nicht beseitigt) die Wahrscheinlichkeit der oben genannten Verschachtelung, da jeder Thread der Reihe nach warten muss, um ihn zu durchlaufen. Die relative Zeit, die in der Sperre selbst im Vergleich zum Inkrement verbracht wird, kann ebenfalls einen Faktor spielen.

Viel Spaß beim Kodieren.


Wie andere bereits angemerkt haben, sollten Sie den kritischen Bereich verwenden, der durch die Sperre oder eines der zur Verfügung gestellten atomaren Inkremente festgelegt wird, um es wirklich thread-sicher zu machen.

0voto

Miguel Angelo Punkte 23208

Der Operator += ist nicht atomar, d.h. er liest zuerst und schreibt dann den neuen Wert. In der Zwischenzeit, zwischen Lesen und Schreiben, könnte der Thread A zum anderen B wechseln, und zwar ohne den Wert zu schreiben... dann sieht der andere Thread B den neuen Wert nicht, weil er nicht vom anderen Thread A zugewiesen wurde... und bei der Rückkehr zum Thread A wird die gesamte Arbeit des Threads B verworfen.

CodeJaeger.com

CodeJaeger ist eine Gemeinschaft für Programmierer, die täglich Hilfe erhalten..
Wir haben viele Inhalte, und Sie können auch Ihre eigenen Fragen stellen oder die Fragen anderer Leute lösen.

Powered by:

X