1. Einführung
Livelock — das ist eine Situation in einer multithreaded Anwendung, in der zwei oder mehr Threads versuchen, gegenseitige Sperrung (Deadlock) zu vermeiden, aber am Ende nur endlos auf die Aktionen des anderen reagieren, ohne voranzukommen. Im Unterschied zum Deadlock, wo die Threads eingefroren sind und nichts tun können, wirkt das Programm bei Livelock lebendig: Threads laufen, aber keiner erledigt nützliche Arbeit.
Das ist, als würdet ihr mit einem Kollegen in einem schmalen Flur ständig einen Schritt zurück machen, um einander durchzulassen, und deswegen nie weiterkommen. Von außen lustig, aber im Programm furchtbar.
Beispiel für Livelock: Programmierer in der Tür
Stellt euch zwei überaus höfliche Programmierer vor, die sich in einem engen Flur begegnen. Sie stehen gleichzeitig vor einer Tür und beide entscheiden sich zu weichen: sie treten zur Seite, um den anderen passieren zu lassen. Beide sehen wieder, dass sich nichts geändert hat, und versuchen erneut nachzugeben — endlos. Niemand steht still, alle bewegen sich, aber niemand geht durch die Tür.
In einem multithreaded Programm sieht das so aus: ein Thread sieht eine Ressource als belegt, tritt zurück, prüft erneut, weicht wieder — und so weiter ohne Ende.
2. Livelock in der Praxis
Lass uns eine analoge Situation im Code umsetzen. Angenommen, wir haben zwei Ressourcen (zum Beispiel zwei Bankkonten) und zwei Threads versuchen gleichzeitig, Geld gegenseitig zu überweisen, wobei sie nicht „in den Clinch“ geraten wollen.
class Account
{
public int Balance { get; set; }
public object LockObj { get; } = new object();
}
public class Program
{
static void Transfer(Account from, Account to, int amount)
{
while (true)
{
bool lockedFrom = false;
bool lockedTo = false;
try
{
Monitor.TryEnter(from.LockObj, 100, ref lockedFrom);
Monitor.TryEnter(to.LockObj, 100, ref lockedTo);
if (lockedFrom && lockedTo)
{
from.Balance -= amount;
to.Balance += amount;
break; // Überweisung erledigt!
}
}
finally
{
if (lockedFrom) Monitor.Exit(from.LockObj);
if (lockedTo) Monitor.Exit(to.LockObj);
}
// Hat nicht geklappt? Wir treten zurück und versuchen es nochmal — höflich!
Thread.Sleep(1);
}
}
// ... Start von zwei Threads mit Transfer(A, B, ...); Transfer(B, A, ...);
}
In diesem Beispiel versuchen beide Threads wiederholt, die Locks zu bekommen. Wenn aber beide ständig sehen, dass die Locks belegt sind und nachgeben, läuft alles auf ein „höfliches“ Nachgeben hinaus und es erfolgt keine Überweisung. Auf den Zufall des Schedulers zu setzen ist keine gute Strategie für zuverlässige Programme. Fügt Pausen und backoff hinzu, vermeidet enge „leere“ Loops mit Monitor.TryEnter und Thread.Sleep.
Vergleich Livelock vs Deadlock
| Deadlock | Livelock | |
|---|---|---|
| Threads | Stehen still, warten | Laufen aktiv, warten |
| Ressourcen | Für immer geblockt | Offensichtlich nicht geblockt |
| CPU | Fast keine Auslastung | Kann 100% auslasten |
| Lösung | Timeouts, feste Sperrreihenfolge | Zufall, Pausen, backoff |
3. Starvation (Hunger): wenn die Warteschlange nicht ankommt
Starvation (wörtlich „Hunger“) — das ist eine Situation, in der ein oder mehrere Threads ständig übergangen werden und keinen Zugriff auf die benötigte Ressource bekommen, weil andere Threads immer vor ihnen dran sind.
Im Unterschied zu Deadlock und Livelock wird hier niemand dauerhaft blockiert und niemand gibt endlos nach — einfach manche Threads bekommen ständig kein „Stück vom Kuchen“.
Illustration: Real-Life-Analogie
Stellt euch eine Kantine mit zwei Schlangen vor: „VIP“ (z. B. Kasse für Mitarbeiter des Jahres) und „für alle anderen“. Die VIP-Schlange ist kurz, aber es schafft immer jemand, vorher dort durchzukommen. Die „normalen“ Besucher stehen eine halbe Stunde und sehen zu, wie die VIPs immer wieder vorgezogen werden. Das ist Starvation!
4. Starvation in C#: Praxis und Debugging
Betrachten wir eine Situation mit lock, in der ein Thread mit hohem Priority immer wieder die kritische Sektion betritt und ein anderer „hinterherhinkt“:
private static readonly object _locker = new object();
static void Greedy()
{
while (true)
{
lock (_locker)
{
Console.WriteLine("Habgieriger Thread hat die Ressource ergriffen...");
Thread.Sleep(10); // hält das Lock länger
}
Thread.Sleep(1);
}
}
static void Poor()
{
while (true)
{
lock (_locker)
{
Console.WriteLine("Armer Thread hat versucht einzutreten...");
}
}
}
Wenn man beide Threads startet, hält "Greedy" („Habgierig“) das Lock lang und bekommt es schnell wieder, während "Poor" („Arm“) kaum hineinkommt und de facto „hungert“ ohne Ressource. In echten Szenarien kann Starvation weniger offensichtlich sein: z. B. wenn ein Thread niedrigere Priorität hat oder öfter von der OS verdrängt wird.
Wo tritt Starvation auf?
- Bei Threads mit niedriger Priorität.
- Bei falscher Organisation von Task-Queues (keine faire FIFO-Policy).
- Bei ReaderWriterLockSlim mit Default-Einstellungen: wenn Writers selten kommen, aber Readers ständig, dann wird der Writer ständig „hungern“, weil die Readers den Read Lock halten und keinen Write Lock zulassen.
5. Warum Starvation nicht immer ein Bug ist, aber immer ein Problem
Starvation führt, anders als Deadlock, nicht zum vollständigen Stillstand des Programms, aber das Ergebnis wird unvorhersehbar und unfair: Daten werden ungleich verarbeitet, manche Tasks warten endlos, die Performance leidet. Das ist besonders kritisch in Serveranwendungen, wo alle gleiche Chancen bekommen sollten.
Wie man Livelock und Starvation entdeckt und diagnostiziert
- Starker Anstieg der CPU-Auslastung, während die Aufgabe „stillsteht“ — mögliches Livelock.
- Threads scheinen zu arbeiten, aber einige Tasks werden nie fertig — wahrscheinliche Starvation.
- Logs: wenn das Log zeigt, dass bestimmte Threads oder Tasks nie Zugriff auf eine Ressource bekommen — 100%iges Hunger-Problem.
Wie man Livelock vermeidet
- Verlasst euch nicht ausschließlich auf nachgiebige non-blocking Versuche wie Monitor.TryEnter. Wenn es nicht klappt, fügt zufällige Pausen ein (Thread.Sleep(Random.Next(1, 10)))), damit Threads nicht zeitlich gleichzeitig agieren.
- Vermeidet konstante kurze Loops mit Ressourcenerwerb — sonst wird der „zurückweichen — probieren“-Loop endlos.
- Manchmal hilft ein Algorithmuswechsel: ein „Senioritätsrecht“ einführen, sodass nur ein Thread nachgeben darf und der andere nicht.
Wie man Starvation vermeidet
- Achtet auf Thread-Prioritäten: Hintergrund- oder hochprioritäre Tasks sollten nicht dauerhaft andere verdrängen.
- Verwendet Queues (ConcurrentQueue<T>, Channels, Task-Queues) mit fairer FIFO-Policy, damit Tasks in Reihenfolge bedient werden.
- Bei ReaderWriterLockSlim kann man neue Leser zugunsten von Schreibern pausieren, damit ein Writer doch an einen Write Lock kommt.
- Setzt Timeouts und Logging ein: wenn ein Thread zu lange auf ein Lock wartet, loggt eine Warnung.
- Verringert die Dauer, in der Locks gehalten werden, und minimiert kritische Sektionen.
Vergleich Deadlock, Livelock und Starvation
| Szenario | Deadlock | Livelock | Starvation |
|---|---|---|---|
| Threads | Sind totstehend | Laufen, aber vergeblich | Einige arbeiten, einige nicht |
| Zugriff auf Ressource | Kein Zugriff | Kein Zugriff | Ungleichmäßig, manchmal kein Zugriff |
| CPU | Wenig Auslastung | Stark ausgelastet | Ausgelastet, aber nicht von allen |
| Kritischer Fehler? | Ja | Ja | Manchmal |
GO TO FULL VERSION