Semaphore to prymityw synchronizacji, który zarządza dostępem do współdzielonego zasobu za pomocą licznika i kolejki oczekujących wątków. Według Wikipedia, 2024, semafor został zaproponowany przez Edsgera Dijkstrę w 1965 roku do rozwiązywania problemów wielowątkowej interakcji. Narzędzie pozwala ograniczyć liczbę wątków jednocześnie pracujących z sekcją krytyczną.
Najważniejsze
Semaphore — to prymityw synchronizacji wykorzystujący licznik do zarządzania dostępem do współdzielonego zasobu. Koncepcja została zaproponowana przez Edsgera Dijkstrę w 1965 roku i stała się fundamentem wszystkich nowoczesnych mechanizmów synchronizacji w systemach operacyjnych.
Semafor to zmienna całkowitoliczbowa z dwoma atomowymi operacjami: wait (acquire) i signal (release). Operacja wait zmniejsza licznik, a signal zwiększa go. Gdy licznik osiąga zero, wątek wywołujący wait zostaje zablokowany do momentu wykonania signal przez inny wątek.
Głównym przeznaczeniem semafora jest ochrona sekcji krytycznych przed jednoczesnym dostępem wielu wątków. W przeciwieństwie do mutexu, semafor nie wymaga przypisania do wątku-właściciela, co czyni go odpowiednim do szerszego zakresu zadań koordynacyjnych.
Koncepcja semafora powstała w kontekście systemu operacyjnego THE, opracowanego w Technische Hogeschool Eindhoven. Dijkstra sformalizował semafor jako abstrakcję matematyczną, udowadniając jego wystarczalność do implementacji dowolnych prymitywów synchronizacji.
Mechanizm semafora opiera się na dwóch atomowych operacjach i wewnętrznej kolejce oczekiwania. Podczas wywołania acquire wątek sprawdza wartość licznika i albo kontynuuje wykonanie, albo zostaje zablokowany do momentu zwolnienia zasobu.
Podczas tworzenia semafora ustawiana jest początkowa wartość licznika zezwoleń. Każde wywołanie acquire zmniejsza licznik o 1. Jeśli po tym licznik stanie się ujemny, wątek zostaje zablokowany. Operacja release zwiększa licznik i budzi jeden z oczekujących wątków.
import java.util.concurrent.Semaphore
val semaphore = Semaphore(3)
fun accessResource() {
semaphore.acquire()
try {
println("${Thread.currentThread().name} pracuje")
} finally {
semaphore.release()
}
}
Gdy wątek wywołuje acquire przy zerowym liczniku, system operacyjny umieszcza go w kolejce FIFO semafora. Wątek przechodzi w stan BLOCKED, nie zużywając czasu procesora. Po wywołaniu release pierwszy wątek w kolejce przechodzi do stanu RUNNABLE i uzyskuje dostęp do zasobu.
W teorii synchronizacji wyróżnia się dwa główne typy semaforów: binarny (binary) i licznikowy (counting). Wybór typu zależy od konkretnego zadania zarządzania dostępem do zasobów.
Semafor binarny przyjmuje tylko wartości 0 i 1. Swoim zachowaniem przypomina mutex, ale bez wymogu posiadania — każdy wątek może wykonać release. Takie semafory są wygodne do implementacji flag gotowości i zdarzeń między wątkami.
val ready = Semaphore(0)
fun producer() {
Thread.sleep(1000)
ready.release()
}
fun consumer() {
ready.acquire()
println("Dane gotowe")
}
Semafor licznikowy może przyjmować dowolną nieujemną wartość. Jest używany do zarządzania pulą jednorodnych zasobów, gdzie dostępnych jest kilka instancji. Na przykład pula 5 połączeń sieciowych: każde acquire zajmuje jedno połączenie, release zwraca je do puli.
Semafory licznikowe są niezastąpione przy ograniczaniu prędkości dostępu do zewnętrznych usług i implementacji pul wątków. Pozwalają precyzyjnie kontrolować stopień równoległości bez ręcznego zarządzania wątkami.
| Parametr | Semafor binarny | Semafor licznikowy |
|---|---|---|
| Zakres | 0 lub 1 | od 0 do N |
| Wątków jednocześnie | 1 | do N |
| Zastosowanie | sygnalizacja, flagi | pule zasobów, rate limiting |
Programiści często mylą semafor i mutex, chociaż istnieją między nimi fundamentalne różnice. Zrozumienie tych różnic jest kluczowe dla wyboru odpowiedniego mechanizmu synchronizacji w projekcie.
Kluczowa różnica — koncepcja posiadania. Mutex zawsze wie, który wątek go przechwycił, i tylko ten wątek może go zwolnić. Semafor nie ma właściciela: każdy wątek może wywołać release, nawet nie wywołując acquire. To czyni mutex bezpieczniejszym do ochrony danych, a semafor — bardziej elastycznym do koordynacji.
W praktyce mutex jest szybszy dla prostej blokady wzajemnej dzięki optymalizacjom typowego scenariusza. Semafor wymaga dodatkowych narzutów na utrzymanie licznika. Jednak do ograniczania równoległości lub implementacji wzorca „producent-konsument“ semafor jest niezastąpiony.
| Cecha | Semaphore | Mutex |
|---|---|---|
| Posiadanie | brak właściciela | jest właściciel |
| Zwolnienie | każdy wątek | tylko wątek-właściciel |
| Licznik | od 0 do N | binarny |
| Use case | ograniczanie równoległości i sygnalizacja | ochrona sekcji krytycznej |
| Rekursja | nie | tak (reentrant) |
W tworzeniu aplikacji mobilnych Semaphore jest używany do zarządzania dostępem do ograniczonych zasobów: połączeń sieciowych, plików, baz danych i komponentów sprzętowych. Nowoczesne platformy udostępniają wygodne wbudowane implementacje.
Jednym z typowych przypadków jest pula połączeń HTTP. Aplikacja może jednocześnie wysyłać nie więcej niż 4 zapytania do serwera, ponieważ API dostawcy ogranicza równoległość. Semafor z początkową wartością 4 gwarantuje, że przy dowolnym obciążeniu liczba jednoczesnych zapytań nie przekroczy limitu, a pozostałe wątki będą czekać w kolejce.
Bez semafora przy gwałtownym wzroście aktywności użytkowników infrastruktura serwerowa może doznać nagłego przeciążenia, co prowadzi do timeoutów i błędów 429 Too Many Requests. Semafor działa jak bezpiecznik, przepuszczając ściśle określoną liczbę jednoczesnych wywołań niezależnie od liczby aktywnych wątków.
Android udostępnia klasę Semaphore z pakietu java.util.concurrent. Rozważmy przykład ograniczenia jednoczesnych zapytań sieciowych do dwóch wątków w celu zapobieżenia przeciążeniu serwera.
class ApiClient {
private val throttle = Semaphore(2)
suspend fun fetch(url: String): Result {
throttle.acquire()
return try {
httpGet(url)
} finally {
throttle.release()
}
}
}
W iOS DispatchSemaphore z GCD rozwiązuje to samo zadanie. Programiści używają go do synchronizacji dostępu do zasobów w kodzie asynchronicznym bez blokowania głównego wątku.
let semaphore = DispatchSemaphore(value: 3)
func processBatch(_ items: [UIImage]) {
for img in items {
semaphore.wait()
DispatchQueue.global().async {
applyFilter(to: img)
semaphore.signal()
}
}
}
Najczęstszym błędem jest zapomniany release przy wyjątku. Jeśli wątek kończy się z błędem przed wywołaniem release, semafor na zawsze pozostaje zablokowany dla pozostałych wątków. Używaj try/finally lub defer do gwarantowanego zwolnienia. Drugi problem to deadlock przy przechwytywaniu kilku semaforów w różnej kolejności przez różne wątki.
Semafory są stosowane nie tylko do ochrony danych, ale również do koordynacji wątków w złożonych scenariuszach wielowątkowych. Znajomość powszechnych wzorców przyspiesza rozwój i zmniejsza prawdopodobieństwo błędów synchronizacji.
Istnieje kilka sprawdzonych wzorców stosowania semaforów w rzeczywistych projektach. Ich znajomość pomaga uniknąć typowych błędów i budować niezawodne systemy wielowątkowe.
Semafor z początkową wartością N i okresowym release przez timer implementuje ograniczanie prędkości zapytań do API. Na przykład usługa zezwala na 10 zapytań na sekundę: semafor startuje z 10, każde zapytanie zmniejsza licznik, a osobny TimerTask raz na sekundę przywraca licznik do wartości początkowej. To chroni zarówno aplikację, jak i serwer przed przeciążeniem.
W klasycznym zadaniu producent-konsument dwa semafory zarządzają buforem: empty (zezwolenia na zapis) i full (zezwolenia na odczyt). Producent wywołuje acquire na empty i release na full, Konsument — odwrotnie. Taki schemat gwarantuje, że Konsument nigdy nie odczyta pustego bufora, a Producent go nie przepełni.
Ten sam schemat leży u podstaw bufora ograniczonego w systemach operacyjnych — bufora cyklicznego o stałym rozmiarze. W aplikacjach mobilnych wzorzec jest stosowany do przetwarzania kolejek obrazów, plików wideo i zdarzeń analitycznych.
Semafory są z powodzeniem używane do tłumienia wywołań sieciowych w usługach działających w tle. Na przykład aplikacja analityczna wysyła do serwera pakiety zdarzeń. Bez ograniczenia liczby współbieżnych wątków przy szczytowym obciążeniu (uruchomienie aplikacji, synchronizacja po offline) liczba jednoczesnych zapytań może przekroczyć limity serwera. Semafor z początkową wartością 3 gwarantuje płynne wysyłanie i zapobiega blokadzie po stronie serwera.
Często zadawane pytania
Semafor to nie tylko licznik, ale prymityw synchronizacji z atomowymi operacjami i kolejką oczekiwania. Zwykły licznik nie blokuje wątku i nie gwarantuje atomowości inkrementacji przy współbieżnym dostępie wielu wątków.
Tak, deadlock jest możliwy przy przechwytywaniu kilku semaforów w różnej kolejności przez różne wątki. Na przykład wątek A przechwytuje S1, potem S2, a wątek B — S2, potem S1. Ustal jednolitą kolejność przechwytywania dla wszystkich semaforów w projekcie.
Wątek zostaje zablokowany i przechodzi w stan oczekiwania. Nie zużywa czasu procesora do momentu, aż inny wątek wywoła release. W Javie jest to stan BLOCKED, w Swift wątek jest wstrzymywany przez GCD.
Główna różnica — posiadanie. Mutex może być zwolniony tylko przez wątek-właściciela. Binary Semaphore może być zwolniony przez dowolny wątek, co jest wygodne do sygnalizacji między wątkami, ale mniej bezpieczne do ochrony integralności danych.
Początkowa wartość zależy od scenariusza. Do ochrony jednego zasobu — 1. Dla puli N połączeń — N. Do sygnalizacji między wątkami używaj 0, aby wątek-konsument czekał na sygnał od producenta.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również