1. Klassische Coffman-Bedingungen
1971 beschrieb der Informatiker Edward Coffman (Edward G. Coffman Jr.) vier Bedingungen, ohne die ein deadlock unmöglich ist. Diese Regeln sind längst die „Basics“ für Interviews und Kurse zu Multithreading. Es ist nützlich, sie nicht nur auswendig zu kennen, sondern zu verstehen, wozu sie gut sind:
- Mutual Exclusion (gegenseitiger Ausschluss): wenigstens eine Ressource kann immer nur von einem Thread gleichzeitig gehalten werden.
- Hold and Wait (Halten und Warten): ein Thread, der eine Ressource hat, kann auf weitere Ressourcen warten.
- No Preemption (keine Präemption): eine Ressource kann einem Thread nicht gewaltsam weggenommen werden — nur der Thread selbst kann sie freigeben.
- Circular Wait (zyklisches Warten): es existiert eine Kette von Threads, wobei jeder auf eine Ressource wartet, die der nächste hält.
Wenn alle vier Bedingungen erfüllt sind, ist ein deadlock möglich. Wird mindestens eine verletzt — verschwindet die Wahrscheinlichkeit einer gegenseitigen Blockade.
Schnelle Entscheidungsfindung
flowchart TD
A[Musst du mehrere Ressourcen sperren?] -- Nein --> B[Standard lock]
A -- Ja --> C[Können sie immer in derselben Reihenfolge genommen werden?]
C -- Ja --> D[Immer in derselben Reihenfolge nehmen]
C -- Nein --> E[Vermeide verschachtelte lock-Aufrufe]
D --> F[Minimiere Zeit unter dem Lock]
E --> F
B --> F
F --> G{Immer noch beunruhigt?}
G -- Ja --> H[Füge Timeouts und Wiederholungen hinzu]
G -- Nein --> I[Schlaf ruhig!]
2. Hauptstrategien zur Vermeidung von Deadlock
Da Deadlock ein inhärenter Teil der Multithread-Welt ist, ist unsere Aufgabe, zu lernen, wie man damit koexistiert. Verschiedene Strategien helfen: von simpel bis raffiniert.
Sperre Ressourcen immer in derselben Reihenfolge
Die Idee ist simpel: wenn alle Threads Ressourcen immer in derselben Reihenfolge erwerben, kann kein zyklisches Warten entstehen. Diese Technik nennt man oft ordered locking — „geordnete Sperrung“.
Beispiel
Gefährlicher Code:
// Thread 1
lock (resA)
{
lock (resB)
{
// ...
}
}
// Thread 2
lock (resB)
{
lock (resA)
{
// ...
}
}
Hier ist ein deadlock möglich: der erste Thread hat resA und wartet auf resB, der zweite genau umgekehrt. Beide hängen und werden nie freigeben.
Richtiger Code:
// Immer: zuerst resA, dann resB (niemals umgekehrt)
lock (resA)
{
lock (resB)
{
// ...
}
}
Jetzt ist die Reihenfolge einheitlich und ein deadlock ist nicht möglich. Egal, wie viele Ressourcen du hast oder wie sie heißen — wichtig ist, dass alle Threads Locks in der gleichen Reihenfolge nehmen.
Typischer Fehler: findet sich irgendwo im Code auch nur eine Stelle, an der die Reihenfolge verletzt wird, kehrt das Deadlock-Risiko zurück. Also vorsichtig!
Vermeide das Sperren mehrerer Ressourcen gleichzeitig
Das ist am einfachsten: wenn du nicht gleichzeitig Locks für zwei oder mehr Ressourcen nehmen musst — nimm sie nicht! Je mehr verschachtelte Sperren, desto größer die Chance, hängen zu bleiben. Stattdessen kann man z.B. nötige Daten in temporäre Variablen kopieren, den Lock freigeben und erst dann mit anderen Ressourcen arbeiten.
Nutze Timeouts beim Erwerb von Locks
Wenn du wirklich mehrere Locks brauchst, helfen Timeouts. Statt des normalen lock nutze Methoden, die ein „try“ erlauben, und falls es nicht klappt — sauber zurückrollen, z.B. Monitor.TryEnter.
object resourceA = new object();
object resourceB = new object();
bool successA = false, successB = false;
try
{
// Versuchen, beide Locks maximal 2 Sekunden zu bekommen
successA = Monitor.TryEnter(resourceA, TimeSpan.FromSeconds(2));
if (!successA) return; // nicht gelungen, raus
successB = Monitor.TryEnter(resourceB, TimeSpan.FromSeconds(2));
if (!successB) return;
// Kritischer Abschnitt
}
finally
{
if (successB) Monitor.Exit(resourceB);
if (successA) Monitor.Exit(resourceA);
}
Wenn der zweite Lock nicht innerhalb von 2 Sekunden gelingt, geben wir den ersten frei und steigen aus. So entsteht kein deadlock — höchstens rollen einige Threads ihre Versuche zurück.
Minimiere den Codeumfang innerhalb der Sperre
Je kleiner der Codeabschnitt „unter dem Lock“, desto weniger Zeit müssen andere Threads warten. Mach keine aufwändigen Berechnungen, Netzwerkaufrufe oder Disk-IO innerhalb kritischer Sektionen. Lock nehmen — globalen Zustand schnell ändern — freigeben. Alles andere außerhalb erledigen.
Teile Zustand auf — vermeide gemeinsame Ressourcen
Manchmal ist es besser, Locks ganz zu vermeiden, indem man geteilten Zustand vermeidet:
- Verwende unveränderliche Objekte (immutable).
- Arbeite mit Kopien oder lokalen Variablen statt globals.
- Übermittle Daten per Nachrichten (Actor Model, Message Queue).
- Nutze threadsichere Collections: ConcurrentDictionary, ConcurrentQueue usw. aus System.Collections.Concurrent.
3. Deadlock auflösen, wenn er schon passiert ist
Manuell killen und neu starten (nicht die beste Option)
Die einfachste Methode ist, alle blockierten Prozesse zu beenden. Das birgt aber Datenverlust-Risiken. Das ist der letzte Ausweg, den man möglichst vermeiden sollte.
Deadlock-Erkennung zur Laufzeit
Manche Systeme können Deadlocks automatisch erkennen. Wenn ein Thread einen Lock zu lange nicht bekommt, kann er das Ereignis loggen, Monitoring warnen und Recovery einleiten.
if (!Monitor.TryEnter(resource, TimeSpan.FromSeconds(10)))
{
Console.WriteLine("Sieht so aus, als hätten wir einen deadlock oder jemand hält den Lock zu lange!");
// Recovery starten, Dump erzeugen oder Nutzer benachrichtigen
}
In verteilten Systemen gibt es spezielle Detektoren, die Wartungsgraphen analysieren und hängende Transaktionen zwangsweise freigeben.
Automatisches Zurückgeben und Wiederholen
Eine praktische Strategie ist „aufgeben und erneut versuchen“: wenn es nicht gelingt, alle Locks in vernünftiger Zeit zu bekommen, rollbacken, kurz warten (oft zufällige Wartezeit, um Versuche zu entkoppeln) und nochmal versuchen.
for (int attempt = 0; attempt < 3; attempt++)
{
bool gotRes1 = Monitor.TryEnter(res1, TimeSpan.FromSeconds(2));
bool gotRes2 = false;
try
{
if (gotRes1)
{
gotRes2 = Monitor.TryEnter(res2, TimeSpan.FromSeconds(2));
if (gotRes2)
{
// Kritischer Abschnitt
break;
}
}
}
finally
{
if (gotRes2) Monitor.Exit(res2);
if (gotRes1) Monitor.Exit(res1);
}
// Nicht gelungen — warten und erneut versuchen
Thread.Sleep(500 + new Random().Next(500));
}
4. Best Practices und Empfehlungen
- Analysiere die Reihenfolge des Lock-Erwerbs. Sammle die Entry-Points und überprüfe die Reihenfolge.
- Verwende nach Möglichkeit Collections aus System.Collections.Concurrent.
- Setze Code-Analyse-Werkzeuge ein. IDEs wie Rider oder Visual Studio helfen, verschachtelte Locks zu finden.
- Logge lange Versuche beim Lock-Erwerb.
- Führe Stress‑Tests für Multithread-Komponenten durch.
- Dokumentiere Vereinbarungen zur Reihenfolge der Locks. Kommentare helfen, Verletzungen der Ordnung zu vermeiden.
5. Praktische Beispiele
Beispiel: Datenbank
In DBMS ist deadlock ein häufiger Gast: zwei Transaktionen updaten dieselben Tabellen, aber in unterschiedlicher Reihenfolge (A→B und B→A). Die meisten DBMS können solche Situationen detektieren und „töten“ eine der Transaktionen.
Beispiel: Asynchrone Methoden
In .NET kann das Mischen von Asynchronität und Locks zu gegenseitigen Blockaden führen: await blockiert einen Thread, der einen Lock hält, während ein anderer Thread auf dieselbe Ressource wartet. Plane die Grenzen kritischer Sektionen und vermeide await innerhalb von ihnen.
6. Typische Fehler beim Arbeiten mit Locks
Fehler Nr. 1: Sperren von Strings.
lock (myString) — schlechte Idee. In .NET werden Strings interniert, deshalb sperrst du praktisch die globale String-Tabelle.
Fehler Nr. 2: unterschiedliche Reihenfolge beim Erwerb von Objekten.
Wenn an verschiedenen Stellen im Programm Locks in unterschiedlicher Reihenfolge genommen werden, steigt die Wahrscheinlichkeit für deadlock stark an.
Fehler Nr. 3: zu lange Locks.
Einen Lock länger halten als nötig oder externe Methoden innerhalb einer kritischen Sektion aufzurufen — sichere Wege, Hänger und gegenseitige Sperren einzufangen.
GO TO FULL VERSION