Semaphore est un primitif de synchronisation qui contrôle l'accès à une ressource partagée via un compteur et une file d'attente de threads en attente. Selon Wikipedia, 2024, le sémaphore a été proposé par Edsger Dijkstra en 1965 pour résoudre les problèmes d'interaction multithread. L'outil permet de limiter le nombre de threads travaillant simultanément avec une section critique.
Points clés
Semaphore est un primitif de synchronisation qui utilise un compteur pour contrôler l'accès à une ressource partagée. Le concept a été proposé par Edsger Dijkstra en 1965 et est devenu le fondement de tous les mécanismes modernes de synchronisation dans les systèmes d'exploitation.
Un sémaphore est une variable entière avec deux opérations atomiques : wait (acquire) et signal (release). L'opération wait diminue le compteur, tandis que signal l'augmente. Lorsque le compteur atteint zéro, le thread qui appelle wait est bloqué jusqu'à ce qu'un autre thread exécute signal.
L'objectif principal d'un sémaphore est de protéger les sections critiques de l'accès simultané par plusieurs threads. Contrairement à un mutex, un sémaphore ne nécessite pas de liaison à un thread propriétaire, ce qui le rend adapté à une gamme plus large de tâches de coordination.
Le concept du sémaphore est né dans le contexte du système d'exploitation THE, développé à la Technische Hogeschool Eindhoven. Dijkstra a formalisé le sémaphore comme une abstraction mathématique, prouvant sa suffisance pour implémenter n'importe quel primitif de synchronisation.
Le mécanisme du sémaphore est basé sur deux opérations atomiques et une file d'attente interne. Lorsque acquire est appelé, le thread vérifie la valeur du compteur et soit continue l'exécution, soit se bloque jusqu'à ce que la ressource soit libérée.
Lors de la création d'un sémaphore, une valeur initiale du compteur de permissions est définie. Chaque appel acquire diminue le compteur de 1. Si le compteur devient négatif après cela, le thread est bloqué. L'opération release augmente le compteur et réveille l'un des threads en attente.
import java.util.concurrent.Semaphore
val semaphore = Semaphore(3)
fun accessResource() {
semaphore.acquire()
try {
println("${Thread.currentThread().name} travaille")
} finally {
semaphore.release()
}
}
Lorsqu'un thread appelle acquire avec un compteur à zéro, l'OS le place dans la file FIFO du sémaphore. Le thread passe à l'état BLOCKED, ne consommant pas de temps CPU. Après un appel release, le premier thread dans la file passe à l'état RUNNABLE et obtient l'accès à la ressource.
Dans la théorie de la synchronisation, on distingue deux types principaux de sémaphores : binaire et compteur. Le choix du type dépend de la tâche spécifique de gestion d'accès aux ressources.
Un sémaphore binaire ne prend que les valeurs 0 et 1. Par son comportement, il ressemble à un mutex, mais sans l'exigence de propriété — n'importe quel thread peut exécuter release. Ces sémaphores sont pratiques pour implémenter des drapeaux de disponibilité et des événements entre threads.
val ready = Semaphore(0)
fun producer() {
Thread.sleep(1000)
ready.release()
}
fun consumer() {
ready.acquire()
println("Données prêtes")
}
Un sémaphore compteur peut prendre n'importe quelle valeur non négative. Il est utilisé pour gérer un ensemble de ressources similaires où plusieurs instances sont disponibles. Par exemple, un pool de 5 connexions réseau : chaque acquire prend une connexion, release la retourne au pool.
Les sémaphores compteurs sont indispensables pour limiter la vitesse d'accès aux services externes et implémenter des pools de threads. Ils permettent de contrôler précisément le degré de parallélisme sans gestion manuelle des threads.
| Paramètre | Sémaphore binaire | Sémaphore compteur |
|---|---|---|
| Plage | 0 ou 1 | de 0 à N |
| Threads simultanés | 1 | jusqu'à N |
| Application | signalisation, drapeaux | pools de ressources, limitation de débit |
Les développeurs confondent souvent sémaphore et mutex, bien qu'il existe des différences fondamentales entre eux. Comprendre ces différences est crucial pour choisir le mécanisme de synchronisation approprié dans un projet.
La différence clé est le concept de propriété. Un mutex sait toujours quel thread l'a acquis, et seul ce thread peut le libérer. Un sémaphore n'a pas de propriétaire : n'importe quel thread peut appeler release sans même avoir appelé acquire. Cela rend le mutex plus sûr pour la protection des données et le sémaphore plus flexible pour la coordination.
En pratique, un mutex est plus rapide pour l'exclusion mutuelle simple grâce aux optimisations pour les scénarios typiques. Un sémaphore nécessite une surcharge supplémentaire pour maintenir le compteur. Cependant, pour limiter le parallélisme ou implémenter le modèle producteur-consommateur, un sémaphore est indispensable.
| Caractéristique | Semaphore | Mutex |
|---|---|---|
| Propriété | pas de propriétaire | a un propriétaire |
| Libération | n'importe quel thread | seul le thread propriétaire |
| Compteur | de 0 à N | binaire |
| Cas d'utilisation | limitation du parallélisme et signalisation | protection de section critique |
| Récursion | non | oui (réentrant) |
Dans le développement d'applications mobiles, Semaphore est utilisé pour gérer l'accès à des ressources limitées : connexions réseau, fichiers, bases de données et composants matériels. Les plateformes modernes offrent des implémentations intégrées pratiques.
Un cas d'utilisation typique est un pool de connexions HTTP. Une application ne peut pas envoyer plus de 4 requêtes simultanées à un serveur car l'API du fournisseur limite le parallélisme. Un sémaphore avec une valeur initiale de 4 garantit que sous n'importe quelle charge, le nombre de requêtes concurrentes ne dépasse pas la limite, tandis que les autres threads attendent dans la file.
Sans sémaphore, une augmentation brusque de l'activité utilisateur pourrait provoquer une surcharge soudaine de l'infrastructure serveur, entraînant des timeouts et des erreurs 429 Too Many Requests. Semaphore agit comme un fusible, permettant strictement un nombre spécifié d'appels concurrents indépendamment du nombre de threads actifs.
Android fournit la classe Semaphore du package java.util.concurrent. Voyons un exemple de limitation des requêtes réseau concurrentes à deux threads pour éviter la surcharge du serveur.
class ApiClient {
private val throttle = Semaphore(2)
suspend fun fetch(url: String): Result {
throttle.acquire()
return try {
httpGet(url)
} finally {
throttle.release()
}
}
}
Sous iOS, DispatchSemaphore de GCD résout la même tâche. Les développeurs l'utilisent pour synchroniser l'accès aux ressources dans du code asynchrone sans bloquer le thread principal.
let semaphore = DispatchSemaphore(value: 3)
func processBatch(_ items: [UIImage]) {
for img in items {
semaphore.wait()
DispatchQueue.global().async {
applyFilter(to: img)
semaphore.signal()
}
}
}
L'erreur la plus courante est un release oublié lorsqu'une exception se produit. Si un thread se termine avec une erreur avant d'appeler release, le sémaphore reste bloqué en permanence pour les autres threads. Utilisez try/finally ou defer pour garantir la libération. Le deuxième problème est le deadlock lors de l'acquisition de plusieurs sémaphores dans un ordre différent par des threads différents.
Les sémaphores ne sont pas utilisés seulement pour la protection des données, mais aussi pour la coordination des threads dans des scénarios multithread complexes. Connaître les modèles courants accélère le développement et réduit la probabilité d'erreurs de synchronisation.
Il existe plusieurs modèles éprouvés d'utilisation des sémaphores dans des projets réels. Les connaître aide à éviter les erreurs typiques et à construire des systèmes multithread fiables.
Un sémaphore avec une valeur initiale N et une libération périodique via un timer implémente la limitation de débit des requêtes API. Par exemple, un service permet 10 requêtes par seconde : le sémaphore commence à 10, chaque requête diminue le compteur, et un TimerTask séparé ramène le compteur à sa valeur initiale chaque seconde. Cela protège à la fois l'application et le serveur de la surcharge.
Dans le problème classique du producteur-consommateur, deux sémaphores gèrent un tampon : empty (permissions d'écriture) et full (permissions de lecture). Le producteur appelle acquire sur empty et release sur full, tandis que le consommateur fait l'inverse. Ce schéma garantit que le consommateur ne lit jamais un tampon vide et que le producteur ne le dépasse jamais.
Ce même schéma sous-tend le tampon borné dans les systèmes d'exploitation — un tampon circulaire de taille fixe. Dans les applications mobiles, le modèle est utilisé pour traiter les files d'images, de fichiers vidéo et d'événements analytiques.
Les sémaphores sont utilisés avec succès pour limiter les appels réseau dans les services en arrière-plan. Par exemple, une application d'analyse envoie des paquets d'événements au serveur. Sans limiter les threads concurrents lors des pics de charge (lancement de l'application, synchronisation après hors ligne), le nombre de requêtes simultanées peut dépasser les limites du serveur. Un sémaphore avec une valeur initiale de 3 garantit un envoi fluide et empêche le blocage côté serveur.
Foire aux questions
Semaphore n'est pas simplement un compteur, mais un primitif de synchronisation avec des opérations atomiques et une file d'attente. Un compteur ordinaire ne bloque pas un thread et ne garantit pas l'atomicité de l'incrémentation lors d'un accès concurrent par plusieurs threads.
Oui, un deadlock est possible lors de l'acquisition de plusieurs sémaphores dans un ordre différent par des threads différents. Par exemple, le thread A acquiert S1, puis S2, tandis que le thread B acquiert S2, puis S1. Fixez un ordre d'acquisition unique pour tous les sémaphores du projet.
Le thread se bloque et entre dans un état d'attente. Il ne consomme pas de temps CPU jusqu'à ce qu'un autre thread appelle release. En Java, c'est l'état BLOCKED ; en Swift, le thread est suspendu par GCD.
La principale différence est la propriété. Un Mutex ne peut être libéré que par le thread propriétaire. Un Binary Semaphore peut être libéré par n'importe quel thread, ce qui est pratique pour la signalisation entre threads mais moins sûr pour protéger l'intégrité des données.
La valeur initiale dépend du scénario. Pour protéger une seule ressource — 1. Pour un pool de N connexions — N. Pour la signalisation entre threads, utilisez 0 afin que le thread consommateur attende un signal du producteur.
Résumé
Nous développerons une application mobile clé en main
IT Sectr crée des applications iOS et Android pour les startups et les entreprises depuis 2017. Nous vous conseillerons et vous proposerons la meilleure solution.
Lisez aussi