1. Wprowadzenie
Mutex i lock — to jak barista, który obsługuje jednego klienta na raz. Ale co jeśli nie mamy jednej ekspresu, tylko trzy — i jednocześnie można zrobić trzy kawy?
Na przykład masz kawiarnię z trzema ekspresami. Klienci (wątki) przychodzą, zajmują wolny ekspres, robią kawę i wychodzą. Jeśli wszystkie trzy maszyny są zajęte, pozostali czekają, aż zwolni się przynajmniej jedna.
Pytanie: Jak sprawić, żeby jednocześnie przy ekspresach pracowało nie więcej niż trzech klientów, a reszta czekała w kolejce?
Odpowiedź: użyć semafora!
Co to jest semafor?
Semafor — klasyczne narzędzie synchronizacji. Jeśli lock/Mutex kontrolują "jeden wszedł — reszta czeka", to semafor mówi: "pozwalam na N jednocześnie!".
Semafory zostały zaproponowane przez Edsgera Dijkstrę w 1965 roku. Nazwa pochodzi z sygnalizacji morskiej: tak jak flagi przekazywały informacje, tak semafor w kodzie mówi wątkom — możesz wejść albo musisz poczekać.
Scenariusze użycia
- Ograniczenie liczby wątków pracujących jednocześnie z zasobem.
- Limit jednoczesnych połączeń do BD, liczby równoległych zapytań, ciężkich zadań.
2. Przegląd klas: Semaphore i SemaphoreSlim
Semaphore
- Ciężka klasa, używa obiektów jądra OS (kernel objects).
- Wspiera synchronizację między wątkami różnych procesów.
- Można podać nazwę i dzielić między procesami.
SemaphoreSlim
- Odchudzona wersja, działa tylko w ramach jednego procesu.
- Szybsza i oszczędniejsza jeśli chodzi o zasoby.
- Prawie zawsze preferowana, jeśli nie potrzebujesz synchronizacji międzyprocesowej.
Analogia: plecak turystyczny (SemaphoreSlim) kontra duża walizka (Semaphore). Podróżujesz lekko — weź plecak.
Tabela porównawcza
| Klasa | Międzyprocesowy | Wydajność | Polecane |
|---|---|---|---|
|
Tak | Wolniejszy | Gdy potrzebna synchronizacja między procesami |
|
Nie | Szybszy | W 99% przypadków, wewnątrz jednego procesu |
Główne metody i właściwości semafora
Główne parametry
- InitialCount — początkowa liczba zezwoleń.
- MaxCount — maksimum jednocześnie wydanych zezwoleń.
Kluczowe metody
- Wait() lub WaitAsync() — zażądać dostępu (zająć zezwolenie).
- Release() — zwolnić zezwolenie.
Jak to działa
Jeśli przy wywołaniu Wait() nie ma zezwoleń, wątek zostaje zablokowany i czeka, aż ktoś wywoła Release(). Po zwolnieniu jedno z oczekujących wznowi wykonanie.
3. Pierwszy praktyczny przykład
Dodamy do aplikacji konsolowej "parking" na 3 miejsca i spróbujemy uruchomić 10 wątków.
using System;
using System.Threading;
class Program
{
// Semafor z 3 zezwoleniami (3 miejsca na parkingu)
static SemaphoreSlim parking = new SemaphoreSlim(3);
static void Main()
{
for (int i = 1; i <= 10; i++)
{
int carNumber = i;
new Thread(() =>
{
Console.WriteLine($"Maszyna #{carNumber} próbuje zaparkować...");
parking.Wait(); // Czeka na wolne miejsce
Console.WriteLine($"Maszyna #{carNumber} wjechała na parking!");
Thread.Sleep(2000); // Stoimy na parkingu 2 sekundy
Console.WriteLine($"Maszyna #{carNumber} wyjeżdża z parkingu.");
parking.Release(); // Zwalamy miejsce
}).Start();
}
}
}
- Jednocześnie "zaparkują" tylko trzy maszyny.
- Pozostali będą czekać na zwolnienie miejsca.
- Wyjście konsoli będzie pomieszane — to normalne przy wielowątkowości.
4. Semafor jako ogranicznik obciążenia
Ograniczmy liczbę jednocześnie wykonywanych ciężkich zadań (np. pobrań) do 5.
static SemaphoreSlim semaphore = new SemaphoreSlim(5); // maksymalnie 5 jednoczesnych pobrań
static void DownloadFile(int fileId)
{
semaphore.Wait();
try
{
Console.WriteLine($"--> Zaczynam pobieranie pliku {fileId}");
Thread.Sleep(1000 + fileId * 100); // Pobieranie (symulacja)
Console.WriteLine($"<-- Plik {fileId} pobrany");
}
finally
{
semaphore.Release();
}
}
static void Main()
{
for (int i = 1; i <= 12; i++)
{
int localId = i;
new Thread(() => DownloadFile(localId)).Start();
}
}
Ważna uwaga: Wait() umieszczamy przed blokiem try, a Release() — w finally. Dzięki temu zezwolenie na pewno zostanie zwolnione nawet w przypadku wyjątku.
5. Wait(int millisecondsTimeout) i metody asynchroniczne
Można czekać tylko ograniczony czas:
if (semaphore.Wait(500))
{
// Udało się zająć zezwolenie w pół sekundy!
}
else
{
// W 500 ms nie doczekaliśmy się — przerwane
}
W nowoczesnych aplikacjach (np. ASP.NET) używaj wariantu asynchronicznego: await semaphore.WaitAsync(). To nie blokuje wątku wykonania podczas oczekiwania na zezwolenie.
Uwaga: w kodzie asynchronicznym używaj właśnie SemaphoreSlim i jego WaitAsync, inaczej możesz dostać niespodziewane deadlock-i.
6. Przykłady złej i dobrej praktyki
Częsty błąd — zapomnieć wywołać Release(): zezwolenia "uciekają" i wszystko staje.
Źle
static void SomeWork()
{
semaphore.Wait();
// ... przetwarzanie, a Release zapomniano!
}
Dobrze
static void SomeWork()
{
semaphore.Wait();
try
{
// przetwarzanie
}
finally
{
semaphore.Release();
}
}
Wersja asynchroniczna
static async Task SomeAsyncWork()
{
await semaphore.WaitAsync();
try
{
// asynchroniczne przetwarzanie
}
finally
{
semaphore.Release();
}
}
7. Wewnętrzna budowa semafora (wyjaśnienie "na palcach")
Semafor — to licznik. Wait() zmniejsza go o 1. Jeśli był > 0 — wątek przechodzi; jeśli 0 — wątek czeka. Release() zwiększa licznik i budzi oczekujących.
+-------------------------------+
| Semafor (licznik = 3) |
+-------------------------------+
| [ ] [ ] [ ] | <--- Zezwolenia
+----+----+----+----------------+
| | |
Wątek Wątek Wątek
8. Przydatne niuanse
Różnice w porównaniu z innymi prymitywami
- lock / Monitor / Mutex — wpuszczają tylko jeden wątek (dostęp ekskluzywny).
- Semaphore/SemaphoreSlim — wpuszczają ograniczoną liczbę N wątków jednocześnie.
Semafor nie jest powiązany z "właścicielem": zezwolenie może zwolnić dowolny wątek. To cecha, nie błąd.
Zastosowanie w praktyce
- Limit równoległych połączeń do serwisu lub BD.
- Pula: nie więcej niż N wątków przy zasobie.
- Ograniczenie jednocześnie obsługiwanych żądań webowych.
- Limit odczytu/zapisu by chronić przed przeciążeniem.
- Ograniczenie wywołań zewnętrznego API.
Przykład błędu (Release więcej niż Wait)
var semaphore = new SemaphoreSlim(2);
semaphore.Release(); // Błąd! Licznik stał się 3, przekraczając MaxCount — zostanie rzucony SemaphoreFullException.
Tutaj będzie SemaphoreFullException: licznik przekroczył maksimum.
Różnice między Semaphore i SemaphoreSlim
- SemaphoreSlim — w ramach procesu, szybszy i prostszy (używaj prawie zawsze).
- Semaphore — potrzebny do synchronizacji międzyprocesowej (rzadki scenariusz).
Po co znać semafory?
Klasyczne pytanie na rozmowie: "Jak ograniczyć liczbę wątków pracujących z zasobem?" — poprawna odpowiedź: semafor.
- lock — 1 wątek.
- Semaphore/SemaphoreSlim — N wątków.
9. Typowe błędy i cechy używania semaforów
Błąd nr 1: zapominają wywołać Release(). Jeśli wątek zajął zezwolenie (Wait() lub WaitAsync()), ale go nie zwolnił, pozostali będą czekać w nieskończoność — aplikacja "zamarznie".
Błąd nr 2: wywołują Release() więcej razy niż było Wait(). Pojawiają się "nadmiarowe" zezwolenia. Dla Semaphore to doprowadzi do SemaphoreFullException i popsutej logiki dostępu.
Błąd nr 3: mieszają różne mechanizmy synchronizacji. W jednym miejscu — lock, w innym — semafor dla tego samego zasobu. To zwiększa ryzyko wzajemnych blokad (deadlock).
Błąd nr 4: używają Semaphore w kodzie asynchronicznym. Klasyczny semafor nie jest przyjazny dla async/await. Dla scenariuszy asynchronicznych używaj SemaphoreSlim i WaitAsync().
Błąd nr 5: źle ustawiają initialCount i maxCount. Przy nieprawidłowym doborze wartości ograniczenie można obejść i do zasobu przejdzie więcej wątków niż zaplanowano.
GO TO FULL VERSION