Semaphore este un primitiv de sincronizare care gestionează accesul la o resursă partajată printr-un contor și o coadă de thread-uri în așteptare. Conform Wikipedia, 2024, semaforul a fost propus de Edsger Dijkstra în 1965 pentru rezolvarea problemelor de interacțiune multi-thread. Instrumentul permite limitarea numărului de thread-uri care lucrează simultan cu secțiunea critică.
Principalele idei
Semaphore — este un primitiv de sincronizare care utilizează un contor pentru a gestiona accesul la o resursă partajată. Conceptul a fost propus de Edsger Dijkstra în 1965 și a devenit fundamentul tuturor mecanismelor moderne de sincronizare în sistemele de operare.
Semaforul este o variabilă întreagă cu două operații atomice: wait (acquire) și signal (release). Operația wait micșorează contorul, iar signal îl mărește. Când contorul ajunge la zero, thread-ul care apelează wait este blocat până la executarea signal de către un alt thread.
Scopul principal al semaforului este protejarea secțiunilor critice împotriva accesului simultan al mai multor thread-uri. Spre deosebire de mutex, semaforul nu necesită legarea de thread-ul proprietar, ceea ce îl face potrivit pentru o gamă mai largă de sarcini de coordonare.
Conceptul de semafor a apărut în contextul sistemului de operare THE, dezvoltat la Technische Hogeschool Eindhoven. Dijkstra a formalizat semaforul ca o abstractizare matematică, demonstrând suficiența sa pentru implementarea oricăror primitive de sincronizare.
Mecanismul semaforului se bazează pe două operații atomice și o coadă internă de așteptare. La apelarea acquire, thread-ul verifică valoarea contorului și fie continuă execuția, fie se blochează până la eliberarea resursei.
La crearea semaforului se stabilește valoarea inițială a contorului de permisiuni. Fiecare apel acquire micșorează contorul cu 1. Dacă după aceasta contorul devine negativ, thread-ul se blochează. Operația release mărește contorul și trezește unul dintre thread-urile în așteptare.
import java.util.concurrent.Semaphore
val semaphore = Semaphore(3)
fun accessResource() {
semaphore.acquire()
try {
println("${Thread.currentThread().name} lucrează")
} finally {
semaphore.release()
}
}
Când thread-ul apelează acquire cu contorul zero, SO îl plasează în coada FIFO a semaforului. Thread-ul trece în starea BLOCKED, neconsumând timp de procesor. După apelul release, primul thread din coadă trece în starea RUNNABLE și obține acces la resursă.
În teoria sincronizării se disting două tipuri principale de semafoare: binar (binary) și numeric (counting). Alegerea tipului depinde de sarcina specifică de gestionare a accesului la resurse.
Semaforul binar ia doar valorile 0 și 1. Prin comportament seamănă cu mutex, dar fără cerința de proprietate — orice thread poate executa release. Astfel de semafoare sunt convenabile pentru implementarea steagurilor de pregătire și a evenimentelor între thread-uri.
val ready = Semaphore(0)
fun producer() {
Thread.sleep(1000)
ready.release()
}
fun consumer() {
ready.acquire()
println("Datele sunt gata")
}
Semaforul numeric poate lua orice valoare nenegativă. Este utilizat pentru gestionarea unui pool de resurse omogene, unde sunt disponibile mai multe instanțe. De exemplu, un pool de 5 conexiuni de rețea: fiecare acquire ocupă o conexiune, release o returnează în pool.
Semafoarele numerice sunt indispensabile pentru limitarea vitezei de acces la servicii externe și implementarea pool-urilor de thread-uri. Ele permit controlul precis al gradului de paralelism fără gestionarea manuală a thread-urilor.
| Parametru | Semafor binar | Semafor numeric |
|---|---|---|
| Interval | 0 sau 1 | de la 0 la N |
| Thread-uri simultan | 1 | până la N |
| Aplicare | semnalizare, steaguri | pool-uri de resurse, rate limiting |
Dezvoltătorii confundă adesea semaforul cu mutex-ul, deși există diferențe fundamentale între ele. Înțelegerea acestor diferențe este esențială pentru alegerea mecanismului corect de sincronizare în proiect.
Diferența cheie — conceptul de proprietate. Mutex știe întotdeauna care thread l-a capturat și doar acel thread îl poate elibera. Semaforul nu are proprietar: orice thread poate apela release, chiar fără a apela acquire. Acest lucru face mutex-ul mai sigur pentru protejarea datelor, iar semaforul — mai flexibil pentru coordonare.
În practică, mutex este mai rapid pentru blocarea mutuală simplă datorită optimizărilor pentru scenariul tipic. Semaforul necesită cheltuieli suplimentare pentru menținerea contorului. Cu toate acestea, pentru limitarea paralelismului sau implementarea modelului „producător-consumator“, semaforul este de neînlocuit.
| Caracteristică | Semaphore | Mutex |
|---|---|---|
| Proprietate | fără proprietar | are proprietar |
| Eliberare | orice thread | doar thread-ul proprietar |
| Contor | de la 0 la N | binar |
| Utilizare | limitarea paralelismului și semnalizare | protejarea secțiunii critice |
| Recursivitate | nu | da (reentrant) |
În dezvoltarea aplicațiilor mobile, Semaphore este utilizat pentru gestionarea accesului la resurse limitate: conexiuni de rețea, fișiere, baze de date și componente hardware. Platformele moderne oferă implementări încorporate convenabile.
Unul dintre cazurile tipice — pool de conexiuni HTTP. Aplicația poate trimite simultan cel mult 4 cereri către server, deoarece API-ul furnizorului limitează paralelismul. Semaforul cu valoarea inițială 4 garantează că la orice sarcină numărul de cereri simultane nu va depăși limita, iar celelalte thread-uri vor aștepta în coadă.
Fără semafor, la creșterea bruscă a activității utilizatorilor, infrastructura serverului poate suferi o suprasarcină subită, ceea ce duce la timeout-uri și erori 429 Too Many Requests. Semaforul funcționează ca o siguranță, permițând strict un număr specificat de apeluri simultane, indiferent de numărul de thread-uri active.
Android oferă clasa Semaphore din pachetul java.util.concurrent. Să examinăm un exemplu de limitare a cererilor de rețea simultane la două thread-uri pentru prevenirea suprasarcinii serverului.
class ApiClient {
private val throttle = Semaphore(2)
suspend fun fetch(url: String): Result {
throttle.acquire()
return try {
httpGet(url)
} finally {
throttle.release()
}
}
}
În iOS, DispatchSemaphore din GCD rezolvă aceeași sarcină. Dezvoltătorii îl folosesc pentru sincronizarea accesului la resurse în cod asincron fără a bloca thread-ul principal.
let semaphore = DispatchSemaphore(value: 3)
func processBatch(_ items: [UIImage]) {
for img in items {
semaphore.wait()
DispatchQueue.global().async {
applyFilter(to: img)
semaphore.signal()
}
}
}
Cea mai frecventă eroare — release uitat la excepție. Dacă thread-ul se termină cu eroare înainte de a apela release, semaforul rămâne blocat pentru totdeauna pentru celelalte thread-uri. Folosiți try/finally sau defer pentru eliberare garantată. A doua problemă — deadlock la capturarea mai multor semafoare în ordine diferită de thread-uri diferite.
Semafoarele sunt aplicate nu doar pentru protejarea datelor, ci și pentru coordonarea thread-urilor în scenarii complexe multi-thread. Cunoașterea pattern-urilor comune accelerează dezvoltarea și reduce probabilitatea erorilor de sincronizare.
Există câteva pattern-uri dovedite de aplicare a semafoarelor în proiecte reale. Cunoașterea lor ajută la evitarea erorilor tipice și construirea sistemelor multi-thread fiabile.
Semaforul cu valoarea inițială N și release periodic printr-un timer implementează limitarea vitezei cererilor către API. De exemplu, serviciul permite 10 cereri pe secundă: semaforul pornește cu 10, fiecare cerere micșorează contorul, iar un TimerTask separat readuce contorul la valoarea inițială o dată pe secundă. Aceasta protejează atât aplicația, cât și serverul de suprasarcină.
În problema clasică producător-consumator, două semafoare gestionează buffer-ul: empty (permisiuni de scriere) și full (permisiuni de citire). Producătorul apelează acquire pe empty și release pe full, Consumatorul — invers. Această schemă garantează că Consumatorul nu va citi niciodată un buffer gol, iar Producătorul nu îl va umple excesiv.
Aceeași schemă stă la baza buffer-ului delimitat în sistemele de operare — buffer circular cu dimensiune fixă. În aplicațiile mobile, pattern-ul este utilizat pentru procesarea cozilor de imagini, fișiere video și evenimente analitice.
Semafoarele sunt folosite cu succes pentru throttling-ul apelurilor de rețea în serviciile de fundal. De exemplu, o aplicație de analitică trimite pachete de evenimente la server. Fără limitarea thread-urilor concurente, la sarcinile de vârf (pornirea aplicației, sincronizarea după offline) numărul de cereri simultane poate depăși limitele serverului. Semaforul cu valoarea inițială 3 garantează trimiterea lină și previne blocarea pe partea serverului.
Întrebări frecvente
Semaforul nu este doar un contor, ci un primitiv de sincronizare cu operații atomice și coadă de așteptare. Contorul obișnuit nu blochează thread-ul și nu garantează atomicitatea incrementării la accesul concurent al mai multor thread-uri.
Da, deadlock este posibil la capturarea mai multor semafoare în ordine diferită de thread-uri diferite. De exemplu, thread-ul A capturează S1, apoi S2, iar thread-ul B — S2, apoi S1. Stabiliți o ordine unică de capturare pentru toate semafoarele din proiect.
Thread-ul se blochează și trece în stare de așteptare. Nu consumă timp de procesor până când un alt thread apelează release. În Java aceasta este starea BLOCKED, în Swift thread-ul este suspendat de GCD.
Diferența principală — proprietatea. Mutex poate fi eliberat doar de thread-ul proprietar. Binary Semaphore poate fi eliberat de orice thread, ceea ce este convenabil pentru semnalizarea între thread-uri, dar mai puțin sigur pentru protejarea integrității datelor.
Valoarea inițială depinde de scenariu. Pentru protejarea unei singure resurse — 1. Pentru un pool de N conexiuni — N. Pentru semnalizarea între thread-uri folosiți 0, astfel încât thread-ul consumator să aștepte semnalul de la producător.
Rezumat
Vom dezvolta o aplicație mobilă la cheie
IT Sectr creează aplicații iOS și Android pentru startup-uri și afaceri din 2017. Vă vom consilia și vă vom propune cea mai bună soluție.
Citiți și