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

Ich habe diesen Artikel ursprünglich im CodingSight- Blog veröffentlicht.
Es ist hier auch in russischer Sprache erhältlich.

Dieser Artikel enthält den zweiten Teil meiner Rede beim Multithreading-Treffen. Den ersten Teil können Sie hier und hier ansehen. Im ersten Teil konzentrierte ich mich auf die grundlegenden Tools zum Starten eines Threads oder einer Aufgabe, auf die Möglichkeiten, ihren Status zu verfolgen, und auf einige zusätzliche nützliche Dinge wie PLinq. In diesem Teil werde ich die Probleme beheben, die in einer Multithread-Umgebung auftreten können, sowie einige Möglichkeiten, sie zu beheben.

Inhalt




In Bezug auf gemeinsam genutzte Ressourcen


Sie können kein Programm schreiben, das auf mehreren Threads basiert, ohne über gemeinsam genutzte Ressourcen zu verfügen. Selbst wenn es auf Ihrer aktuellen Abstraktionsebene funktioniert, werden Sie feststellen, dass es tatsächlich über gemeinsam genutzte Ressourcen verfügt, sobald Sie eine oder mehrere Abstraktionsebenen herunterfahren. Hier einige Beispiele:

Beispiel 1:

Um mögliche Probleme zu vermeiden, lassen Sie die Threads mit verschiedenen Dateien arbeiten, eine Datei für jeden Thread. Es scheint Ihnen, dass das Programm keinerlei gemeinsame Ressourcen hat.

Wenn Sie ein paar Ebenen tiefer gehen, werden Sie feststellen, dass es nur eine Festplatte gibt und es Sache des Treibers oder des Betriebssystems ist, eine Lösung für Probleme mit dem Festplattenzugriff zu finden.

Beispiel 2:

Nachdem Sie Beispiel 1 gelesen haben, haben Sie beschlossen, die Dateien auf zwei verschiedenen Remotecomputern mit physisch unterschiedlicher Hardware und Betriebssystemen abzulegen. Sie pflegen auch zwei verschiedene FTP- oder NFS-Verbindungen.

Wenn Sie noch einmal ein paar Stufen tiefer gehen, haben Sie verstanden, dass sich nichts wirklich geändert hat, und das Problem des wettbewerbsfähigen Zugriffs wird jetzt an den Netzwerkkartentreiber oder das Betriebssystem des Computers delegiert, auf dem das Programm ausgeführt wird.

Beispiel 3:

Nachdem Sie den größten Teil Ihrer Haare herausgezogen haben, um zu beweisen, dass Sie ein Multithread-Programm schreiben können, entscheiden Sie sich, die Dateien vollständig zu löschen und die Berechnungen auf zwei verschiedene Objekte zu verschieben, wobei die Links zu jedem Objekt nur für das jeweilige Objekt verfügbar sind Fäden.

Um das letzte Dutzend Nägel in den Sarg dieser Idee zu hämmern: Eine Laufzeit und ein Garbage Collector, ein Thread-Scheduler, physisch ein einheitlicher RAM und ein Prozessor gelten weiterhin als gemeinsam genutzte Ressourcen.

Wir haben also gelernt, dass es unmöglich ist, ein Multithread-Programm ohne gemeinsam genutzte Ressourcen auf allen Abstraktionsebenen und im gesamten Umfang des Technologie-Stacks zu schreiben. Glücklicherweise kümmert sich jede Abstraktionsebene (in der Regel) teilweise oder sogar vollständig um die Probleme des Wettbewerbszugriffs oder verweigert sie einfach sofort (Beispiel: Jedes UI-Framework erlaubt nicht die Arbeit mit Elementen aus verschiedenen Threads). Daher treten die Probleme mit gemeinsam genutzten Ressourcen normalerweise auf Ihrer aktuellen Abstraktionsebene auf. Um sich um sie zu kümmern, wird das Konzept der Synchronisation eingeführt.

Mögliche Probleme in Multithread-Umgebungen


Wir können Softwarefehler in die folgenden Kategorien einteilen:
  1. Das Programm liefert kein Ergebnis - es stürzt ab oder friert ein.
  2. Das Programm liefert ein falsches Ergebnis.
  3. Das Programm liefert ein korrektes Ergebnis, erfüllt jedoch einige nicht funktionsbezogene Anforderungen nicht - es verbraucht zu viel Zeit oder Ressourcen.

In Umgebungen mit mehreren Threads sind die Hauptprobleme, die zu den Fehlern Nr. 1 und Nr. 2 führen, Deadlock und Race Condition .


Deadlock


Deadlock ist ein gegenseitiger Block. Es gibt viele Variationen eines Deadlocks. Das folgende kann als das häufigste angesehen werden:



Während Thread 1 etwas tat, blockierte Thread 2 Ressource B. Einige Zeit später blockierte Thread 1 Ressource A und versuchte, Ressource B zu blockieren. Leider wird dies nie passieren, da Thread 2 Ressource B erst nach dem Blockieren von Ressource A loslässt .

Rennbedingung


Race-Condition ist eine Situation, in der sowohl das Verhalten als auch die Ergebnisse der Berechnungen vom Thread-Scheduler der Ausführungsumgebung abhängen

Das Problem ist, dass Ihr Programm einmal in hundert oder sogar in einer Million nicht richtig funktioniert.

Die Dinge können schlimmer werden, wenn Probleme zu dritt auftreten. Beispielsweise kann das spezifische Verhalten des Thread-Schedulers zu einem gegenseitigen Deadlock führen.

Zusätzlich zu diesen beiden Problemen, die zu expliziten Fehlern führen, gibt es auch Probleme, die, wenn sie nicht zu falschen Berechnungsergebnissen führen, dazu führen können, dass das Programm viel mehr Zeit oder Ressourcen benötigt, um das gewünschte Ergebnis zu erzielen. Zwei dieser Probleme sind Busy Wait und Thread Starvation .

Beschäftigt warten


Busy Wait ist ein Problem, das auftritt, wenn das Programm Prozessorressourcen eher für das Warten als für die Berechnung ausgibt.

In der Regel sieht dieses Problem folgendermaßen aus:

while(!hasSomethingHappened) ; 

Dies ist ein Beispiel für einen extrem schlechten Code, da er einen Kern Ihres Prozessors vollständig belegt und überhaupt nichts Produktives tut. Ein solcher Code kann nur gerechtfertigt werden, wenn es von entscheidender Bedeutung ist, eine Änderung eines Werts in einem anderen Thread schnell zu verarbeiten. Und mit "schnell" meine ich, dass Sie nicht einmal ein paar Nanosekunden warten können. In allen anderen Fällen, dh in allen Fällen, in denen sich ein vernünftiger Verstand einfallen lassen kann, ist es viel bequemer, die Variationen von ResetEvent und deren Slim-Versionen zu verwenden. Wir werden etwas später darüber sprechen.

Wahrscheinlich würden einige Leser vorschlagen, das Problem zu lösen, dass ein Kern vollständig mit dem Warten beschäftigt ist, indem Thread.Sleep (1) (oder ähnliches) zum Zyklus hinzugefügt wird. Während dieses Problem behoben wird, wird ein neues erstellt. Die Reaktionszeit auf Änderungen beträgt jetzt durchschnittlich 0,5 ms. Einerseits ist es nicht so viel, andererseits ist dieser Wert katastrophal höher als das, was wir mit Synchronisationsprimitiven der ResetEvent-Familie erreichen können.

Fadenhunger


Thread Starvation ist ein Problem mit dem Programm, das zu viele gleichzeitig arbeitende Threads hat. Hier geht es speziell um die Threads, die mit der Berechnung beschäftigt sind, anstatt auf die Antwort einer E / A zu warten. Mit diesem Problem verlieren wir alle möglichen Leistungsvorteile, die mit Threads einhergehen, da der Prozessor viel Zeit für das Wechseln von Kontexten benötigt.

Sie können solche Probleme mithilfe verschiedener Profiler finden. Das Folgende ist ein Screenshot des dotTrace- Profilers, der im Timeline-Modus arbeitet

(zum Vergrößern anklicken).

Normalerweise haben Programme, die nicht unter dem Thread-Hunger leiden, keine rosa Abschnitte in den Diagrammen, die die Threads darstellen. Darüber hinaus können wir in der Kategorie Subsysteme sehen, dass das Programm 30,6% der Zeit auf die CPU gewartet hat.

Wenn ein solches Problem diagnostiziert wird, können Sie es ganz einfach beheben: Sie haben zu viele Threads gleichzeitig gestartet, also starten Sie einfach weniger Threads.

Synchronisationsmethoden



Verriegelt


Dies ist wahrscheinlich die leichteste Synchronisationsmethode. Interlocked ist eine Reihe einfacher atomarer Operationen. Wenn eine atomare Operation ausgeführt wird, kann nichts passieren. In .NET wird Interlocked durch die gleichnamige statische Klasse mit einer Auswahl von Methoden dargestellt, von denen jede eine atomare Operation implementiert.

Versuchen Sie, ein Programm zu schreiben, das 10 Threads startet, von denen jeder dieselbe Variable millionenfach erhöht, um den ultimativen Horror nichtatomarer Operationen zu erkennen. Wenn sie mit ihrer Arbeit fertig sind, geben Sie den Wert dieser Variablen aus. Leider wird es stark von 10 Millionen abweichen. Außerdem ist es jedes Mal anders, wenn Sie das Programm ausführen. Dies geschieht, weil selbst eine so einfache Operation wie das Inkrement keine atomare ist und die Wertextraktion aus dem Speicher, die Berechnung eines neuen Werts und das erneute Schreiben in den Speicher umfasst. Zwei Threads können also jede dieser Operationen ausführen, und in diesem Fall geht ein Inkrement verloren.

Die Interlocked-Klasse bietet die Increment / Decrement-Methoden, und es ist nicht schwer zu erraten, was sie tun sollen. Sie sind sehr praktisch, wenn Sie Daten in mehreren Threads verarbeiten und etwas berechnen. Ein solcher Code funktioniert viel schneller als das klassische Schloss. Wenn wir Interlocked in der im vorherigen Absatz beschriebenen Situation verwenden würden, würde das Programm in jedem Szenario zuverlässig einen Wert von 10 Millionen erzeugen.

Die Funktion der CompareExchange-Methode ist nicht so offensichtlich. Seine Existenz ermöglicht jedoch die Implementierung vieler interessanter Algorithmen. Am wichtigsten sind diejenigen aus der Familie ohne Schlösser.

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

Diese Methode nimmt drei Werte an. Der erste Wert wird durch eine Referenz geleitet, und dieser Wert wird in den zweiten Wert geändert, wenn Position1 gleich Vergleich ist, und wenn der Vergleich durchgeführt wird. Der ursprüngliche Wert von location1 wird zurückgegeben. Das klingt kompliziert, daher ist es einfacher, einen Code zu schreiben, der dieselben Vorgänge wie CompareExchange ausführt:

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

Der einzige Unterschied besteht darin, dass die Interlocked-Klasse dies auf atomare Weise implementiert. Wenn wir diesen Code selbst schreiben würden, könnten wir uns einem Szenario stellen, in dem die Bedingung location1 == compareand bereits erfüllt ist. Wenn jedoch die Anweisung location1 = value ausgeführt wird, hat ein anderer Thread den Wert location1 bereits geändert, sodass er verloren geht.

Wir finden ein gutes Beispiel dafür, wie diese Methode in dem Code verwendet werden kann, den der Compiler für jedes C # -Ereignis generiert.

Schreiben wir eine einfache Klasse mit einem Ereignis namens MyEvent:

 class MyClass { public event EventHandler MyEvent; } 

Lassen Sie uns nun das Projekt in der Release-Konfiguration erstellen und den Build über dotPeek mit aktivierter Option " Vom Compiler generierten Code anzeigen " öffnen:

 [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 wir sehen, dass der Compiler hinter den Kulissen einen ziemlich komplexen Algorithmus generiert hat. Dieser Algorithmus verhindert, dass wir ein Abonnement für das Ereignis verlieren, bei dem einige Threads gleichzeitig dieses Ereignis abonniert haben. Lassen Sie uns die Methode add näher erläutern und dabei berücksichtigen, 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 viel einfacher zu handhaben, erfordert aber wahrscheinlich noch eine Erklärung. So würde ich den Algorithmus beschreiben:

Wenn MyEvent immer noch das gleiche ist wie zu dem Zeitpunkt, als wir mit der Ausführung von Delegate.Combine begonnen haben, setzen Sie es auf das, was Delegate.Combine zurückgibt. Wenn dies nicht der Fall ist, versuchen Sie es erneut, bis es funktioniert.

Auf diese Weise gehen Abonnements niemals verloren. Sie müssen ein ähnliches Problem lösen, wenn Sie ein dynamisches, threadsicheres und sperrfreies Array implementieren möchten. Wenn plötzlich mehrere Threads Elemente zu diesem Array hinzufügen, ist es wichtig, dass alle diese Elemente erfolgreich hinzugefügt werden.

Monitor.Enter, Monitor.Exit, sperren


Diese Konstruktionen werden am häufigsten für die Thread-Synchronisation verwendet. Sie implementieren das Konzept eines kritischen Abschnitts: Das heißt, der zwischen den Aufrufen von Monitor.Enter und Monitor.Exit geschriebene Code kann nur von einem Thread zu einem bestimmten Zeitpunkt auf einer Ressource ausgeführt werden. Der Sperroperator dient als Syntaxzucker für die in try-finally eingeschlossenen Enter / Exit-Aufrufe. Eine angenehme Eigenschaft des kritischen Abschnitts in .NET ist, dass er den Wiedereintritt unterstützt. Dies bedeutet, dass der folgende Code ohne echte Probleme ausgeführt werden kann:

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

Es ist unwahrscheinlich, dass jemand genau so schreibt. Wenn Sie diesen Code jedoch auf einige Methoden in der Tiefe des Aufrufstapels verteilen, können Sie mit dieser Funktion einige IFs sparen. Damit dieser Trick funktioniert, mussten die Entwickler von .NET eine Einschränkung hinzufügen: Sie können nur Instanzen von Referenztypen als Synchronisationsobjekt verwenden, und jedem Objekt, in das die Thread-ID geschrieben wird, werden einige Bytes hinzugefügt.

Diese Besonderheit des Arbeitsprozesses des kritischen Abschnitts in C # führt zu einer interessanten Einschränkung für den Sperroperator: Sie können den Operator await nicht innerhalb des Sperroperators verwenden. Das hat mich zunächst überrascht, da eine ähnliche Try-finally-Monitor.Enter / Exit-Konstruktion kompiliert werden kann. Was ist los? Es ist wichtig, den vorherigen Absatz erneut zu lesen und einige Kenntnisse über die Funktionsweise von async / await anzuwenden: Der Code nach dem Warten wird nicht im selben Thread wie der Code vor dem Warten ausgeführt. Dies hängt vom Synchronisationskontext ab und davon, ob die ConfigureAwait-Methode aufgerufen wird oder nicht. Daraus folgt, dass Monitor.Exit möglicherweise auf einem anderen Thread als Monitor.Enter ausgeführt wird, was dazu führt, dass SynchronizationLockException ausgelöst wird. Wenn Sie mir nicht glauben, versuchen Sie, den folgenden Code in einer Konsolenanwendung auszuführen. Dadurch wird eine SynchronizationLockException generiert:

 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 erwähnenswert, dass in einer WinForms- oder WPF-Anwendung dieser Code korrekt funktioniert, wenn Sie ihn vom Hauptthread aus aufrufen, da es einen Synchronisationskontext gibt, der die Rückkehr zum UI-Thread nach dem Aufruf von wait implementiert. In jedem Fall ist es besser, nicht mit kritischen Abschnitten im Kontext eines Codes herumzuspielen, der den Operator await enthält. In solchen Beispielen ist es besser, Synchronisationsprimitive zu verwenden, die wir uns etwas später ansehen werden.

Während wir uns mit kritischen Abschnitten in .NET befassen, ist es wichtig, eine weitere Besonderheit der Implementierung zu erwähnen. Ein kritischer Abschnitt in .NET funktioniert in zwei Modi: Spin-Wait und Core-Wait. Wir können den Spin-Wait-Algorithmus wie den folgenden Pseudocode darstellen:

 while(!TryEnter(syncObject)) ; 

Diese Optimierung zielt darauf ab, einen kritischen Abschnitt so schnell wie möglich in kurzer Zeit zu erfassen, auf der Grundlage, dass die Ressource, selbst wenn sie derzeit belegt ist, sehr bald freigegeben wird. Wenn dies nicht in kurzer Zeit geschieht, wechselt der Thread zum Warten im Kernmodus, was einige Zeit in Anspruch nimmt - genauso wie das Zurückkehren vom Warten. Die Entwickler von .NET haben das Szenario der kurzen Blöcke so weit wie möglich optimiert. Wenn viele Threads anfangen, den kritischen Abschnitt zwischen sich zu ziehen, kann dies leider zu einer plötzlichen hohen Belastung der CPU führen.

SpinLock, SpinWait


Nachdem bereits der zyklische Wartealgorithmus (Spin-Wait) erwähnt wurde, lohnt es sich, über die SpinLock- und SpinWait-Strukturen von BCL zu sprechen. Sie sollten sie verwenden, wenn Grund zu der Annahme besteht, dass es immer möglich ist, einen Block sehr schnell zu erhalten. Auf der anderen Seite sollten Sie nicht wirklich darüber nachdenken, bis die Profilerstellungsergebnisse zeigen, dass der Engpass Ihres Programms durch die Verwendung anderer Synchronisationsprimitive verursacht wird.

Monitor.Wait, Monitor.Pulse [Alle]


Wir sollten diese beiden Methoden nebeneinander betrachten. Mit ihrer Hilfe können Sie verschiedene Producer-Consumer-Szenarien implementieren.

Producer-Consumer ist ein Muster eines Multi-Prozess- / Multi-Threaded-Designs, das einen oder mehrere Threads / Prozesse impliziert, die Daten erzeugen, und einen oder mehrere Prozesse / Threads, die diese Daten verarbeiten. Normalerweise wird eine gemeinsam genutzte Sammlung verwendet.

Beide Methoden können nur von einem Thread aufgerufen werden, der derzeit einen Block hat. Die Wait-Methode gibt den Block frei und friert ein, bis ein anderer Thread Pulse aufruft.

Als Demonstration dafür 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 hier eher ein Bild als einen Text verwendet, um die Reihenfolge der Befehlsausführung genau anzuzeigen.)
Erläuterung: Ich habe beim Starten des zweiten Threads eine Latenz von 100 ms festgelegt, um sicherzustellen, dass er später ausgeführt wird.
- T1: Zeile 2 der Thread wird gestartet
- T1: Zeile 3 Der Thread tritt in einen kritischen Abschnitt ein
- T1: Zeile 6, in der der Thread in den Ruhezustand wechselt
- T2: Zeile 3 der Thread wird gestartet
- T2: Zeile 4 friert ein und wartet auf den kritischen Abschnitt
- T1: Zeile 7 lässt den kritischen Abschnitt los und friert ein, während darauf gewartet wird, dass Pulse herauskommt
- T2: Zeile 8 tritt in den kritischen Abschnitt ein
- T2: Zeile 11 signalisiert T1 mit Hilfe von Pulse
- T2: Zeile 14 kommt aus dem kritischen Abschnitt. T1 kann seine Ausführung nicht fortsetzen, bevor dies geschieht.
- T1: Zeile 15 kommt aus dem Warten heraus
- T1: Zeile 16 kommt aus dem kritischen Abschnitt heraus

In MSDN gibt es eine wichtige Bemerkung zur Verwendung der Pulse / Wait-Methoden: Monitor speichert keine Statusinformationen, was bedeutet, dass das Aufrufen der Pulse-Methode vor der Wait-Methode zu einem Deadlock führen kann. Wenn ein solcher Fall möglich ist, ist es besser, eine der Klassen aus der ResetEvent-Familie zu verwenden.

Das vorige Beispiel zeigt deutlich, wie die Wait / Pulse-Methoden der Monitor-Klasse funktionieren, lässt jedoch noch einige Fragen zu den Fällen offen, in denen wir sie verwenden sollten. Ein gutes Beispiel ist diese Implementierung von BlockingQueue <T>. Andererseits verwendet die Implementierung von BlockingCollection <T> aus System.Collections.Concurrent SemaphoreSlim für die Synchronisation.

ReaderWriterLockSlim


Ich liebe dieses Synchronisationsprimitiv sehr und es wird durch die gleichnamige Klasse aus dem System.Threading-Namespace dargestellt. Ich denke, dass viele Programme viel besser funktionieren würden, wenn ihre Entwickler diese Klasse anstelle der Standardsperre verwenden würden.

Idee: Viele Threads können lesen und der einzige kann schreiben. Wenn ein Thread schreiben möchte, können keine neuen Lesevorgänge gestartet werden - sie warten auf das Schreiben bis zum Ende. Es gibt auch das Upgrade-Read-Lock-Konzept. Sie können es verwenden, wenn Sie während des Lesevorgangs verstehen, dass etwas geschrieben werden muss - eine solche Sperre wird in einer atomaren Operation in eine Schreibsperre umgewandelt.

Im System.Threading-Namespace gibt es auch die ReadWriteLock-Klasse, es wird jedoch dringend empfohlen, sie nicht für Neuentwicklungen zu verwenden. Die Slim-Version hilft dabei, Fälle zu vermeiden, die zu Deadlocks führen, und ermöglicht die schnelle Erfassung eines Blocks, da sie die Synchronisation im Spin-Wait-Modus unterstützt, bevor Sie in den Core-Modus wechseln.

Wenn Sie vor dem Lesen dieses Artikels nichts über diese Klasse gewusst haben, haben Sie sich inzwischen an viele Beispiele aus dem kürzlich geschriebenen Code erinnert, in denen dieser Ansatz für Blöcke es dem Programm ermöglichte, effektiv zu arbeiten.

Die Oberfläche der ReaderWriterLockSlim-Klasse ist einfach und leicht zu verstehen, aber nicht so benutzerfreundlich:

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

Normalerweise wickle ich es gerne in eine Klasse ein - das macht es viel handlicher.

Idee: Erstellen Sie Read / WriteLock-Methoden, die zusammen mit der Dispose-Methode ein Objekt zurückgeben. Sie können dann unter Verwenden auf sie zugreifen, und es wird wahrscheinlich nicht zu sehr von der Standardsperre abweichen, wenn es um die Anzahl der Zeilen geht.

 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(); } 

Dies ermöglicht es uns, später im Code Folgendes zu schreiben:

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


Die ResetEvent-Familie


Ich füge die folgenden Klassen in diese Familie ein: ManualResetEvent, ManualResetEventSlim und AutoResetEvent.

Die ManualResetEvent-Klasse, ihre Slim-Version und die AutoResetEvent-Klasse können in zwei Zuständen existieren:

- Nicht signalisiert - In diesem Zustand frieren alle Threads, die WaitOne aufgerufen haben, ein, bis das Ereignis in einen signalisierten Zustand wechselt.
- Signalisiert - In diesem Zustand werden alle Threads freigegeben, die zuvor bei einem WaitOne-Aufruf eingefroren wurden. Alle neuen WaitOne-Aufrufe eines signalisierten Ereignisses werden relativ sofort ausgeführt.

AutoResetEvent unterscheidet sich von ManualResetEvent dadurch, dass es automatisch in den nicht signalisierten Zustand wechselt, nachdem genau ein Thread freigegeben wurde. Wenn einige Threads eingefroren sind, während auf AutoResetEvent gewartet wird, wird beim Aufrufen von Set nur ein zufälliger Thread freigegeben, im Gegensatz zu ManualResetEvent, bei dem alle Threads freigegeben werden.

Schauen wir uns ein Beispiel für die Funktionsweise von AutoResetEvent an:

 AutoResetEvent evt = new AutoResetEvent(false); Thread t1 = new Thread(T1); t1.Start(); Thread.Sleep(100); Thread t2 = new Thread(T2); t2.Start(); 

In diesen Beispielen können wir sehen, dass das Ereignis erst automatisch in den nicht signalisierten Zustand wechselt, nachdem der Thread freigegeben wurde, der bei einem WaitOne-Aufruf eingefroren wurde.

Im Gegensatz zu ReaderWriterLock gilt ManualResetEvent auch nach Erscheinen der Slim-Version nicht als veraltet. Diese schlanke Version der Klasse kann für kurze Wartezeiten wirksam sein, wie dies im Spin-Wait-Modus der Fall ist. Die Standardversion ist gut für lange Wartezeiten.

Neben den Klassen ManualResetEvent und AutoResetEvent gibt es auch die Klasse CountdownEvent. Diese Klasse ist sehr nützlich für die Implementierung von Algorithmen, die Ergebnisse nach einem parallelen Abschnitt zusammenführen. Dieser Ansatz wird als Fork-Join bezeichnet . Es gibt einen großartigen Artikel zu dieser Klasse, daher werde ich ihn hier nicht im Detail beschreiben.

Schlussfolgerungen


  • Bei der Arbeit mit Threads gibt es zwei Probleme, die zu falschen Ergebnissen oder sogar zum Fehlen von Ergebnissen führen können - Race Condition und Deadlock.
  • Probleme, die dazu führen können, dass das Programm mehr Zeit oder Ressourcen verbringt, sind Thread-Hunger und beschäftigtes Warten.
  • .NET bietet viele Möglichkeiten zum Synchronisieren von Threads.
  • Es gibt zwei Arten von Blockwartezeiten - Spin Wait und Core Wait. Einige Thread-Synchronisationsprimitive in .NET verwenden beide.
  • Interlocked ist eine Reihe von atomaren Operationen, mit denen sperrfreie Algorithmen implementiert werden können. Es ist das schnellste Synchronisationsprimitiv.
  • Die Operatoren lock und Monitor. Enter / Exit implementieren das Konzept eines kritischen Abschnitts - eines Codefragments, das nur von einem Thread zu einem bestimmten Zeitpunkt ausgeführt werden kann.
  • Die Monitor.Pulse / Wait-Methoden sind nützlich für die Implementierung von Producer-Consumer-Szenarien.
  • ReaderWriterLockSlim kann nützlicher sein als die Standard-Sperrfälle, wenn paralleles Lesen erwartet wird.
  • Die ResetEvent-Klassenfamilie kann für die Thread-Synchronisierung hilfreich sein.

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


All Articles