Gdy kilka zadań jednocześnie korzysta z pliku, puli połączeń albo współdzielonego obiektu, zwykły dostęp bez synchronizacji szybko prowadzi do wyścigów i trudnych do odtworzenia błędów. Właśnie dlatego porównanie mutex vs semaphore jest tak istotne w C# i .NET, gdzie oba mechanizmy blokują dostęp, ale rozwiązują różne problemy. Pokażę, kiedy wybrać wyłączność mutexu, kiedy lepiej ograniczyć liczbę równoległych operacji przez semafor oraz dlaczego w kodzie asynchronicznym najczęściej sięgam po SemaphoreSlim.
Najważniejsze różnice w kilku praktycznych punktach
- Mutex wpuszcza jednocześnie tylko jeden wątek i ma właściciela.
- Semaphore pozwala kontrolować konkretną liczbę równoległych wejść.
- SemaphoreSlim jest zwykle najlepszym wyborem w obrębie jednej aplikacji, szczególnie z WaitAsync.
- Named Mutex i Semaphore mogą synchronizować procesy, ale SemaphoreSlim działa tylko lokalnie.
- Do prostnej sekcji krytycznej w jednym procesie często lepiej użyć lock lub dedykowanego System.Threading.Lock.
Mutex i semafor rozwiązują dwa różne problemy
Mutex oznacza wzajemne wykluczanie. W danym momencie tylko jeden wątek może posiadać mutex i wykonywać chroniony fragment kodu. Traktuję go jak klucz do zamkniętego pomieszczenia: jedna osoba wchodzi, reszta czeka, aż klucz zostanie oddany.
Semafor działa inaczej. Ma licznik dostępnych wejść, na przykład 3. Pierwsze trzy zadania mogą przejść równocześnie, a czwarte zaczeka, dopóki jedno z nich nie wywoła Release(). To dobre rozwiązanie wtedy, gdy zasób nie wymaga pełnej wyłączności, ale ma ograniczoną przepustowość.
Przykładem może być wysyłanie żądań do zewnętrznego API. Jeśli usługa pozwala na kilka równoległych wywołań, semafor ograniczający współbieżność do 5 zadań będzie trafniejszy niż mutex. Z kolei przy modyfikowaniu jednego pliku konfiguracyjnego zwykle potrzebuję jednego właściciela, więc semafor z większym licznikiem mógłby dopuścić do uszkodzenia danych.
Najważniejsze pojęcie w przypadku mutexu
Mutex ma tożsamość właściciela. Wątek, który go przejął, powinien go zwolnić, a inny wątek nie może zrobić tego za niego. Mutex pozwala też temu samemu wątkowi przejąć blokadę ponownie, ale wtedy musi wykonać tyle samo zwolnień.
Ta własność ma praktyczne konsekwencje. Jeśli wątek zakończy działanie bez zwolnienia mutexu, kolejny oczekujący może otrzymać AbandonedMutexException. Nie traktuję tego jako zwykłego timeoutu, tylko jako sygnał, że chronione dane mogły pozostać w niespójnym stanie.
Semafor jest licznikiem, a nie właścicielem
Semafor nie pilnuje, który wątek wykonał Wait() lub WaitAsync(). Jeden wątek może zająć miejsce, a inny może je zwolnić. To przydatne w modelu producent-konsument, ale zwiększa ryzyko błędu programistycznego.
Jeżeli wywołam Release() za dużo razy, licznik semafora może osiągnąć wartość maksymalną i pojawi się SemaphoreFullException. Dlatego zwolnienie powinno znajdować się w bloku finally, a samo oczekiwanie musi zakończyć się sukcesem.
Mutex kontra SemaphoreSlim w bezpośrednim porównaniu
W praktyce nie porównuję wyłącznie klas Mutex i Semaphore. W nowoczesnych aplikacjach .NET równie ważny jest SemaphoreSlim, czyli lżejsza implementacja przeznaczona do synchronizacji wewnątrz jednego procesu.
| Kryterium | Mutex | Semaphore | SemaphoreSlim |
|---|---|---|---|
| Liczba jednoczesnych wejść | Dokładnie 1 | Od 1 do ustalonego limitu | Od 1 do ustalonego limitu |
| Własność wątku | Tak | Nie | Nie |
| Synchronizacja między procesami | Tak, przez nazwany obiekt | Tak, przez nazwany obiekt | Nie |
| Obsługa async | Brak naturalnego WaitAsync
|
Głównie scenariusze synchroniczne | Tak, przez WaitAsync |
| Typowe zastosowanie | Jeden współdzielony zasób lub aplikacja singleton | Współdzielony limit także między procesami | Limit równoległości w jednej aplikacji |
| Koszt użycia | Cięższy obiekt systemowy | Cięższy obiekt systemowy | Zwykle lżejszy w obrębie procesu |
Najważniejsza granica przebiega między wyłącznością a limitem współbieżności. Jeżeli pytam „czy tylko jedno zadanie może wejść?”, myślę o mutexie. Jeżeli pytam „ile zadań może działać naraz?”, wybieram semafor.
Druga granica dotyczy procesów. Named mutex, utworzony z nazwą, może posłużyć do pilnowania, aby w systemie działała tylko jedna instancja aplikacji. Named Semaphore może ograniczać wspólny zasób używany przez kilka procesów. SemaphoreSlim nie ma tej możliwości, ponieważ działa wyłącznie w granicach jednej aplikacji.
Nie zakładam jednak, że mutex jest „mocniejszym semaforem”. To inne abstrakcje. Mutex zapewnia własność i wyłączność, a semafor daje pulę pozwoleń. Próba zastąpienia jednego drugim często kończy się kodem, który działa w prostym teście, ale zawodzi przy większej współbieżności.
Praktyczne przykłady w C#
Mutex dla wyłącznego dostępu
Poniższy przykład chroni operację, którą w danym momencie może wykonywać tylko jeden wątek. Zmienna acquired jest ważna, ponieważ ReleaseMutex() wolno wywołać wyłącznie po skutecznym przejęciu blokady.
using var mutex = new Mutex();
bool acquired = false;
try
{
acquired = mutex.WaitOne(TimeSpan.FromSeconds(5));
if (!acquired)
{
return;
}
UpdateSharedFile();
}
catch (AbandonedMutexException)
{
// Dane trzeba zweryfikować przed dalszą pracą.
acquired = true;
UpdateSharedFile();
}
finally
{
if (acquired)
{
mutex.ReleaseMutex();
}
}W typowej aplikacji webowej nie używam mutexu do każdej sekcji krytycznej. Jest cięższy od mechanizmów działających wyłącznie wewnątrz procesu, a jego obsługa wymaga pilnowania własności. Sięgam po niego wtedy, gdy naprawdę potrzebuję blokady widocznej dla innych procesów albo operuję na API opartym o WaitHandle.
SemaphoreSlim do ograniczenia liczby zadań
Załóżmy, że aplikacja może wykonywać najwyżej cztery operacje jednocześnie. Każde zadanie zajmuje jedno miejsce, a w finally oddaje je innym oczekującym.
private static readonly SemaphoreSlim Gate = new(4, 4);
public static async Task ProcessAsync(
WorkItem item,
CancellationToken cancellationToken)
{
await Gate.WaitAsync(cancellationToken);
try
{
await ProcessItemAsync(item, cancellationToken);
}
finally
{
Gate.Release();
}
}Ten wzorzec dobrze sprawdza się przy wywołaniach HTTP, odczycie danych z ograniczonej puli albo generowaniu kosztownych raportów. Limit 4 nie jest magiczną wartością, więc dobieram go na podstawie czasu odpowiedzi, zużycia CPU, limitów usługi i liczby dostępnych połączeń.
Named mutex dla jednej instancji programu
Jednym z najbardziej czytelnych zastosowań mutexu jest kontrola uruchomienia aplikacji desktopowej. Nazwa sprawia, że kolejne procesy mogą odwołać się do tego samego obiektu systemowego.
using var singleInstance = new Mutex(
initiallyOwned: true,
name: "Kursdotnet.pl.SampleApp");
if (!singleInstance.WaitOne(TimeSpan.Zero))
{
Console.WriteLine("Aplikacja już działa.");
return;
}
RunApplication();W produkcji trzeba jeszcze zadbać o właściwą nazwę i uprawnienia. Nazwany obiekt jest widoczny szerzej niż zwykła zmienna w procesie, dlatego przypadkowa kolizja nazw może zablokować aplikację. Dla prostego kodu wewnątrz jednego procesu taki mutex byłby przerostem formy nad treścią.
Kiedy wybrać SemaphoreSlim, a kiedy zwykły Semaphore
W aplikacji działającej w jednym procesie domyślnie rozważam SemaphoreSlim. Jest przeznaczony do lokalnej synchronizacji, obsługuje anulowanie i ma metodę WaitAsync(), więc pasuje do kodu opartego na async i await.
Zwykły Semaphore wybieram wtedy, gdy potrzebuję nazwanego semafora systemowego albo integracji z mechanizmem WaitHandle. To scenariusze bardziej wyspecjalizowane, na przykład koordynacja kilku procesów uruchomionych na tym samym komputerze.
Nie blokuję synchronicznie wątku na długo tylko dlatego, że mam do dyspozycji WaitOne(). W kodzie asynchronicznym taka decyzja może zajmować wątki puli i pogorszyć przepustowość aplikacji. Jeśli operacja czeka na I/O, zwykle lepiej użyć SemaphoreSlim.WaitAsync i przekazać token anulowania.
Trzeba też pamiętać, że semafor nie gwarantuje kolejności FIFO. Wątek, który czeka najdłużej, nie musi zostać wpuszczony jako pierwszy. Jeżeli aplikacja wymaga ścisłej kolejki, sam semafor nie wystarczy i potrzebny będzie dodatkowy mechanizm kolejkowania.
Najczęstsze błędy przy synchronizacji
Użycie mutexu tam, gdzie wystarczy lock
Jeśli współdzielony stan znajduje się w jednym procesie, nie zaczynam automatycznie od Mutex. Dla krótkiej, synchronicznej sekcji krytycznej lepszy może być lock, a w .NET 9 i C# 13 dedykowany System.Threading.Lock. Taki mechanizm jest prostszy i ogranicza liczbę ręcznych operacji.
Nie używam jednak lock do oczekiwania na operację asynchroniczną. Nie wolno umieszczać await wewnątrz klasycznego bloku lock. W takim przypadku potrzebuję wzorca opartego na SemaphoreSlim albo innej blokadzie obsługującej asynchroniczne oczekiwanie.
Zwolnienie semafora w złym miejscu
Najgroźniejszy błąd polega na wywołaniu Release() mimo tego, że oczekiwanie zostało anulowane lub zakończyło się timeoutem. Wtedy licznik przestaje odzwierciedlać rzeczywistą liczbę zajętych miejsc, a aplikacja może dopuścić zbyt wiele operacji.
if (await Gate.WaitAsync(timeout, cancellationToken))
{
try
{
await DoWorkAsync();
}
finally
{
Gate.Release();
}
}W tym wariancie zwolnienie następuje tylko po uzyskaniu pozwolenia. To drobny szczegół, ale właśnie takie szczegóły decydują o tym, czy ograniczenie współbieżności będzie stabilne po błędach i anulowaniu.
Przeczytaj również: Pętla do-while w C# - składnia, przykłady i pułapki
Zbyt szeroki zakres blokady
Blokada powinna obejmować wyłącznie fragment, który rzeczywiście wymaga ochrony. Jeśli pod mutexem albo semaforem umieszczę długie wywołanie HTTP, cały system może czekać na zewnętrzną usługę, choć współdzielony stan był używany przez kilka milisekund.
Najpierw przygotowuję dane lokalnie, potem krótko aktualizuję wspólny stan, a kosztowne operacje wykonuję poza blokadą, o ile logika na to pozwala. Krótka sekcja krytyczna zwykle daje większą poprawę niż zamiana jednego prymitywu synchronizacji na inny.
Jak podjąć decyzję w konkretnym projekcie
Moja praktyczna reguła wygląda następująco. Najpierw ustalam granicę synchronizacji, potem sprawdzam, czy kod jest synchroniczny czy asynchroniczny, a dopiero na końcu wybieram klasę.
- Wymagasz, aby działał tylko jeden wątek? Rozważ mutex albo prostszy
lock. - Chcesz dopuścić maksymalnie określoną liczbę operacji? Wybierz SemaphoreSlim.
- Operacje zawierają
await? Użyj WaitAsync(), nie blokuj wątku przezWaitOne(). - Synchronizujesz kilka procesów? Potrzebujesz nazwanego Mutex lub Semaphore.
- Chronisz tylko dane lokalne dla jednego procesu i sekcja jest krótka? Sprawdź, czy wystarczy lock.
Przed wdrożeniem testuję także zachowanie przy timeoutach, anulowaniu, wyjątkach i nagłym zakończeniu procesu. Szczególnie w przypadku mutexu sprawdzam scenariusz porzuconej blokady, a przy semaforze liczbę wszystkich wywołań Release(). Te testy pokazują błędy, których zwykły test „dwa wątki uruchomione równocześnie” często nie wykrywa.
Najprostsza zasada wyboru między mutexem a semaforem
Jeżeli chronisz jeden zasób przed równoczesnym użyciem, myśl o mutexie. Jeżeli zarządzasz pulą dostępnych miejsc, połączeń lub operacji, myśl o semaforze. W nowym kodzie C# działającym w jednym procesie najczęściej zacząłbym od SemaphoreSlim dla scenariuszy asynchronicznych, a od lock lub System.Threading.Lock dla krótkiej sekcji synchronicznej.
Najważniejsze nie jest samo słowo w nazwie klasy, lecz odpowiedź na dwa pytania: ilu wykonawców może wejść jednocześnie i czy blokada ma działać między procesami. Gdy te założenia są jasne, właściwy wybór zwykle staje się oczywisty.
