Les Sémaphores
  1. Description

    La plus importante contribution au problème de la synchronisation entre processus fut l'introduction par Dijkstra, en 1965, du concept des Sémaphores.

    Conceptuellement, un sémaphore joue le rôle d'un distributeur de tickets, qui, initialement dispose d'un certain nombre de tickets, éventuellement nul.

    Un processus utilisateur du sémaphore demande un ticket en invoquant une opération particulière appelée P : si au moins un ticket est disponible, le processus appelant le prend et poursuit son exécution ; dans le cas contraire, le demandeur est enregistré dans une file d'attente et est bloqué dans l'attente de l'obtention d'un ticket. Autrement dit, l'opération P n'est exécutable par le processus appelant que s'il existe un ticket libre, elle peut se retrouver bloquante dans le cas contraire.

    Grâce à une opération inverse, appelée V, un processus dépose son ticket dans le distributeur. Si des processus sont en attente dans la file du sémaphore, le premier d'entre eux est débloqué et obtient son ticket.

    Plus précisément, un sémaphore est une variable entière non négative à laquelle, sauf pour l'initialisation, on accède seulement à travers deux opérations standards atomiques (non interruptibles) : P et V.

    Algorithme de P :

    TQ sémaphore < = 0 Faire

    rien (nop ...)

    FTQ

    Décrémenter sémaphore

    Algorithme de V :

    Incrémenter sémaphore

    Nous verrons plus tard, dans la partie Détails du fonctionnement à la page 15, comment fonctionne plus précisément les sémaphores et comment sont réalisées les opérations P et V.

    Ce mécanisme permet donc de limiter l'exécution d'une portion de code (et donc l'accès à une ressource quelconque), encadrée par des appels respectifs à P et à V, à un nombre fixé de processus. Nous allons voir par la suite, que cela permet de réaliser l'exclusion mutuelle nécessaire à la prévention des conflits d'accès aux données non partageables.

  2. Quelques variantes

    Pour adapter le concept des sémaphores à certains problèmes spécifiques et, en particulier, pour répondre aux exigences du temps réel, plusieurs variantes ont été proposées :

    Un sémaphore est privé à un processus dit propriétaire, si seul ce processus peut effectuer une opération P sur ce sémaphore. Il ne peut donc y avoir qu'au plus un processus en attente derrière un sémaphore privé, le processus propriétaire, et il n'est pas nécessaire alors d'avoir une file d'attente associée à un sémaphore privé. Tout processus, y compris le propriétaire, peut, par contre, exécuter une opération V sur le sémaphore privé.

    voir Utilisation d'un sémaphore privé pour bloquer un processus, à la page 24

    Un sémaphore binaire est une variante du mécanisme de sémaphore général. Le compteur associé au sémaphore est simplement remplacé par un booléen, qui ne peut donc prendre que les valeurs Vrai ou Faux.

    L'opération P immédiate permet à un processus de demander des tickets, en lui évitant d'être bloqué si la demande ne peut pas être honorée : il sera seulement averti de l'échec de sa demande.

    prototype : bool pImmediate(sem s) {...}

    Grâce à l'opération P temporisée, un processus précise le temps maximum pendant lequel il accepte d'attendre que sa requête soit satisfaite : ce délai écoulé, le processus est débloqué et prévenu de l'échec.

    prototype : bool ptemporise(sem s, int delai) {...}

    L'opération P avec priorité range les demandes dans la file d'attente en fonction d'une priorité, ce qui permet à chaque fois de servir en premier le processus réalisant le traitement le plus urgent.

    prototype : void pPriorite(sem s, int priorite) { }

  3. L'exclusion mutuelle grâce aux Sémaphores

    Le problème de l'exclusion mutuelle peut être simplement résolu à l'aide d'un sémaphore dont la valeur initiale est fixée à 1. Un tel sémaphore est couramment appelé sémaphore d'exclusion mutuelle (mutex).

    Ainsi pour garantir le respect de la section critique, il suffit de l'encadrer entre deux appels à respectivement P et V sur ce sémaphore.

    Figure 2 : Utilisation des sémaphores pour l'exclusion mutuelle

    Le premier processus désirant entrer en section critique (on suppose que celle-ci est libre), effectue un appel à P, et prend donc le seul ticket disponible. La valeur du sémaphore passe à 0.

    Dès lors, tout autre processus désirant entrer en section critique, se voit mis en attente sur le sémaphore (lors de son appel à P), car la valeur de ce dernier est nul.

    Enfin, lorsque l'exécution de la section critique est terminée, le processus l'ayant occupé effectue son appel à V afin de rendre son ticket : la valeur du sémaphore redevient positive, en l'occurrence égale à 1, et le (ou les) processus éventuellement bloqués peuvent accéder, à tour de rôle et selon la méthode précédemment décrite, à la section critique.

    La section critique étant protégée par un sémaphore ne disposant que d'un ticket : un seul processus peut exécuter l'opération P avant celle-ci ne devienne bloquante, et seul l'opération V exécutée après la fin de la section critique autorise à nouveau l'exécution de P. Donc l'exclusion mutuelle est donc bien assuré.

    Précision : pour un fonctionnement correct, il doit être garanti, comme dans la définitions sémantique, que les opérations P et V soient indivisibles, c'est à dire non interruptibles au profit d'un autre processus.

    Nous verrons par la suite, dans la partie Utilisation d'un sémaphore privé pour bloquer un processus, à la page 24, que même si l'utilisation des sémaphores afin de restreindre l'accès à une ressource est le plus courant, il n'est pas le seul usage que l'on peut en faire.

  4. Restriction d'accès pour 'N' processus

    De même que pour l'exclusion mutuelle, où l'on limitait l'exécution d'une portion de code à un seul et unique processus, en assignant la valeur 1 au sémaphore, on peut restreindre le fonctionnement d'une séquence d'instruction à un nombre fixé 'N' de processus en affectant cette valeur au sémaphore.

    Cette possibilité peut être très utile pour limiter le nombre maximum de processus utilisant simultanément une ressource système, et ainsi empêcher un phénomène de saturation ou d'interblocage (dead-lock).

  5. Détails du fonctionnement
    1. Fonctionnement logique

      Nous avons vu dans la partie 2.1, à la page 12, la spécification du mécanisme des sémaphore. Nous examinerons ici, les solutions possibles pour implanter ce mécanisme dans un contexte centralisé.

      Un sémaphore peut donc être représenté par un compteur, et une file d'attente de processus. La file d'attente enregistre les processus demandeurs bloqués, et le compteur représente :

      • quand il est supérieur à 0, le nombre de tickets disponibles et donc le nombre de processus dont les demandes seront immédiatement satisfaisantes,
      • et quand il devient négatif, sa valeur absolue est alors le nombre de processus bloqués en attente d'un ticket.

      Structure de données d'un sémaphore :

      typedef struct

      {

      int valeur;

      processus *ptrTeteListe;

      } semaphore;

      typedef struct

      {

      int pid;

      processus *ptrSuivante;

      } processus;

      NB : on remarquera que le compteur interne associé à la représentation du sémaphore peut devenir négatif. Ceci est en contradiction apparente avec la spécification de la sémantique d'un sémaphore donnée précédemment. En réalité, pour l'usager du sémaphore, tout se passe comme si le compteur (logique) de tickets restait bien toujours positif ou nul. Il ne s'agit ici que d'une technique particulière qui ne contredit en rien la sémantique externe du sémaphore, et qui permet de connaître, en prenant la valeur absolue du compteur négatif, le nombre de processus bloqué dans la file d'attente.

      Le principal désavantage de la définition simpliste de P, est que tout processus bloqué, essayant d'entrer en section critique, doit itérer continuellement, on appelle cela l'attente active.

      Rappel : algorithme simpliste de P :

      Début

      TQ sémaphore < = 0 Faire

      rien (nop ...)

      FTQ

      Décrémenter sémaphore

      Fin

      Cette attente gaspille des cycles du processeur, que d'autres processus pourraient utiliser de façon productive. Pour surmonter cet inconvénient, le processus peut se bloquer lui-même, en s'endormant, et en devenant donc inactif.

      Implémentation de P :

      Début

      semaphore.valeur = semaphore.valeur - 1

      Si semaphore.valeur < 0 Alors

      ajouter le processus dans la liste des processus bloqués

      mettre en sommeil le processus

      FSi

      Fin

      En se mettant en sommeil, le processus devient inactif au niveau du système, et ne consomme donc plus de temps du processeur.

      Le processus redevient actif, lorsque qu'un autre processus exécute V en rendant son ticket.

      Implémentation de V :

      Début

      semaphore.valeur = semaphore.valeur + 1

      Si semaphore.valeur < = 0 Alors

      retirer un processus de la liste des processus bloqués

      réveiller ce processus

      FSi

      Fin

      Sauf pour le cas des sémaphores disposant de listes d'attente avec ordre de priorité (voir Quelques variantes à la page 13), en principe, le réveil des processus se fait suivant la logique des piles FIFO : le plus vieux processus endormi est réveillé.

      1. Les sémaphores sous Unix
      2. présentation

        La gestion des sémaphores sous Unix, est prise en compte par des primitives systèmes (system calls) étant donné leur principe de fonctionnement très bas niveau et la nécessité d'avoir les opérations P et V qui ne soient pas interruptibles. Les sémaphores sont donc gérés par le noyau, ils appartiennent à la classe des objets IPC.

        La technique IPC Unix système V, propose un ensemble de mécanismes purement mémoires, qui permettent dans des limites de droits, à n'importe quels couples de processus d'échanger des informations et/ou de se synchroniser. Les objets IPC sont composés en :

        • des files de messages, aussi appelées queues de messages ("messages", abrégé msg),

        Les processus ont le loisir de créer une file d'attente, de la supprimer, d'y ajouter ou d'en retirer un message. Ces files gérées directement dans l'espace noyau recevront des messages de différents types et tailles.

        • des mémoires partagées ("shared memory", abrégé shm),

        Les mémoires partagées sont des segments de mémoire qui peuvent, comme leur nom l'indique être partagé par plusieurs processus, avec les problèmes d'exclusion mutuelle que peut impliquer.

        • et des sémaphores ("semaphore", abrégé sem),

        Sémaphores que l'on ne présente plus...

        Le point commun des objets IPC, et qui nous intéresse plus particulièrement en ce qui concerne les sémaphores, est qu'ils forment des ressources maintenues par le noyau dans son espace personnel ; ils sont donc indépendants d'un processus particulier. Chaque processus ne fait que requérir auprès du noyau l'obtention et l'utilisation de ceux-ci.

        A cette double fin (centralisation et partage), Unix maintient pour type d'objet IPC, une table personnelle identifiant chacune des instances crées. Ceci explique pourquoi, les files de messages, les mémoires partagées et les sémaphores sont tous identifiés sous la forme d'un entier (type int) souvent appelé xxxid (ou xxx est soit msg pour les files, shm pour les mémoires ou sem pour les sémaphores).

        L'IPC sémaphore d'Unix possède deux généralisations qui en enrichissent encore la puissance.

        • Incrémentation et décrémentation d'un sémaphore par un entier quelconque.
        • Possibilité d'agir sur plusieurs sémaphores de manière atomique (non-interruptible), un IPC de type sémaphore gèrant par conséquence non pas un seul sémaphore, mais un tableau de sémaphore, chacun étant identifié par son rang dans le tableau (0 pour le premier).
      3. création ou acquisition

        Pour créer, ou acquérir un sémaphore, on dispose de la primitive suivante :

        int semget(key_t cle, int nsems, int semflag);

        Cette fonction crée le sémaphore si celui-ci n'existe pas, ou renvoie un identificateur sur ce dernier s'il a déjà été créé par un autre processus.

        Description des paramètres :

        • cle, constante symbolique pour identifier le sémaphore à sa création
        • nsems, est le nombre de sémaphore(s) souhaité(s) dans le tableau
        • semflag, représente les droits souhaités, généralement : IPC_CREAT | 0700

        En retour, la fonction renvoie soit -1 si une erreur c'est produite, ou alors l'identificateur (semid) du sémaphore.

        Ex. pour créer un sémaphore simple :

        semid = semget(cle, 1, IPC_CREAT | 0700);
      4. initialisation

        Pour affecter une valeur initiale à un sémaphore, on utilise la primitive semctl(), qui permet aussi entre autre, comme nous le verrons plus tard, de libérer un sémaphore.

        int semctl(int semid, int semnum, SETVAL, int valeur);

        Description des paramètres :

        • semid, identificateur du sémaphore au niveau du système (voir précédemment)
        • semnum, indice du sémaphore dans le sous-ensemble
        • SETVAL, pour indiquer au noyau que l'on désire affecter une valeur au sémaphore
        • valeur, valeur initiale

        En retour, la fonction renvoie soit -1 si une erreur s'est produite.

        Ex. pour initialiser un sémaphore simple à 1 (identifié par semid) :

        valRet = semctl(semid, 0, SETVAL, 1);
      5. utilisation

        En ce qui concerne les opérations P et V, elles peuvent être réalisées par des appels directs à la primitive système semop(), de la manière suivante :

        void p(int semid)

        {

        int rep;

        struct sembuf pd={0,-1,0};

        rep=semop(semid, &pd, 1);

        return(rep);

        }

        void v(int semid)

        {

        int rep;

        struct sembuf pd={0,1,0};

        rep=semop(semid, &pd, 1);

        return(rep);

        }

        NB : il est très important pour le bon fonctionnement de ces deux opérations, que leur code ne comportent uniquement que des appels à semop() et rien de plus, car seul le déroulement de cette primitive est garanti comme n'étant pas interruptible par le noyau ! Cette précaution est nécessaire pour préserver le caractère atomique de ces deux opérations.

        La primitive semop() permet d'augmenter ou de diminuer la valeur d'un sémaphore, et comme nous l'avons vu précédemment, elle peut le faire avec un nombre d'une valeur quelconque et sur un sémaphore qui peut éventuellement être décomposé en un sous-ensemble de sémaphore.

        int semop(int semid, struct sembuf *sops, size_t nsops);

        Description des paramètres :

        • semid, identificateur du sémaphore au niveau du système
        • sops, est une structure composée de 3 nombres :
          • sem_num, l'indice de départ du (ou des) sémaphore(s) à traiter,
          • sem_op, la valeur à appliquer au sémaphore,
          • et sem_flg, un drapeau (flag) qui sera renseigné par le système.
        • nsops, nombre de sémaphore(s) a traiter (à partir de l'indice de départ de sops)

        Dans le cas présent, un seul sémaphore (non décomposable) nous suffit, donc la plupart de ces paramètres sont à zéro.

        L'action réelle de semop()dépend de la valeur du paramètre sem_op :

        • si celle-ci est strictement supérieur à 0, alors elle est ajoutée à celle du sémaphore. Les processus en attente de l'augmentation du sémaphore sont réveillées par Unix.
        • si par contre elle est strictement inférieur à 0, alors la valeur absolue de sem_op est retirée du sémaphore. Dans le cas ou cette absolue est plus grande que celle du sémaphore, alors le processus s'endort.
        • enfin, dans le cas ou la valeur transmise est égal à 0, cela permet de tester si le sémaphore est à 0 et donc de déterminer éventuellement si l'opération P sera bloquante.

        Ainsi, dans les définitions précédentes de P et de V, on peut remarquer que le paramètre sem_op prend respectivement la valeur -1 (pour décrémenter le sémaphore) et la valeur 1 (pour l'incrémenter).

        Remarque:

        • au niveau du noyau, les processus endormis sous placé dans une file d'attente similaire à celle qui regroupe les processus en attente d'une entrée / sortie, ils ne consomment donc plus de temps CPU.
        • les processus sommeillent dans un mode interruptible, ils sont donc sensible aux signaux !
        • on peut implémenter différentes options, telle que l'opération P immédiate (voir la partie Quelques variantes à la page 13), grâce à certains paramètres tels que le flag IPC_NOWAIT qui permet de ne pas rendre semop() bloquant et d'être averti de l'échec de ce dernier.
      6. libération

        La libération d'un sémaphore est faite par un appel à semctl(), a qui l'on transmet l'identificateur du sémaphore, ainsi que la commande IPC_RMID.

        int semctl(int semid, IPC_RMID, NULL);

        Description des paramètres :

        • semid, identificateur du sémaphore au niveau du système
        • IPC_RMID, pour indiquer au noyau que l'on souhaite supprimer le sémaphore

        En retour, la fonction renvoie soit -1 si une erreur s'est produite.

        Ex. pour libérer un sémaphore (identifié par semid) :

        valRet = semctl(semid, IPC_RMID, NULL);
      7. Les risques liés à l'emploi des sémaphores

        Bien que les sémaphores fournissent des mécanismes pratiques et efficaces (en temps machine) pour la synchronisation de processus, leur utilisation incorrecte peut encore provoquer de nombreuses erreurs de synchronisation difficiles à détecter, puisque ces erreurs surviennent seulement si certaines séquences d'exécution particulières ont lieu. Une difficulté supplémentaire et pernicieuse étant le fait que ces séquences erronées ne se produisent pas toujours.

        Afin d'illustrer comment, étudions à nouveau la solution au problème de la section critique avec des sémaphores. Tous les processus partagent une variable sémaphore : mutex, initialisée à 1. Chaque processus doit exécuter un P(mutex) avant de rentrer dans la section critique, et un V(mutex) après. Si l'on ne respecte pas cette séquence, les deux processus peuvent se trouver dans leur sections critiques simultanément, ou en arriver à un situation de blocage, ou d'interblocage.

      8. Inversion des opérations P et V

        Supposons qu'un processus permute l'ordre d'exécution des opérateurs P et V sur le sémaphore d'exclusion mutuelle, ce qui provoque l'exécution suivante :

        V(mutex)

        ...

        section critique

        ...

        P(mutex)

        Dans cette situation, plusieurs processus peuvent s'exécuter simultanément dans leur sections critiques, violant ainsi le besoin de l'exclusion mutuelle. On peut aussi découvrir cette erreur si plusieurs processus sont actifs en même temps dans leur sections critiques, mais cette situation peut ne pas toujours se reproduire !

      9. Substitution d'un appel V par un appel à P

        Supposons maintenant, qu'un processus remplace un appel à V (remise du ticket) par un appel à P (prise du ticket), c'est à dire qu'il exécute deux fois P :

        P(mutex)

        ...

        section critique

        ...

        P(mutex)

        Dans ce cas, il se produira irrémédiablement un blocage sur le second P.

      10. Encore pire ...

        Supposons enfin, qu'un processus omette l'appel à V ou à P, ou les deux. Dans ce cas, soit on viole l'exclusion mutuelle, soit il se produira un blocage du processus par lui-même, comme nous venons de le voir précédemment, soit les processus se bloque les uns les autres (interblocage).

      11. Conclusion

        Ces exemples montrent que plusieurs types d'erreurs peuvent se générer facilement quand les sémaphores sont utilisés incorrectement pour résoudre, entre autre, le problème de la section critique.

        Ces cas peuvent sembler évident et facile à déceler lorsqu'ils sont isolés comme ici, mais dans l'hypothèse d'un projet volumineux, employant éventuellement plusieurs sémaphores simultanément, et se déroulant de manière assez difficile à prévoir, ils peuvent facilement se glisser dans des séquences d'instructions et être très complexe à déceler.

        De plus, si un programmeur ne s'aperçoit pas qu'une structure de données particulière doit être partagée entre différents processus, il omettra d'encadrer les sections critiques par des opérateurs P et V, et cette structure sera exposée aux risques de corruption exposés dans la section 1.2.

        Une des solutions introduites, pour remédier à ces inconvénients, fut l'apparition d'une autre structure de synchronisation de plus haut niveau : les Moniteurs, que nous examinerons par la suite, dans la section 4 à la page 26.


© RENAUDON Xavier - Juin 1996
Retour au Sommaire retour au Sommaire