Wszystkie wpisy

Synchronizacja wątków w C# - lock, Monitor, SemaphoreSlim, ReaderWriterLockSlim

Race condition, sekcja krytyczna i sposoby ich obrony - lock z konwencjami Microsoftu, Monitor.TryEnter z timeoutem, SemaphoreSlim dla N wątków i kodu asynchronicznego, ReaderWriterLockSlim dla wzorca read-heavy. Jedenasta część kursu C#.


W poprzednim wpisie Wielowątkowość i programowanie równoległe zobaczyłeś, jak uruchomić pracę równolegle na wielu wątkach. Wspomnieliśmy wtedy o wyścigach (race conditions) i Interlocked, ale to dopiero wierzchołek góry lodowej. Gdy wątki współdzielą bardziej skomplikowane dane (kolekcje, obiekty, sekwencje operacji), potrzebujesz cięższego sprzętu: mechanizmów synchronizacji.

W tej części przejdziemy przez cztery najważniejsze: lock (najprostszy), Monitor (silnik pod spodem lock z dodatkowymi możliwościami), SemaphoreSlim (N wątków zamiast jednego + zgodność z async/await) oraz ReaderWriterLockSlim (wzorzec wielu czytelników i pojedynczego pisarza). Plus pułapki, które w synchronizacji zbierają najwięcej ofiar.

1. Race condition i sekcja krytyczna #

Sekcja krytyczna to fragment kodu, który czyta lub modyfikuje współdzielone dane - obiekt widziany przez kilka wątków. Race condition to sytuacja, w której dwa wątki wchodzą do sekcji krytycznej jednocześnie i nakładają na siebie operacje w taki sposób, że wynik zależy od kolejności ich wykonania - jest niedeterministyczny.

Klasyczny przykład: inkrementacja licznika.

private int _counter = 0;

void Worker()
{
    _counter++;   // niewinna linijka, jedno z trzech działań procesora
}

W kodzie C# to jedna instrukcja. Dla procesora to trzy osobne kroki:

  1. Read: pobierz aktualną wartość _counter z pamięci do rejestru
  2. Modify: dodaj 1 do wartości w rejestrze
  3. Write: zapisz nową wartość z rejestru do pamięci

Jeśli dwa wątki wchodzą tu naraz, scenariusz wygląda tak:

Wątek A: read   → 10 (rejestr A = 10)
Wątek B: read   → 10 (rejestr B = 10)
Wątek A: modify → 11
Wątek B: modify → 11
Wątek A: write  → _counter = 11
Wątek B: write  → _counter = 11

Po dwóch inkrementach _counter ma wartość 11 zamiast 12 - jeden inkrement zniknął. To race condition w czystej postaci.

Rozwiązaniem jest synchronizacja - mechanizm, który zapewnia, że tylko jeden wątek w danej chwili wykonuje sekcję krytyczną.

2. lock - prosta sygnalizacja świetlna #

Co to jest: lock(obj) to instrukcja, która zakłada wzajemnie wykluczającą blokadę na obiekcie obj. Tylko jeden wątek na raz wchodzi w blok kodu. Pozostałe czekają w kolejce.

Dlaczego istnieje: to najprostsze narzędzie synchronizacji w C#. Dla większości codziennych zastosowań w pełni wystarczy.

public class Account
{
    private decimal _balance;
    private readonly object _guard = new object();

    public void Deposit(decimal amount)
    {
        lock (_guard)
        {
            _balance += amount;   // sekcja krytyczna - tylko jeden wątek naraz
        }
    }

    public void Withdraw(decimal amount)
    {
        lock (_guard)
        {
            if (_balance >= amount)
                _balance -= amount;
        }
    }
}

Co kompilator generuje pod spodem #

Cytat z dokumentacji Microsoftu - lock(x) jest precyzyjnie równoważne:

object __lockObj = x;
bool __lockWasTaken = false;
try
{
    System.Threading.Monitor.Enter(__lockObj, ref __lockWasTaken);
    // Twój kod...
}
finally
{
    if (__lockWasTaken) System.Threading.Monitor.Exit(__lockObj);
}

lock to lukier składniowy dla Monitor.Enter/Monitor.Exit w try/finally. finally gwarantuje, że blokada zwalnia się nawet przy wyjątku - inaczej padłaby aplikacja na zawsze trzymając klucz.

Konwencja: na czym blokować #

Cytat MS: lock on a dedicated object instance that isn’t used for another purpose. Trzy zasady:

  1. Prywatne, niemutowalne pole - private readonly object _guard = new();
  2. Dedykowane do tego konkretnego zasobu - nie współdziel jednego klucza dla niezwiązanych zasobów (zwiększa kontencję, ryzykuje deadlock)
  3. Nigdy nie blokuj na: this, Type instancje (typeof(X)), string (w tym literałach)

Dlaczego te trzy są zakazane?

CoCzemu zakazane
lock(this)Caller z zewnątrz może też blokować na tym samym obiekcie - kolizja
lock(typeof(X))Type to obiekt globalny dla całego AppDomain - inny kod może też na nim blokować
lock("key")Stringi mogą być internowane - identyczne literały dzielą fizycznie ten sam obiekt w pamięci, dając przypadkowe kolizje

Od C# 13 / .NET 9 - typ System.Threading.Lock #

W najnowszych wersjach Microsoft dodał dedykowany typ:

// C# 13 / .NET 9
private readonly System.Threading.Lock _guard = new();

public void Deposit(int amount)
{
    lock (_guard)
    {
        _balance += amount;
    }
}

Kompilator wtedy zamienia lock(_guard) na using (_guard.EnterScope()) { ... } - wydajniejsze od starszej ścieżki przez Monitor. Jeśli używasz starszej wersji .NET, pozostań przy private readonly object.

Krytyczne zakazy: await w lock #

Najważniejszy zakaz, którego musisz przestrzegać:

You can’t use the await expression in the body of a lock statement.

Powód jest fundamentalny. await może wznowić kontynuację na innym wątku niż ten, który założył blokadę. Monitor.Exit wywołany z innego wątku niż Monitor.Enter rzuca SynchronizationLockException - bo “klucz” należy do jednego konkretnego wątku. Kompilator wykrywa to i zgłasza błąd.

W asynchronicznym kodzie używaj SemaphoreSlim z WaitAsync - patrz sekcja 4.

3. Monitor - silnik z pełną kontrolą #

Monitor to klasa, na której lock jest oparty. Używa się jej ręcznie, gdy potrzebujesz funkcji, których lock nie daje - przede wszystkim blokady z timeoutem.

TryEnter - blokada z odpuszczeniem #

public class AdvancedAccount
{
    private decimal _balance;
    private readonly object _guard = new object();

    public bool DepositWithTimeout(decimal amount)
    {
        bool taken = false;
        try
        {
            // Próbuj założyć blokadę przez 2 sekundy
            Monitor.TryEnter(_guard, TimeSpan.FromSeconds(2), ref taken);

            if (taken)
            {
                _balance += amount;
                return true;
            }
            else
            {
                // Inny wątek trzyma blokadę zbyt długo - odpuść
                return false;
            }
        }
        finally
        {
            if (taken) Monitor.Exit(_guard);
        }
    }
}

TryEnter to obrona przed deadlockiem albo nieoczekiwanie długim oczekiwaniem. W systemach pod presją czasową (web API, gry) wolisz zwrócić błąd “spróbuj za chwilę” niż wisieć minutami.

Monitor.Wait i Pulse - prymitywy sygnalizacji #

Monitor ma jeszcze parę zaawansowanych metod do sygnalizacji między wątkami:

  • Monitor.Wait(obj) - wątek odpuszcza blokadę i czeka na sygnał od innego wątku
  • Monitor.Pulse(obj) - budzi jeden czekający wątek
  • Monitor.PulseAll(obj) - budzi wszystkie czekające wątki

To podstawa wzorca producent-konsument na niskim poziomie. W praktyce większość programistów używa wyższych abstrakcji (BlockingCollection<T>, Channel<T>), ale dobrze wiedzieć, że Monitor potrafi to wszystko pod spodem.

4. SemaphoreSlim - bramkarz w klubie #

Co to jest: SemaphoreSlim to lekka wersja semafora pozwalająca określić, ile wątków maksymalnie może wejść do sekcji jednocześnie. lock przepuszcza jeden; SemaphoreSlim(5) przepuszcza pięć.

Dlaczego istnieje: dwa kluczowe powody:

  1. Limit współbieżności na zewnętrzny zasób - max 5 jednoczesnych zapytań HTTP, max 10 połączeń z bazą, max 3 procesy ffmpeg.
  2. Zgodność z async/await - ma metodę WaitAsync zwracającą Task, można na nią await bez blokowania wątku.
public class DownloadManager
{
    // Pozwala na maksymalnie 3 jednoczesne pobierania
    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(3);

    public async Task DownloadFileAsync(string fileName)
    {
        Console.WriteLine($"Waiting in queue: {fileName}");

        // Czekanie asynchroniczne - NIE blokuje wątku ThreadPool
        await _semaphore.WaitAsync();

        try
        {
            Console.WriteLine($"[START] Downloading: {fileName}");
            await Task.Delay(2000);   // symulacja pobierania
            Console.WriteLine($"[DONE] Downloaded: {fileName}");
        }
        finally
        {
            _semaphore.Release();   // ZAWSZE w finally
        }
    }
}

Lightweight kontra Semaphore #

Cytat MS: The SemaphoreSlim is a lightweight alternative to the Semaphore class that doesn’t use Windows kernel semaphores… You can use it as a local semaphore only.

SemaphoreSemaphoreSlim
ImplementacjaObiekt kernela OSLekka, w obrębie procesu
Nazwany (cross-process)TakNie
WydajnośćWolniejsza (przejście do kernela)Szybsza
WaitAsyncNie maTak - kompatybilny z async
Rekomendacja MSTylko gdy potrzebujesz cross-processDomyślny wybór w obrębie aplikacji

Niuans: Release może być z innego wątku #

Cytat MS: SemaphoreSlim doesn’t enforce thread or task identity on calls to the Wait, WaitAsync, and Release methods. Oznacza to, że wątek A może WaitAsync, a wątek B Release - to dozwolone i bywa użyteczne (np. Wait w jednej metodzie, Release w callbacku). Ale to też pułapka: nie polegaj na fakcie, że Release zwalnia “tego samego” zwolnienia co Wait.

Pułapka: zapomnienie o Release #

Cytat MS: It is the programmer’s responsibility to ensure that calls to Wait or WaitAsync methods are appropriately paired with calls to Release methods. Zawsze opakowuj w try/finally:

await _semaphore.WaitAsync();
try
{
    // Twoja praca - może rzucić wyjątek
}
finally
{
    _semaphore.Release();   // gwarantowane zwolnienie
}

Bez finally wyjątek w sekcji krytycznej zostawia slot semafora zajęty na zawsze - wyciek. Po N takich wyciekach (gdzie N to maksymalny licznik semafora) cały semafor stoi i nic nie przepuszcza.

5. ReaderWriterLockSlim - wielu czytelników, jeden pisarz #

Co to jest: specjalna blokada dla wzorca read-heavy - tysiące odczytów, rzadkie modyfikacje. Wielu wątków może czytać równocześnie; pisarz dostaje wyłączny dostęp (i blokuje wszystkich czytających).

Dlaczego istnieje: zwykły lock serializuje wszystkie operacje, nawet czytanie. Dla cache’a, do którego sięgają tysiące wątków na sekundę, a aktualizuje się raz na minutę - to marnotrawstwo. ReaderWriterLockSlim daje czytelnikom równoległość.

public class ConcurrentCache
{
    private readonly Dictionary<string, string> _data = new();
    private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();

    public string Get(string key)
    {
        _lock.EnterReadLock();
        try
        {
            return _data.TryGetValue(key, out var value) ? value : null;
        }
        finally
        {
            _lock.ExitReadLock();
        }
    }

    public void Set(string key, string value)
    {
        _lock.EnterWriteLock();
        try
        {
            _data[key] = value;
        }
        finally
        {
            _lock.ExitWriteLock();
        }
    }
}

Trzy tryby wejścia:

TrybCo pozwalaWspółistnienie
EnterReadLockCzytanieWielu czytelników naraz - bez ograniczeń
EnterWriteLockModyfikacjaWyłączny dostęp - blokuje wszystkich (czytelników i pisarzy)
EnterUpgradeableReadLockCzytanie + opcja upgrade do writeJeden upgradeable + wielu czytelników; potem EnterWriteLock z tego trybu

Kiedy NIE używać #

ReaderWriterLockSlim ma większy narzut niż lock na każdą operację - opłaca się tylko, gdy stosunek odczytów do zapisów jest wysoki (10:1 albo więcej) i sekcja krytyczna jest na tyle długa, że narzut zwykłego lock byłby zauważalny. Dla krótkich sekcji lock często wygrywa mimo “serializowania”.

Jak zwykle: mierz, nie zgaduj.

6. Wybór narzędzia - tabela podsumowująca #

NarzędziePrzepuszczaAsync-readyCross-processKiedy
lock1 wątekNie (zakaz await)NieDomyślny wybór; proste sekcje synchroniczne
Monitor.TryEnter1 wątek z timeoutemNieNieGdy potrzebujesz odpuścić po czasie (anty-deadlock)
SemaphoreSlim(N)N wątkówTak (WaitAsync)NieLimit współbieżności na zasób; jedyna opcja w async
SemaphoreN wątkówNieTak (nazwany)Cross-process synchronizacja
ReaderWriterLockSlimWielu czytelników LUB 1 pisarzNieNieRead-heavy wzorzec (cache, słownik)
Mutex1 wątekNieTak (nazwany)Cross-process exclusivity (single-instance app)
InterlockedAtomowa operacjaN/ANieProste liczniki/flagi - bez blokady

7. Pułapki produkcyjne #

Brak finally przy ręcznym Monitor.Enter #

// ŹLE - wyjątek w środku nie zwolni blokady
Monitor.Enter(_guard);
DoSomethingThatMightThrow();
Monitor.Exit(_guard);   // nigdy nie wywołane przy wyjątku
// DOBRZE - try/finally zapewnia zwolnienie
bool taken = false;
try
{
    Monitor.Enter(_guard, ref taken);
    DoSomethingThatMightThrow();
}
finally
{
    if (taken) Monitor.Exit(_guard);
}

Albo - po prostu używaj lock, kompilator wygeneruje try/finally za Ciebie.

Deadlock dwóch blokad w odwrotnej kolejności #

// Wątek A:
lock (_lockX)
{
    lock (_lockY) { ... }   // czeka na lockY (trzyma B)
}

// Wątek B:
lock (_lockY)
{
    lock (_lockX) { ... }   // czeka na lockX (trzyma A)
}
// → wzajemne wieczne czekanie

Obrona: zawsze blokuj w tej samej kolejności (np. alfabetycznie po nazwie). Albo - unikaj wielu blokad przez przeprojektowanie - jeden większy lock zamiast dwóch mniejszych, lub kolejka (Channel<T>, BlockingCollection<T>) zamiast jawnej synchronizacji.

Blokada na obiekcie typu wartościowego #

int counter = 0;
lock (counter) { counter++; }   // BŁĄD KOMPILACJI - lock wymaga referencyjnego

lock wymaga typu referencyjnego. Typy wartościowe (int, struct) były boksowane każdorazowo do nowego obiektu - kolejne lock(counter) blokowałoby innego boxa. Kompilator C# 7.3+ zgłasza to jako błąd.

Lock + długa operacja blokuje wszystkich #

lock (_guard)
{
    var data = HttpClient.GetStringAsync(url).Result;   // 5 sekund I/O w blokadzie!
    Process(data);
}

Cytat MS: Hold a lock for as short time as possible to reduce lock contention. W sekcji krytycznej trzymaj tylko szybkie operacje na pamięci. I/O, długie obliczenia - poza locka. W tym przypadku: pobierz dane bez locka, wejdź pod lock dopiero do modyfikacji wspólnego stanu.

SemaphoreSlim bez Dispose #

public class Worker
{
    private readonly SemaphoreSlim _sem = new(5);
    // brak Dispose - wyciek
}

SemaphoreSlim implementuje IDisposable - zwalnia wewnętrzne uchwyty. Jeśli żyje przez całe życie aplikacji, OK. Jeśli tworzysz go tymczasowo - using albo jawny Dispose.

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Sekcja krytyczna i race condition - counter++ to trzy instrukcje CPU; bez ochrony wątki “zjadają” inkrementy
  • lock - najprostsze narzędzie; pod spodem Monitor.Enter/Exit w try/finally; konwencja prywatnego niemutowalnego klucza; zakaz blokowania na this/Type/string; zakaz await w środku
  • Monitor - silnik lock plus TryEnter z timeoutem (obrona przed deadlockiem) i Wait/Pulse (sygnalizacja niskopoziomowa)
  • SemaphoreSlim - N wątków na raz; WaitAsync dla kodu async; lekka wersja Semaphore; zawsze Release w finally
  • ReaderWriterLockSlim - wzorzec read-heavy: wielu czytelników kontra wyłączny pisarz; trzy tryby (Read, Write, UpgradeableRead); narzut wyższy niż lock, opłaca się przy odpowiednim stosunku odczytów do zapisów
  • Pułapki - brak try/finally przy ręcznym Monitor, deadlock dwóch blokad w odwrotnej kolejności, blokada typu wartościowego, długie operacje w lock’u

W następnym wpisie Async/await w C# całe drzewo synchronizacji odwróci się o 180 stopni - zamiast blokować wątek na czas czekania, będziemy go zwalniać, a kontynuacja wykona się “kiedy będzie gotowe”. Poznasz typ Task, model TAP, Task.WhenAll do równoległej kompozycji oraz dlaczego .Result/.Wait() to anti-pattern.

Quiz i zadanie poniżej. Zadanie demonstruje dokładnie wzorzec MS z dokumentacji lock: konto bankowe z synchronizowanymi Deposit/Withdraw plus dziesięć równoczesnych zadań - deterministyczny wynik 0 mimo 20 000 operacji konkurujących o ten sam stan.

Podobne wpisy

🔗 Linkują tu