CodeGym /Kursy /C# SELF /Inne problemy: Livelock i Starvation

Inne problemy: Livelock i Starvation

C# SELF
Poziom 57 , Lekcja 3
Dostępny

1. Wprowadzenie

Livelock — to sytuacja w aplikacji wielowątkowej, kiedy dwa lub więcej wątków próbują uniknąć wzajemnego zablokowania (Deadlock), ale w efekcie tylko bez końca reagują na działania siebie nawzajem, nie posuwając się do przodu. W przeciwieństwie do Deadlock, gdzie wątki zatrzymały się i nic nie mogą zrobić, przy Livelock program wygląda na żywy: wątki wykonują się, ale żaden nie wykonuje użytecznej pracy.

To jakbyś ty i kolega w wąskim korytarzu ciągle robili krok w tył, żeby przepuścić drugą osobę, i przez to nigdy nie przechodziliście dalej. Śmieszne z zewnątrz, ale fatalne w programie.

Przykład Livelock: programiści w drzwiach

Wyobraźcie sobie dwóch bardzo uprzejmych programistów, którzy idą naprzeciw siebie w wąskim korytarzu. Oboje jednocześnie stają przed drzwiami i obaj postanawiają ustąpić: robią krok w bok, żeby przepuścić drugiego. Oboje znów widzą, że sytuacja się nie zmieniła, i znowu próbują ustąpić — w nieskończoność. Nikt nie stoi, wszyscy się poruszają, ale nikt nie przechodzi przez drzwi.

W programie wielowątkowym wygląda to tak: wątek widzi zasób zajęty, cofa się, sprawdza jeszcze raz, znowu ustępuje, i tak bez końca.

2. Livelock w praktyce

Zaimplementujmy analogiczną sytuację w kodzie. Załóżmy, że mamy dwa zasoby (np. dwa konta bankowe), i dwa wątki jednocześnie próbują przelać pieniądze do siebie nawzajem, starając się nie „zablokować się”.

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; // Perevod vipolnen!
                }
            }
            finally
            {
                if (lockedFrom) Monitor.Exit(from.LockObj);
                if (lockedTo) Monitor.Exit(to.LockObj);
            }
            // Ne poluchilos'? Odshto i poprobuyem snova — vezhlivo!
            Thread.Sleep(1);
        }
    }
    // ... Zapusk dvuh potokov s Transfer(A, B, ...); Transfer(B, A, ...);
}

W tym przykładzie oba wątki wielokrotnie próbują złapać zamki. Ale jeśli oba cały czas widzą, że zamki są zajęte i ustępują, to wszystko sprowadza się do „uprzejmego” ustępowania i żadnego przelewu pieniędzy nie ma. Poleganie na losowości planisty to zła strategia dla niezawodnego programu. Dodawaj pauzy i backoff, nie twórz krótkich „pustych” pętli wokół Monitor.TryEnter i Thread.Sleep.

Porównanie Livelock vs Deadlock

Deadlock Livelock
Wątki Zatrzymały się, czekają Aktywnie kręcą się, czekają
Zasoby Zablokowane na stałe Wyraźnie nie zablokowane
CPU Prawie nie obciążone Może być obciążone na 100%
Rozwiązanie Timeouty, porządek bloków Losowość, pauzy, backoff

3. Starvation (głodzenie): kiedy do zasobu nie dociera kolejka

Starvation (dosłownie „głodzenie”) — to sytuacja, kiedy jeden lub kilka wątków ciągle są pomijane i nie dostają dostępu do potrzebnego zasobu, ponieważ inne wątki cały czas są przed nimi.

W przeciwieństwie do Deadlock i Livelock, tutaj nikt nie jest zablokowany na zawsze i nikt nie ustępuje w nieskończoność — po prostu niektórym wątkom ciągle nie przypada „kawałek ciasta”.

Ilustracja: analogia z realnego życia

Wyobraź sobie stołówkę z dwiema kolejkami: „VIP” (np. kasa dla pracowników roku) i „dla wszystkich pozostałych”. Kolejka VIP jest krótka, ale tam zawsze ktoś zdąży wcześniej. „Zwykli” klienci stoją pół godziny i patrzą, jak VIP znowu przechodzi przed nimi. To jest właśnie Starvation!

4. Starvation w C#: praktyka i debug

Rozważmy sytuację z lock, kiedy jeden wątek o wysokim priorytecie stale zdąża wejść do sekcji krytycznej, a inny „zostaje w tyle”:

private static readonly object _locker = new object();

static void Greedy()
{
    while (true)
    {
        lock (_locker)
        {
            Console.WriteLine("Zhadny potok zahvatil resurs...");
            Thread.Sleep(10); // dl'she derzhit zamok
        }
        Thread.Sleep(1);
    }
}

static void Poor()
{
    while (true)
    {
        lock (_locker)
        {
            Console.WriteLine("Bedny potok popytalsya voiti...");
        }
    }
}

Jeśli uruchomicie oba wątki, to "Greedy" („Zachłanny”) długo trzyma zamek i szybko znowu go zdobywa, a "Poor" („Biedny”) prawie nie dostaje się do środka i właściwie „głoduje” bez zasobu. W rzeczywistych zadaniach starvation może być mniej oczywiste: np. jeśli jeden wątek ma niższy priorytet lub częściej jest wypychany przez OS.

Gdzie występuje starvation?

  • W wątkach o niskim priorytecie.
  • Przy niepoprawnej organizacji kolejek zadań (brak uczciwej polityki FIFO).
  • W ReaderWriterLockSlim z domyślnymi ustawieniami: jeśli writer przychodzi rzadko, a czytelnicy idą w nieskończonym strumieniu, to writer będzie ciągle „głodował”, bo czytelnicy zajmują Read Lock i nie pozwalają zdobyć Write Lock.

5. Dlaczego Starvation — nie zawsze bug, ale zawsze problem

Starvation, w przeciwieństwie do Deadlock, nie prowadzi do pełnego zatrzymania programu, ale wynik działania staje się nieprzewidywalny i niesprawiedliwy: dane są przetwarzane nierównomiernie, niektóre zadania czekają w nieskończoność, wydajność spada. To szczególnie krytyczne w aplikacjach serwerowych, gdzie wszyscy powinni mieć równe szanse.

Jak wykryć i zdiagnozować Livelock i Starvation

  • Zdecydowany wzrost zużycia CPU, ale zadanie „stoi w miejscu” — możliwy Livelock.
  • Wątki wydają się działać, ale niektóre zadania nigdy się nie kończą — możliwe Starvation.
  • Logi: jeśli dziennik pokazuje, że określone wątki lub zadania nigdy nie dostają dostępu do zasobu — sto procent „głodowanie”.

Jak uniknąć Livelock

  • Nie ograniczaj się do prób złapania zasobów tylko przez „ustępliwe” non-blocking metody typu Monitor.TryEnter. Jeśli się nie uda, dodawaj losowe pauzy (Thread.Sleep(Random.Next(1, 10)))), żeby wątki nie były zsynchronizowane czasowo.
  • Nie używaj stałych krótkich pętli z łapaniem zasobu — inaczej pętla „cofnął się — spróbował” stanie się nieskończona.
  • Czasem pomaga zmienić algorytm: wprowadzić „prawo starszeństwa”, żeby tylko jeden wątek mógł ustępować, a drugi — nie.

Jak uniknąć Starvation

  • Kontroluj priorytety wątków: zadania tła lub o wysokim priorytecie nie powinny na zawsze blokować innych.
  • Używaj kolejek (ConcurrentQueue<T>, kanały, task queue) z uczciwą polityką FIFO, żeby zadania były obsługiwane po kolei.
  • W ReaderWriterLockSlim można wstrzymać nowych czytelników na korzyść writerów, żeby writer w końcu dostał Write Lock.
  • Stosuj timeouty i logi: jeśli wątek próbuje zdobyć zamek zbyt długo, wypisz ostrzeżenie.
  • Zmniejszaj czas trzymania blokad i rozdzielaj sekcje krytyczne.

Porównanie Deadlock, Livelock i Starvation

Scenariusz Deadlock Livelock Starvation
Wątki Stoją martwe Biegają, ale na próżno Ktoś działa, ktoś nie
Dostęp do zasobu Brak Brak Nierównomierny, czasem brak
CPU Nie jest obciążone Mocno obciążone Obciążone, ale nie przez wszystkich
Błąd krytyczny? Tak Tak Czasem
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION