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:
- Read: pobierz aktualną wartość
_counterz pamięci do rejestru - Modify: dodaj 1 do wartości w rejestrze
- 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:
- Prywatne, niemutowalne pole -
private readonly object _guard = new(); - Dedykowane do tego konkretnego zasobu - nie współdziel jednego klucza dla niezwiązanych zasobów (zwiększa kontencję, ryzykuje deadlock)
- Nigdy nie blokuj na:
this,Typeinstancje (typeof(X)),string(w tym literałach)
Dlaczego te trzy są zakazane?
| Co | Czemu 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ątkuMonitor.Pulse(obj)- budzi jeden czekający wątekMonitor.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:
- 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.
- Zgodność z
async/await- ma metodęWaitAsynczwracającąTask, można na niąawaitbez 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.
Semaphore | SemaphoreSlim | |
|---|---|---|
| Implementacja | Obiekt kernela OS | Lekka, w obrębie procesu |
| Nazwany (cross-process) | Tak | Nie |
| Wydajność | Wolniejsza (przejście do kernela) | Szybsza |
WaitAsync | Nie ma | Tak - kompatybilny z async |
| Rekomendacja MS | Tylko gdy potrzebujesz cross-process | Domyś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:
| Tryb | Co pozwala | Współistnienie |
|---|---|---|
EnterReadLock | Czytanie | Wielu czytelników naraz - bez ograniczeń |
EnterWriteLock | Modyfikacja | Wyłączny dostęp - blokuje wszystkich (czytelników i pisarzy) |
EnterUpgradeableReadLock | Czytanie + opcja upgrade do write | Jeden 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ędzie | Przepuszcza | Async-ready | Cross-process | Kiedy |
|---|---|---|---|---|
lock | 1 wątek | Nie (zakaz await) | Nie | Domyślny wybór; proste sekcje synchroniczne |
Monitor.TryEnter | 1 wątek z timeoutem | Nie | Nie | Gdy potrzebujesz odpuścić po czasie (anty-deadlock) |
SemaphoreSlim(N) | N wątków | Tak (WaitAsync) | Nie | Limit współbieżności na zasób; jedyna opcja w async |
Semaphore | N wątków | Nie | Tak (nazwany) | Cross-process synchronizacja |
ReaderWriterLockSlim | Wielu czytelników LUB 1 pisarz | Nie | Nie | Read-heavy wzorzec (cache, słownik) |
Mutex | 1 wątek | Nie | Tak (nazwany) | Cross-process exclusivity (single-instance app) |
Interlocked | Atomowa operacja | N/A | Nie | Proste 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 spodemMonitor.Enter/Exitwtry/finally; konwencja prywatnego niemutowalnego klucza; zakaz blokowania nathis/Type/string; zakazawaitw środkuMonitor- silniklockplusTryEnterz timeoutem (obrona przed deadlockiem) iWait/Pulse(sygnalizacja niskopoziomowa)SemaphoreSlim- N wątków na raz;WaitAsyncdla koduasync; lekka wersjaSemaphore; zawszeReleasewfinallyReaderWriterLockSlim- 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/finallyprzy ręcznymMonitor, 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
Wielowątkowość i programowanie równoległe w C# - Thread, ThreadPool, TPL, Parallel
Trzy poziomy abstrakcji wielowątkowej w C# - klasa Thread, ThreadPool, Task Parallel Library z Parallel.For/ForEach i PLINQ. Synchronizacja (lock, Interlocked), pułapki race condition i deadlock oraz kiedy paralelizacja szkodzi bardziej niż pomaga. Dziesiąta część kursu C#.
Więzy integralności w SQL - Primary Key, Foreign Key, CASCADE, UNIQUE, CHECK
Mechanizmy obronne bazy danych - Primary Key (INT IDENTITY kontra GUID), Foreign Key i integralność referencyjna, trzy strategie ON DELETE (CASCADE/SET NULL/NO ACTION), constraints UNIQUE i CHECK jako walidacja na poziomie bazy. Dwudziesta czwarta część kursu C#.
SQL w praktyce - DDL i DML - CREATE, INSERT, SELECT, UPDATE, DELETE, JOIN
Praktyczny SQL dla programisty C# - podział na DDL/DML/DCL/TCL, CREATE TABLE z kluczami głównymi i obcymi, CRUD (Wielka Czwórka DML), INNER JOIN, klasyczne pułapki UPDATE/DELETE bez WHERE, mapowanie SQL na LINQ w C#. Dwudziesta trzecia część kursu C#.
🔗 Linkują tu