Letztes Mal haben wir den Unterschied angeschaut zwischen CPU-bound und IO-bound-Aufgaben um die richtigen Technologien wählen zu können.
Siehe C# Concurrency Teil 1: CPU-bound und IO-bound Tasks In diesem Teil sehen wir uns die Gefahren von Multithreading an. Pro Gefahr werden Lösungsvorschläge vorgestellt die in späteren Teilen der Blog-Serie ausführlicher erklärt werden.
Datakorruption
Problem
Wenn ein Thread Daten ändert während ein anderer Thread diese Daten zeitgleich liesst, dann kann es sein, dass der lesende Thread korrupte Daten erhält. Lese- und Schreibeinstruktionen in C# Quellcode werden letztendlich übersetzt in die Prozessor Assembly-Sprache. Der Compiler übersetzt eine C# Instruktion in mehrere Assembly Instruktionen. Die eine Instruktion in C# ist auf Prozessorebene nicht zwangsläufig Atomar und kann durch einen anderer Thread unterbrochen werden.
Lösung
Kritische Codeblöcke, die nicht unterbrochen werden dürfen, locken. Ein Lock sorgt dafür, dass die kritischen Sektion (gelockter Abschnitt) nur durch einen Thread gleichzeitig ausgeführt wird. Das Locken wird separat behandelt in dieser Serie.
Code Reentrancy
Problem
Durch die asynchrone Programmierung (z.B. mit async\await) ist ein alter Bekannter zurück: das mehrfach Ausführen der gleichen Methode auf dem gleichen Thread.
Weil während des Wartens in einer asynchronen Methode der Thread freigegeben wird (UI Thread), kann dieser die gleiche Methode wieder aufrufen.
In WinForms gab es das Problem durch Application.DoEvents(): ein Hack um das UI reaktiver zu machen. Mit async\await ist dieses Problem wieder da, nur sind sich viele dessen nicht bewusst.
Lösung 1
Das Steuerelement welche die Methode aufruft deaktivieren solange die Methode läuft.
Lösung 2
Die Methode mit einem Semaphore sperren. Das kann ein einfacher Boolean sein (z.B. methodInProgress) und braucht nicht Threadsafe zu sein.
Ein lock hat hier keine Wirkung, weil es der gleiche Thread ist welcher die Methode mehrfach aufruft.
Race condition
Problem
Wenn mehrere Threads mit den gleichen Daten arbeiten ist die Reihenfolge der Zugriffe der Threads nicht gegeben. So kann es sein, dass ein Wert durch den einen Thread schon gelöscht wurde bevor der andere Thread den Wert verarbeitet hat.
Lösung 1
Mit Threadsafe Collections arbeiten (z.B. Producer\Consumer Queue) um die Zugriffe gezielt zu serialisieren.
Lösung 2
Mit Thread Synchronisierungsmechanismen arbeiten (z.B. Barrier, CountDownEvent, Manual-\AutoResetEvent, SemaphoreSlim).
Cross thread violation
Problem
Es darf nur der Thread auf ein Steuerelement zugreifen welcher das Steuerelement auch kreiert hat (UI Thread). Wenn ein anderer Thread das Element versucht zu ändern gibt es eine Cross Thread Exception.
Lösung
Thread Remarshalling: den Aufruf umleiten auf den UI Thread. Die verschiedenen Technologien werden in diesem Blog besprochen.
Dead-Lock
Problem
Zwei Threads warten auf einander bis sie fertig sind oder einen Lock freigeben.
Lösung
Nie einen Event werfen oder eine Fremdmethode aufrufen innerhalb eines Lock und das Lock Objekt nie öffentlich zugängig machen.
Das blockierende Warten auf einen andereren Thread hat hohes Potential für einen Dead-Lock. Es gibt Situationen wo es nicht anders geht aber dann müssen gewisse Randbedingungen (z.B. ConfigureAwait(false)) eingehalten werden.
In diesem Blog wird das Dead-Lock Problem ausführlich analysiert und werden Best Practices vorgestellt die dafür sorgen, dass Threads einander so wenig wie möglich blockieren.
Threads die am Leben bleiben
Problem
Wenn Threads nicht sauber und kontrolliert heruntergefahren werden, kann es sein, dass diese am Leben bleiben.
Auch kann es vorkommen, dass ein Thread sich aufhängt. Der Thread kann dann nicht kontrolliert heruntergefahren werden (z.B. mit CancellationToken) weil er nicht mehr im Stande ist das Token zu überprüfen.
Diese Orphan-Threads beanspruchen unnötig Resourcen und können für Memory-Leaks sorgen. Diese Leichen sind mögliche Ursachen davor, dass ein Programm nicht heruntergefahren werden kann.
Lösungen
- Threads mit dem vorgesehen Mechanismus (z.B. CancellationToken) herunterfahren und auch kontrollieren, ob der Thread tatsächlich gestoppt wurde.
- Start-and-forget Threads vermeiden.
- Threads als background Thread definieren: diese verhindern das Herunterfahren vom Programm nicht.
- Ein nicht-Threadpool Thread kann mit Interrupt()\Abort() abgeschossen werden wenn er nicht mehr reagiert.
Verschluckte Exceptions
Problem
Bei TPL und async\await werden Exceptions im Task-Objekt gespeichert. Es ist aber dem übergeordneten Thread überlassen die Exceptions im Task-Objekt auszuwerten. Wird das vergessen, dann gehen diese Exceptions verloren.
Lösungen
- Immer das Task Objekt eines fertigen Tasks auswerten.
- async void nur für Eventhandlers anwenden.
- Start-and-forget Threads vermeiden.
- Exceptions schon bei der Quelle im Task selber abfangen und verarbeiten.
- Einen TaskScheduler.UnobservedTaskException Hander installieren.
Exceptions beim Herunterfahren vom Programm
Problem
Wird ein Thread nicht richtig (oder zu spät) heruntergefahren, dann kann es sein, dass dieser noch unerwartet auf Ressourcen zugreift die durch den Haupt-Thread abgebaut sind. So kommt es zu Exceptions und unvorgesehenen Situationen wie ‚ab und zu‘ Hängern.
Lösungen
Siehe „Threads die am Leben bleiben“.
Overflow
Problem
Overflow kann auftreten, wenn der generierende Thread (der Producer-Thread) viel schneller Items generiert als der empfangende Thread (der Consumer-Thread) verarbeiten kann.
Lösungen
Thread-Safe Producer\Consumer Queue verwenden mit Throttle-Funktionalität.
Programmintransparenz
Problem
Threading ist schwierig(er) zu Durchschauen und zu Debuggen. Es kommt oft zu ‚ab und zu‘ Exceptions oder Hängern die nicht reproduzierbar sind und deren Ursachen nur mit Traces und viel Aufwand zu finden sind.
Lösung
Die erwähnte Gefahren von Multithreading machen bewusst, dass beim Einsatz von Multithreading und async/await Vorsicht geboten ist. Es sollte nur überlegt und in Einklang mit Patterns und best Practices angewendet werden.
Follow up
Nächstes Mal schauen wir uns die bewährte Thread Klasse an. Dieser Weg ist oft immer noch der einzige Weg um spezielle Probleme zu lösen, trotz Threadpool, TPL und async\await.
Pingback: Noser Blog C# Concurrency Teil 1: CPU-bound und IO-bound Tasks - Noser Blog
Pingback: Noser Blog C# Concurrency Teil 3: Die bewährte Thread Klasse - Noser Blog
Pingback: Noser Blog C# Concurrency Teil 6: Locking - Noser Blog