1. Das Geheimnis stabilen Multithread-Codes
Stell dir Multithreading vor wie ein Team aus mehreren (Dutzenden, Hunderten!) Arbeitern, die gleichzeitig an einer Maschine schrauben. Klar, wenn nur einer von ihnen das falsche Werkzeug greift — zack, alles steht still. Wenn die Arbeiter nicht absprechen, wer wann den Schlüssel nimmt, behindern sie sich gegenseitig, und die reparierte Maschine kommt vielleicht nie fertig oder nur sehr spät.
Im Multithread-Code ist das genauso. Unvorsichtiger Umgang mit Daten kann zu unsichtbaren Fehlern führen, die auf Single-Core-Maschinen seltener auftreten, auf schwachen Rechnern gar nicht sichtbar sind und auf Servern im Live-Betrieb richtig unangenehm werden — und das genau dann, wenn man es am wenigsten braucht.
In dieser Vorlesung erfährst du, wie man Multithread-Code schreibt, der nicht wie ein Kartenhaus zusammenkracht. Außerdem: Welche Tools helfen, wenn doch mal was schief läuft.
1. Minimiert kritische Sektionen
Je weniger Code sich innerhalb eines lock-Blocks befindet, desto besser. Warum? Weil während ein Thread den Lock hält, warten alle anderen. Und warten heißt: unnötiger CPU-Verbrauch.
Beispiel:
// SCHLECHT: Ganze Business-Logik innerhalb von lock — alle warten
lock(_locker)
{
// Lange Operation (nicht mit gemeinsamem Ressource verbunden)
Thread.Sleep(500);
counter++;
}
// GUT: Nur das Nötigste innerhalb von lock
// Schwerer Teil außerhalb der kritischen Sektion
Thread.Sleep(500);
lock(_locker)
{
counter++;
}
Realistische Situation: Wenn in der kritischen Sektion ein Netzwerkaufruf oder eine lange Berechnung steckt, sinkt die Performance deines Programms drastisch, und die Threads „verlaufen“ sich im Nichts.
2. Verwende keine universellen Objekte als Lock-Key
Es ist keine gute Idee, lock(this) oder lock(typeof(MyClass)) zu verwenden.
Warum? Wenn jemand anderes im Projekt oder in einer Bibliothek dasselbe Objekt für eine lock-Anweisung nutzt, entstehen Deadlocks oder versteckte Bugs. Besser immer einen eigenen privaten Lock-Objekt verwenden:
private readonly object _locker = new object();
lock(_locker)
{
// Deine Aktionen
}
Verboten: Strings, öffentliche Felder, Werttypen-Objekte.
3. Nutze immer try...finally zum Freigeben von Ressourcen
Jede Sperre (Mutex, Semaphore, RWLock) sollte im finally-Block freigegeben werden. Sonst bleibt der Lock bei einem Fehler hängen, und andere Threads können nicht mehr weiterarbeiten.
Beispiel:
_mutex.WaitOne();
try
{
// Kritischer Abschnitt
}
finally
{
_mutex.ReleaseMutex();
}
4. Übertreibe es nicht mit Synchronisation
Wenn du bei jedem kleinen Schritt eine lock-Anweisung hast, erinnert dein Programm an eine sowjetische Warteschlange: Alle warten auf irgendwas. Oft reicht es, nur die echten gemeinsamen Ressourcen (z.B. Collections) zu schützen, nicht jede einzelne Variable.
5. Nutze thread-sichere Collections und Typen
.NET bietet spezielle Collections für Multithreading: ConcurrentDictionary, ConcurrentQueue, ConcurrentBag, BlockingCollection und andere. Diese sind bereits intern geschützt — du brauchst kein zusätzliches lock.
Beispiel:
using System.Collections.Concurrent;
ConcurrentDictionary<int, string> users = new ConcurrentDictionary<int, string>();
users.TryAdd(1, "Alice");
users[2] = "Bob";
6. Vorsicht vor Deadlocks
Der gefährlichste Feind im Multithread-Programm ist der Deadlock: Wenn Thread A auf Thread B wartet, während B gleichzeitig auf A wartet. Oder wenn mehrere Locks in unterschiedlicher Reihenfolge gehalten werden.
Typischer Fall:
// Thread 1
lock(obj1)
{
lock(obj2)
{
// Mach was
}
}
// Thread 2
lock(obj2)
{
lock(obj1)
{
// Mach was
}
}
Hier könnten beide Threads „hängen bleiben“: jeder hat seinen Lock, wartet aber auf den anderen. Das nennt man Deadlock.
Tipp: Immer die Locks in der gleichen Reihenfolge in allen Threads anfordern.
7. Bevorzuge immutable state
Wenn ein Objekt nach der Erstellung nie mehr verändert wird, kannst du es sicher in mehreren Threads lesen. Beispiele: string, Tuple, DateTime-Struktur, eigene Read-Only-Klassen.
2. Tools zur Diagnose von Multithreading-Problemen
Fehler mit Threads sind tückisch: Sie treten oft tief im Code auf, erscheinen selten und verhalten sich unvorhersehbar. Manchmal vergehen Jahre, bis der Bug im Live-Server sichtbar wird.
Deshalb nutzen professionelle .NET-Entwickler spezielle Tools und Methoden, um Synchronisationsprobleme zu finden und zu analysieren:
1. Event- und Thread-Logging
Füge Ausgaben mit Thread.ManagedThreadId und wichtigen Operationen ein. Das ist die einfachste Methode, um zu sehen, „wer wann“ in die kritische Sektion eingreift.
Beispiel:
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Eintritt in kritische Sektion");
// ...
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Austritt aus kritischer Sektion");
Für echte Anwendungen empfiehlt sich Microsoft.Extensions.Logging oder externe Frameworks wie NLog, Serilog.
2. Thread Sanitizer & Race Detector
In der .NET-Umgebung gibt es kein „perfektes“ integriertes Tool wie ThreadSanitizer für C++/Go, aber es gibt externe Lösungen und statische Analysatoren:
- ReSharper (JetBrains ReSharper): erkennt einige gefährliche Synchronisationsmuster;
- Roslyn-Analyzers: helfen, offensichtliche Fehler im Multithread-Code zu vermeiden (Analyzers);
- Concurrency Visualizer von Microsoft (Concurrency Visualizer Extension für Visual Studio): zeigt Laufzeitdaten, Deadlocks und Thread-Auslastung in Echtzeit.
3. Visual Studio Diagnostics Tools
In Visual Studio ist ein Profiler integriert, der zeigt:
- Welche Threads laufen;
- Wo Threads warten (waiting);
- Wo Locks entstehen;
- Wann Deadlocks auftreten und Threads auf Locks „kontendieren“.
Man kann auch eine Trace-Aufzeichnung machen, um detailliert zu sehen, wie Locks genutzt werden.
4. Dump-Analysen und WinDbg
In schweren Fällen (z.B. Server „hängt“, nichts reagiert) kannst du einen Memory-Dump des Prozesses erstellen und in WinDbg oder dotnet-dump öffnen. Anhand des Call-Stacks siehst du, wo die Threads hängen und wer welche Locks hält.
Beispiel für Stack-Analyse:
0:000> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
1 000001d4b6f90e08 1 1 000001d4b5c941c0 000001d4b6f03458
(Keine Panik, wenn das unverständlich klingt — nur erfahrene Debugger verwenden das bei komplexen Problemen.)
5. Unit-Tests mit Lasttests (Stress Testing)
Schreibe Tests, die deinen Multithread-Code mit vielen parallelen Durchläufen ausführen, z.B. Hunderte oder Tausende. Oft entdeckt man Bugs erst bei hoher Belastung, die im manuellen Test nicht sichtbar sind.
Einfacher Stress-Test:
[Test]
public void Counter_IsThreadSafe()
{
var counter = 0;
var locker = new object();
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 10000; j++)
{
lock (locker)
{
counter++;
}
}
}));
}
Task.WaitAll(tasks.ToArray());
Assert.AreEqual(100 * 10000, counter);
}
6. Nutze Assertions und spezielle Checks
Füge im Code Checks ein, die während der Entwicklung das richtige Verhalten garantieren.
Beispiel: Wenn ein Objekt nur von einem Thread gehalten werden soll, kannst du eine Flagge setzen oder Debug.Assert verwenden, um unerwartete Zustände zu erkennen.
3. Fazit und Empfehlungen
Visuelles Schema: Gefahrenzone und sichere Bereiche
graph TD
A[Gemeinsame Ressource] -- ohne Synchronisation --> B(Situation Race)
A -- Sperre (lock/Mutex) --> C[Sicherer Zugriff: kritischer Abschnitt]
C -- "zu viele Sperren" --> D(Leistungsverlust)
A -- ReaderWriterLockSlim --> E{Viele Leser / Ein Schreiber}
E -- "Lesen" --> F[Viele Threads lesen gleichzeitig]
E -- "Schreiben" --> G[Nur ein Thread schreibt, alle anderen warten]
Synchronisations-Primitives und ihre Aufgaben
| Primitive | Wofür | Anzahl der Threads | Interprozessuell | Performance | Einsatzgebiet |
|---|---|---|---|---|---|
| lock (Monitor) | Einfache kritische Sektion | 1 | Nein | Sehr hoch | 99% der Fälle |
| Mutex | Gleiches wie lock, aber zwischen Prozessen | 1 | Ja | Mittel | Files, IPC |
| Semaphore | Maximal N Threads gleichzeitig | N | Ja | Mittel | Ressourcenpools |
| SemaphoreSlim | Gleiches, aber schneller, nur innerhalb eines Prozesses | N | Nein | Hoch | Code-Pools |
| ReaderWriterLockSlim | Viele Leser, ein Schreiber | Viele/1 | Nein | Hoch | Caches, Einstellungen |
GO TO FULL VERSION