.NET: Tools zum Arbeiten mit Multithreading und Asynchronität. Teil 2

Ich veröffentliche den Originalartikel über Habr, dessen Übersetzung im Codingsight- Blog veröffentlicht ist.

Ich erstelle weiterhin eine Textversion meines Vortrags beim Multithreading-Meeting. Der erste Teil ist hier oder hier zu finden. Dort ging es mehr um die grundlegenden Tools zum Starten eines Threads oder einer Aufgabe, um Möglichkeiten, ihren Status anzuzeigen, und um einige süße Kleinigkeiten wie PLinq. In diesem Artikel möchte ich mich mehr auf die Probleme konzentrieren, die in einer Multithread-Umgebung auftreten können, und auf einige Möglichkeiten, sie zu lösen.

Inhalt





Informationen zu freigegebenen Ressourcen


Es ist unmöglich, ein Programm zu schreiben, das in mehreren Threads funktioniert, aber gleichzeitig keine einzige gemeinsam genutzte Ressource hat: Selbst wenn es auf Ihrer Abstraktionsebene funktioniert, stellt sich heraus, dass es immer noch eine gemeinsame Ressource gibt, wenn Sie eine oder mehrere Ebenen darunter hinuntergehen. Ich werde einige Beispiele geben:

Beispiel 1:

Aus Angst vor möglichen Problemen haben Sie Threads mit verschiedenen Dateien arbeiten lassen. Per Datei zum Streamen. Es scheint Ihnen, dass das Programm keine einzige gemeinsame Ressource hat.

Nachdem wir mehrere Ebenen tiefer gegangen sind, wissen wir, dass es nur eine Festplatte gibt und deren Treiber oder Betriebssystem die Probleme lösen muss, den Zugriff darauf sicherzustellen.

Beispiel 2:

Nachdem Sie Beispiel 1 gelesen haben, haben Sie beschlossen, die Dateien auf zwei verschiedenen Remote-Computern mit zwei physisch unterschiedlichen Eisenstücken und Betriebssystemen abzulegen. Wir halten 2 verschiedene Verbindungen über FTP oder NFS.

Nachdem wir einige Ebenen weiter unten sind, haben wir verstanden, dass sich nichts geändert hat und der Treiber der Netzwerkkarte oder das Betriebssystem des Computers, auf dem das Programm ausgeführt wird, das Problem des wettbewerbsfähigen Zugriffs lösen muss.

Beispiel 3:

Nachdem Sie einen beträchtlichen Teil Ihrer Haare verloren haben, um die Möglichkeit des Schreibens eines Multithread-Programms zu beweisen, lehnen Sie Dateien vollständig ab und zerlegen die Berechnungen in zwei verschiedene Objekte, von denen jeweils nur ein Stream für Links verfügbar ist.

Ich hämmere das letzte Dutzend Nägel in den Sarg dieser Idee: Ein Laufzeit- und Garbage Collector, ein Thread-Scheduler, physisch ein RAM und ein Speicher, ein Prozessor sind immer noch gemeinsam genutzte Ressourcen.

Wir haben also herausgefunden, dass es unmöglich ist, ein Multithread-Programm ohne eine einzige gemeinsam genutzte Ressource auf allen Abstraktionsebenen über die Breite des gesamten Technologie-Stacks zu schreiben. Glücklicherweise löst jede der Abstraktionsebenen in der Regel die Probleme des Wettbewerbszugriffs teilweise oder vollständig oder verbietet sie einfach (Beispiel: Jedes UI-Framework verbietet die Arbeit mit Elementen aus verschiedenen Threads). Daher treten Probleme am häufigsten bei gemeinsam genutzten Ressourcen auf Ihre Abstraktionsebene. Um sie zu lösen, führen Sie das Konzept der Synchronisation ein.

Mögliche Probleme beim Arbeiten in einer Multithread-Umgebung


Fehler in der Software können in mehrere Gruppen unterteilt werden:

  1. Das Programm erzeugt kein Ergebnis. Abstürze oder Einfrieren.
  2. Das Programm gibt ein falsches Ergebnis zurück.
  3. Das Programm liefert das richtige Ergebnis, erfüllt jedoch nicht die eine oder andere nicht funktionierende Anforderung. Läuft zu lange oder verbraucht zu viele Ressourcen.

In einer Umgebung mit mehreren Threads sind die beiden Hauptprobleme, die die Fehler 1 und 2 verursachen, Deadlock und Race Condition .

Deadlock


Deadlock - Deadlock. Es gibt viele verschiedene Variationen. Am häufigsten sind die folgenden:



Während Thread Nr. 1 etwas tat, blockierte Thread Nr. 2 Ressource B , etwas später blockierte Thread Nr. 1 Ressource A und versuchte, Ressource B zu sperren. Leider wird dies niemals passieren, da Thread 2 gibt Ressource B erst frei, nachdem Ressource A gesperrt wurde .

Rennbedingung


Rennbedingung - Rennbedingung. Die Situation, in der das Verhalten und das Ergebnis der vom Programm durchgeführten Berechnungen von der Arbeit des Laufzeit-Thread-Schedulers abhängt.
Die Unannehmlichkeit dieser Situation liegt genau in der Tatsache, dass Ihr Programm möglicherweise nicht nur einmal von hundert oder sogar von einer Million funktioniert.

Die Situation wird durch die Tatsache verschärft, dass Probleme zusammenpassen können, zum Beispiel: Bei einem bestimmten Verhalten des Thread-Schedulers tritt ein Deadlock auf.

Zusätzlich zu diesen beiden Problemen, die zu offensichtlichen Fehlern im Programm führen, gibt es auch Probleme, die möglicherweise nicht zu einem falschen Berechnungsergebnis führen, aber mehr Zeit oder Verarbeitungsleistung benötigen, um es zu erhalten. Zwei dieser Probleme sind: Busy Wait und Thread Starvation .

Beschäftigt warten


Busy-Wait ist ein Problem, bei dem das Programm Prozessorressourcen nicht für Berechnungen, sondern zum Warten verbraucht.

Oft sieht ein solches Problem im Code ungefähr so ​​aus:

while(!hasSomethingHappened) ; 

Dies ist ein Beispiel für extrem schlechten Code seitdem Ein solcher Code belegt vollständig einen Kern Ihres Prozessors und tut absolut nichts Nützliches. Dies kann nur dann gerechtfertigt werden, wenn es von entscheidender Bedeutung ist, eine Änderung eines Werts in einem anderen Thread zu verarbeiten. Und wenn ich schnell spreche, spreche ich über den Fall, dass Sie nicht einmal ein paar Nanosekunden warten können. In anderen Fällen, dh in allem, was ein gesundes Gehirn hervorbringen kann, ist es sinnvoller, ResetEvent-Sorten und ihre Slim-Versionen zu verwenden. Über sie unten.

Vielleicht schlägt einer der Leser vor, das Problem des vollständigen Ladens eines Kerns mit einer nutzlosen Wartezeit zu lösen, indem er der Schleife Konstrukte wie Thread.Sleep (1) hinzufügt. Dies wird das Problem wirklich lösen, aber ein anderes schaffen: Die Reaktionszeit auf die Änderung beträgt durchschnittlich eine halbe Millisekunde, was nicht viel, aber katastrophal mehr ist, als Sie die Synchronisationsprimitive der ResetEvent-Familie verwenden könnten.

Fadenhunger


Thread-Starvation ist ein Problem, bei dem im Programm zu viele Threads gleichzeitig arbeiten. Was bedeutet es genau die Flows, die mit Berechnungen beschäftigt sind und nicht nur auf eine Antwort von einem IO warten? Mit diesem Problem geht der gesamte mögliche Leistungsgewinn durch die Verwendung von Threads verloren, weil Der Prozessor verbringt viel Zeit damit, die Kontexte zu wechseln.
Es ist praktisch, mit verschiedenen Profilern nach solchen Problemen zu suchen. Im Folgenden finden Sie ein Beispiel für einen Screenshot des im Timeline-Modus gestarteten dotTrace- Profilers.


(Bild ist anklickbar)

In dem Programm, das nicht unter Streaming-Hunger leidet, wird in Diagrammen, die Streams widerspiegeln, keine rosa Farbe angezeigt. Darüber hinaus ist in der Kategorie Subsysteme klar, dass 30,6% des Programms auf die CPU gewartet haben.

Wenn ein solches Problem diagnostiziert wird, wird es ganz einfach gelöst: Sie haben zu viele Threads gleichzeitig gestartet, weniger oder nicht alle gleichzeitig gestartet.

Synchronisierungswerkzeuge



Verriegelt


Dies ist möglicherweise die einfachste Art der Synchronisierung. Interlocked ist eine Sammlung einfacher atomarer Operationen. Eine atomare Operation wird eine Operation genannt, zu deren Zeitpunkt nichts passieren kann. In .NET wird Interlocked durch die gleichnamige statische Klasse mit einer Reihe von Methoden dargestellt, von denen jede eine atomare Operation implementiert.

Um den Schrecken nichtatomarer Operationen zu erkennen, schreiben Sie ein Programm, das 10 Threads startet, von denen jeder eine Million Inkremente derselben Variablen erstellt, und drucken Sie am Ende ihrer Arbeit den Wert dieser Variablen aus - leider wird er sich darüber hinaus stark von 10 Millionen unterscheiden Jedes Mal, wenn das Programm gestartet wird, ist es anders. Dies geschieht, weil selbst eine so einfache Operation wie ein Inkrement nicht atomar ist, sondern das Extrahieren eines Werts aus dem Speicher, das Berechnen eines neuen Werts und das Zurückschreiben umfasst. Somit können zwei Threads gleichzeitig jede dieser Operationen ausführen. In diesem Fall geht das Inkrement verloren.

Die Interlocked-Klasse bietet Inkrementierungs- / Dekrementierungsmethoden, und es ist leicht zu erraten, was sie tun. Sie sind praktisch zu verwenden, wenn Sie Daten in mehreren Threads verarbeiten und etwas berücksichtigen. Ein solcher Code funktioniert viel schneller als das klassische Schloss. Wenn Interlocked für die im letzten Absatz beschriebene Situation verwendet wird, gibt das Programm in jeder Situation stabil 10 Millionen aus.

Die CompareExchange-Methode führt auf den ersten Blick eine eher nicht offensichtliche Funktion aus, aber all ihre Anwesenheit ermöglicht es Ihnen, viele interessante Algorithmen zu implementieren, insbesondere die Familie ohne Sperren.

 public static int CompareExchange (ref int location1, int value, int comparand); 

Die Methode nimmt drei Werte an: Der erste wird als Referenz übergeben, und dies ist der Wert, der in den zweiten geändert wird. Wenn zum Zeitpunkt des Vergleichs location1 mit compareand übereinstimmt, wird der ursprüngliche Wert von location1 zurückgegeben. Das klingt ziemlich verwirrend, da es einfacher ist, Code zu schreiben, der dieselben Vorgänge wie CompareExchange ausführt:

 var original = location1; if (location1 == comparand) location1 = value; return original; 

Nur eine Implementierung in der Interlocked-Klasse ist atomar. Das heißt, wenn wir solchen Code selbst geschrieben hätten, könnte eine Situation aufgetreten sein, in der die Bedingung location1 == compareand bereits erfüllt war, aber als der Ausdruck location1 = value ausgeführt wurde, hatte ein anderer Thread den Wert von location1 geändert und er ging verloren.

Ein gutes Beispiel für die Verwendung dieser Methode finden Sie im Code, den der Compiler für jedes C # -Ereignis generiert.

Schreiben wir eine einfache Klasse mit einem MyEvent-Ereignis:

 class MyClass { public event EventHandler MyEvent; } 

Lassen Sie uns das Projekt in der Release-Konfiguration erstellen und die Assembly mit dotPeek öffnen, wobei die Option Show Compiler Generated Code aktiviert ist :

 [CompilerGenerated] private EventHandler MyEvent; public event EventHandler MyEvent { [CompilerGenerated] add { EventHandler eventHandler = this.MyEvent; EventHandler comparand; do { comparand = eventHandler; eventHandler = Interlocked.CompareExchange<EventHandler>(ref this.MyEvent, (EventHandler) Delegate.Combine((Delegate) comparand, (Delegate) value), comparand); } while (eventHandler != comparand); } [CompilerGenerated] remove { // The same algorithm but with Delegate.Remove } } 

Hier können Sie sehen, dass der Compiler hinter den Kulissen einen ziemlich ausgeklügelten Algorithmus generiert hat. Dieser Algorithmus schützt vor dem Verlust eines Ereignisabonnements, wenn mehrere Threads dieses Ereignis gleichzeitig abonnieren. Lassen Sie uns die add-Methode detaillierter schreiben und uns daran erinnern, was die CompareExchange-Methode hinter den Kulissen tut

 EventHandler eventHandler = this.MyEvent; EventHandler comparand; do { comparand = eventHandler; // Begin Atomic Operation if (MyEvent == comparand) { eventHandler = MyEvent; MyEvent = Delegate.Combine(MyEvent, value); } // End Atomic Operation } while (eventHandler != comparand); 

Dies ist bereits etwas klarer, obwohl es wahrscheinlich noch einer Erklärung bedarf. In Worten würde ich diesen Algorithmus wie folgt beschreiben:

Wenn MyEvent immer noch das gleiche ist wie zu dem Zeitpunkt, als wir mit dem Ausführen von Delegate.Combine begonnen haben, schreiben Sie darin auf, was Delegate.Combine zurückgibt. Wenn dies nicht der Fall ist, spielen wir es erneut und wiederholen es, bis es herauskommt.


Es geht also kein Event-Abonnement verloren. Sie müssen ein ähnliches Problem lösen, wenn Sie plötzlich ein dynamisches thread-sicheres Array ohne Sperren implementieren möchten. Wenn mehrere Streams schnell Elemente hinzufügen, ist es wichtig, dass sie am Ende alle hinzugefügt werden.

Monitor.Enter, Monitor.Exit, sperren


Dies sind die am häufigsten verwendeten Konstrukte für die Thread-Synchronisation. Sie implementieren die Idee eines kritischen Abschnitts: Das heißt, Code, der zwischen Aufrufen von Monitor.Enter, Monitor.Exit für eine Ressource geschrieben wurde, kann gleichzeitig in nur einem Thread ausgeführt werden. Die lock-Anweisung ist syntaktischer Zucker für Enter / Exit-Aufrufe, die in try-finally eingeschlossen sind. Eine nette Funktion beim Implementieren eines kritischen Abschnitts in .NET ist die Möglichkeit, ihn für denselben Stream erneut einzugeben. Dies bedeutet, dass ein solcher Code ohne Probleme ausgeführt wird:

 lock(a) { lock (a) { ... } } 

Es ist natürlich unwahrscheinlich, dass jemand auf diese Weise schreibt, aber wenn Sie diesen Code in mehrere Methoden mit detailliertem Call-Stack einteilen, können Sie mit dieser Funktion einige Wenns sparen. Um einen solchen Trick zu ermöglichen, mussten die .NET-Entwickler eine Einschränkung hinzufügen - nur eine Instanz eines Referenztyps kann als Synchronisationsobjekt verwendet werden, und jedem Objekt, in das die Stream-ID geschrieben wird, werden implizit mehrere Bytes hinzugefügt.

Diese Funktion des kritischen Abschnitts in c # stellt eine interessante Einschränkung für die Funktionsweise der lock-Anweisung dar: Sie können die await-Anweisung nicht in der lock-Anweisung verwenden. Zuerst hat es mich überrascht, weil ein ähnliches Try-finally-Monitor.Enter / Exit-Konstrukt kompiliert wird. Was ist los? Hier ist es notwendig, den letzten Absatz noch einmal sorgfältig zu lesen und dann einige Kenntnisse über das Prinzip von async / await hinzuzufügen: Der Code nach dem Warten wird nicht unbedingt auf demselben Thread wie der Code vor dem Warten ausgeführt, er hängt vom Synchronisationskontext und dem Vorhandensein oder ab Kein Aufruf von ConfigureAwait. Daraus folgt, dass Monitor.Exit auf einem anderen Thread als Monitor.Enter ausgeführt werden kann, wodurch eine SynchronizationLockException ausgelöst wird. Wenn Sie es nicht glauben, können Sie den folgenden Code in einer Konsolenanwendung ausführen: Es wird eine SynchronizationLockException ausgelöst.

 var syncObject = new Object(); Monitor.Enter(syncObject); Console.WriteLine(Thread.CurrentThread.ManagedThreadId); await Task.Delay(1000); Monitor.Exit(syncObject); Console.WriteLine(Thread.CurrentThread.ManagedThreadId); 

Es ist bemerkenswert, dass in WinForms oder einer WPF-Anwendung dieser Code korrekt funktioniert, wenn er vom Hauptthread aufgerufen wird. Es wird einen Synchronisationskontext geben, der nach dem Warten eine Rückkehr zum UI-Thread implementiert. In jedem Fall sollten Sie nicht mit dem kritischen Abschnitt im Kontext des Codes spielen, der den Operator await enthält. In diesen Fällen ist es besser, Synchronisationsprimitive zu verwenden, auf die später noch eingegangen wird.

In Bezug auf die Arbeit des kritischen Abschnitts in .NET ist ein weiteres Merkmal seiner Implementierung zu erwähnen. Der kritische Abschnitt in .NET wird in zwei Modi ausgeführt: im Spin-Wait-Modus und im Kernel-Modus. Der Spin-Wait-Algorithmus wird zweckmäßigerweise als der folgende Pseudocode dargestellt:

 while(!TryEnter(syncObject)) ; 

Diese Optimierung zielt auf die schnellste Erfassung des kritischen Abschnitts in kurzer Zeit ab, basierend auf der Annahme, dass die Ressource, sobald sie ausgelastet ist, sich selbst freigeben wird. Wenn dies nicht in kurzer Zeit geschieht, wartet der Thread im Kernel-Modus, was wie die Rückkehr einige Zeit in Anspruch nimmt. .NET-Entwickler haben das Short-Lock-Szenario so weit wie möglich optimiert. Wenn jedoch viele Threads beginnen, den kritischen Abschnitt untereinander zu unterbrechen, kann dies zu einer hohen und plötzlichen CPU-Auslastung führen.

SpinLock, SpinWait


Da ich den Spin-Wait-Algorithmus erwähnt habe, sollten die BCL SpinLock- und SpinWait-Strukturen erwähnt werden. Sie sollten verwendet werden, wenn Grund zu der Annahme besteht, dass es immer die Möglichkeit gibt, sehr schnell ein Schloss zu schließen. Auf der anderen Seite lohnt es sich kaum, sich daran zu erinnern, bevor die Ergebnisse der Profilerstellung zeigen, dass die Verwendung anderer Synchronisationsprimitive der Engpass Ihres Programms ist.

Monitor.Wait, Monitor.Pulse [Alle]


Dieses Methodenpaar sollte zusammen betrachtet werden. Mit ihrer Hilfe können verschiedene Producer-Consumer-Szenarien implementiert werden.

Producer-Consumer - Ein Entwurfsmuster mit mehreren Prozessen / mehreren Threads, bei dem ein oder mehrere Threads / Prozesse, die Daten erzeugen, und ein oder mehrere Prozesse / Threads, die diese Daten verarbeiten, vorausgesetzt werden. Verwendet normalerweise eine gemeinsam genutzte Sammlung.

Beide Methoden können nur aufgerufen werden, wenn der Thread, der sie verursacht, derzeit gesperrt ist. Die Wait-Methode hebt die Sperre auf und bleibt hängen, bis ein anderer Thread Pulse aufruft.

Um die Arbeit zu demonstrieren, habe ich ein kleines Beispiel geschrieben:

 object syncObject = new object(); Thread t1 = new Thread(T1); t1.Start(); Thread.Sleep(100); Thread t2 = new Thread(T2); t2.Start(); 

(Ich habe das Bild und nicht den Text verwendet, um die Reihenfolge der Ausführung von Anweisungen visuell darzustellen.)

Analysieren: Stellen Sie zu Beginn des zweiten Streams eine Verzögerung von 100 ms ein, um sicherzustellen, dass die Ausführung später beginnt.
- T1: Der Stream der Zeile 2 wird gestartet
- T1: Der Strom der Linie 3 tritt in den kritischen Abschnitt ein
- T1: Linie 6, der Strom schläft ein
- T2: Der Stream der Zeile 3 wird gestartet
- T2: Zeile 4 friert ein, während auf einen kritischen Abschnitt gewartet wird
- T1: Zeile 7 gibt den kritischen Abschnitt frei und friert ein, während auf den Ausgang von Pulse gewartet wird
- T2: Linie 8 tritt in den kritischen Abschnitt ein
- T2: Zeile 11 benachrichtigt T1 mit der Pulse-Methode
- T2: Zeile 14 verlässt den kritischen Abschnitt. Bis dahin kann T1 die Ausführung nicht fortsetzen.
- T1: Linie 15 wacht auf
- T1: Zeile 16 verlässt den kritischen Abschnitt

MSDN hat eine wichtige Bemerkung zur Verwendung von Pulse / Wait-Methoden, nämlich: Monitor speichert keine Statusinformationen. Wenn die Pulse-Methode vor dem Aufruf der Wait-Methode aufgerufen wird, kann dies zu einem Deadlock führen. Wenn diese Situation möglich ist, ist es besser, eine der Klassen der ResetEvent-Familie zu verwenden.

Das vorherige Beispiel zeigt deutlich, wie die Wait / Pulse-Methoden der Monitor-Klasse funktionieren, lässt jedoch noch Fragen darüber offen, wann sie verwendet werden sollten. Ein gutes Beispiel wäre eine solche Implementierung von BlockingQueue <T>. Andererseits verwendet die Implementierung von BlockingCollection <T> aus System.Collections.Concurrent SemaphoreSlim für die Synchronisation.

ReaderWriterLockSlim


Dies ist mein geliebtes Synchronisationsprimitiv, dargestellt durch die gleichnamige System.Threading-Namespace-Klasse. Es scheint mir, dass viele Programme besser funktionieren würden, wenn ihre Entwickler diese Klasse anstelle der üblichen Sperre verwenden würden.

Idee: Viele Threads können lesen, nur ein Schreiben. Sobald der Stream seinen Wunsch zum Schreiben erklärt, können keine neuen Messwerte gestartet werden, sondern warten, bis die Aufzeichnung abgeschlossen ist. Es gibt auch das Konzept der aktualisierbaren Lesesperre, das verwendet werden kann, wenn Sie während des Lesevorgangs verstehen, dass Sie etwas schreiben müssen. Eine solche Sperre wird in einer atomaren Operation in eine Schreibsperre umgewandelt.

Es gibt auch eine ReadWriteLock-Klasse im System.Threading-Namespace, die jedoch für Neuentwicklungen dringend empfohlen wird. Mit der schlanken Version können Sie eine Reihe von Fällen vermeiden, die zu Deadlocks führen. Außerdem können Sie die Sperre schnell erfassen, weil unterstützt die Synchronisation im Spin-Wait-Modus, bevor Sie in den Kernel-Modus wechseln.

Wenn Sie zum Zeitpunkt des Lesens dieses Artikels noch nichts über diese Klasse wussten, haben Sie sich wahrscheinlich an viele Beispiele aus dem kürzlich geschriebenen Code erinnert, bei denen ein solcher Ansatz zum Sperren es dem Programm ermöglichen würde, effizient zu arbeiten.

Die Schnittstelle der ReaderWriterLockSlim-Klasse ist einfach und unkompliziert, ihre Verwendung kann jedoch kaum als bequem bezeichnet werden:

 var @lock = new ReaderWriterLockSlim(); @lock.EnterReadLock(); try { // ... } finally { @lock.ExitReadLock(); } 

Ich mag es, seine Verwendung in eine Klasse zu packen, was die Verwendung viel bequemer macht.
Idee: Um Read / WriteLock-Methoden zu erstellen, die ein Objekt mit der Dispose-Methode zurückgeben, können diese verwendet werden und unterscheiden sich durch die Anzahl der Zeilen kaum von der üblichen Sperre.

 class RWLock : IDisposable { public struct WriteLockToken : IDisposable { private readonly ReaderWriterLockSlim @lock; public WriteLockToken(ReaderWriterLockSlim @lock) { this.@lock = @lock; @lock.EnterWriteLock(); } public void Dispose() => @lock.ExitWriteLock(); } public struct ReadLockToken : IDisposable { private readonly ReaderWriterLockSlim @lock; public ReadLockToken(ReaderWriterLockSlim @lock) { this.@lock = @lock; @lock.EnterReadLock(); } public void Dispose() => @lock.ExitReadLock(); } private readonly ReaderWriterLockSlim @lock = new ReaderWriterLockSlim(); public ReadLockToken ReadLock() => new ReadLockToken(@lock); public WriteLockToken WriteLock() => new WriteLockToken(@lock); public void Dispose() => @lock.Dispose(); } 

Mit einem solchen Trick können Sie einfach weiter schreiben:

 var rwLock = new RWLock(); // ... using(rwLock.ReadLock()) { // ... } 


ResetEvent-Familie


Ich füge der Familie die Klassen ManualResetEvent, ManualResetEventSlim, AutoResetEvent hinzu.
Die ManualResetEvent-Klassen, ihre Slim-Version und die AutoResetEvent-Klasse können sich in zwei Zuständen befinden:
- In diesem Zustand werden alle Threads, die WaitOne aufgerufen haben, eingefroren (nicht signalisiert), bis das Ereignis in einen signalisierten Zustand übergeht.
- Im abgesenkten Zustand (signalisiert) werden in diesem Zustand alle am WaitOne-Aufruf hängenden Flows freigegeben. Alle neuen WaitOne-Aufrufe eines heruntergekommenen Ereignisses werden unter bestimmten Bedingungen sofort ausgeführt.

Die AutoResetEvent-Klasse unterscheidet sich von der ManualResetEvent-Klasse darin, dass sie automatisch in einen gespannten Zustand wechselt, nachdem genau ein Thread freigegeben wurde. Wenn mehrere Threads auf AutoResetEvent warten, gibt der Set-Aufruf im Gegensatz zu ManualResetEvent nur einen beliebigen Thread frei. ManualResetEvent gibt alle Threads frei.

Betrachten Sie ein Beispiel für AutoResetEvent:
 AutoResetEvent evt = new AutoResetEvent(false); Thread t1 = new Thread(T1); t1.Start(); Thread.Sleep(100); Thread t2 = new Thread(T2); t2.Start(); 


Das Beispiel zeigt, dass das Ereignis nur dann automatisch in einen gespannten Zustand (nicht signalisiert) versetzt wird, wenn der beim WaitOne-Aufruf hängende Thread losgelassen wird.

Die ManualResetEvent-Klasse ist im Gegensatz zu ReaderWriterLock nicht als veraltet markiert und wird nach dem Erscheinen der Slim-Version nicht mehr zur Verwendung empfohlen. Die schlanke Version dieser Klasse wird effizient für kurze Erwartungen eingesetzt, wie Es passiert im Spin-Wait-Modus, die reguläre Version ist für lange geeignet.

Zusätzlich zu den Klassen ManualResetEvent und AutoResetEvent ist auch die Klasse CountdownEvent vorhanden. Diese Klasse eignet sich für die Implementierung von Algorithmen, bei denen auf den Teil, der parallelisiert werden konnte, der Teil zum Zusammenführen der Ergebnisse folgt. Dieser Ansatz wird als Fork-Join bezeichnet . Ein ausgezeichneter Artikel ist der Arbeit dieser Klasse gewidmet, daher werde ich ihn hier nicht im Detail analysieren.

Schlussfolgerungen


  • Bei der Arbeit mit Threads sind zwei Probleme, die zu falschen oder fehlenden Ergebnissen führen, der Race-Zustand und der Deadlock
  • Die Probleme, die dazu führen, dass das Programm mehr Zeit oder Ressourcen benötigt - Thread-Hunger und beschäftigtes Warten
  • .NET ist reich an Thread-Synchronisation
  • Es gibt zwei Wartemodi für Sperren - Spin Wait, Core Wait. Einige .NET-Thread-Synchronisationsprimitive verwenden beide
  • Interlocked ist eine Reihe von atomaren Operationen, die in sperrfreien Algorithmen verwendet werden und das schnellste Synchronisationsprimitiv sind
  • Der Sperroperator und Monitor.Enter / Exit implementieren die Idee eines kritischen Abschnitts - eines Codeteils, der jeweils nur von einem Thread ausgeführt werden kann
  • Monitor.Pulse / Wait-Methoden sind praktisch für die Implementierung von Producer-Consumer-Skripten
  • ReaderWriterLockSlim ist möglicherweise effizienter als das normale Sperren von Skripten, bei denen paralleles Lesen akzeptabel ist
  • Die ResetEvent-Klassenfamilie kann für die Thread-Synchronisierung nützlich sein.

Source: https://habr.com/ru/post/de459514/


All Articles