Page préc.
Les sémaphores
Fin de page Page suiv.
Les pipes

Les moniteurs

Définition

    Inventés par Hoare en 1974, les moniteurs sont un moyen aussi puissant mais beaucoup plus sûr de réaliser l'exclusion mutuelle et la synchronisation. Un moniteur peut être assimilé à un objet de la programmation par objets, à un fichier compilé séparément en C++, à un paquetage d'ADA. Un moniteur est constitué de données et de fonctions, dotées de propriétés particulières :     On peut envisager un moniteur monolithique, jouant le rôle d'arbitre et de "chef d'orchestre", résolvant tous les conflits entre tous les processus. Cette solution manque beaucoup de souplesse, ayant de nombreux points d'entrée et de nombreuses variables locales à gérer. De plus, pour respecter la définition, le moniteur continue à être utilisé en exclusion mutuelle, alors que plusieurs processus s'adressent peut-être au moniteur pour accéder à des ressources très différentes. Il parait alors beaucoup plus souple, facile à concevoir et à maintenir d'associer un moniteur différent et tout à fait indépendant à chaque ressource. Ainsi, non seulement des moniteurs différents pourront être exécutés en parallèle mais la maintenance et la modification de l'un d'entre eux ne remettra pas en cause les autres. On peut donc associer un moniteur à la gestion de chaque périphérique (disque, imprimantes, terminaux, etc.).

    Nous désignerons un moniteur par son identificateur et ses points d'entrée par des appels de fonction qualifiés (c'est-à-dire utilisant un qualifieur).

    Exemple de déclaration du moniteur Synchro :
 
moniteur Synchro;

    Si l'une des fonctions de ce moniteur est Entree_1, elle sera appelée par :
 
Synchro.Entree_1;

Premier exemple de synchronisation par moniteur

    Soit à réaliser un point de synchronisation entre deux processus p et q, le premier devant avoir terminé une séquence d'instructions (par exemple avoir fini d'écrire) avant que le second ne soit autorisé à franchir ce point (par exemple pour lire).
 
<processus p> 
Ecrire ();
Synchro.FinEcrire (); 
Suite_p (); 
<processus q>

Synchro.Debut_Lire ();
Suite_q ();

    L'algorithme est écrit en "style" C++ [1] :
 
moniteur Synchro ;
static boolean EcritureFaite = false;
static condition Fini;      // la condition est supposée initialisée à vide

void DebutLire ()   { if (!EcritureFaite) attendre (Fini); )

void FinEcrire ()
{
    EcritureFaite = true ;
    signaler (Fini);

} // FinEcrire()

Point de rendez-vous

    Soit à réaliser la synchronisation de N processus : aucun ne peut franchir le point de synchronisation tant que tous les processus ne sont pas arrivés. Le mécanisme mis en œuvre ici est particulièrement intéressant : tous les processus (sauf le Nième) sont successivement mis en file d'attente. Lorsque le dernier entre dans le moniteur, il saute directement à la primitive signaler().
 
moniteur Synchro;
static int n = 0;
extern înt N;                 // N est supposé connu et initialisé
static condition RDV;

void Arriver ()
{
    if (++n < N) attendre (RDV) ;
    signaler (RDV);

} // Arriver()

    Le processus précédemment mis en file d'attente est donc réveillé, sort du schéma alternatif et passe lui aussi dans la primitive signaler qui réveille le précédent, et ainsi de suite. Tous les processus sont ainsi réveillés en cascade.

    Les moniteurs ne sont ni plus ni moins puissants que les sémaphores, mais seulement plus sûrs, et tous les problèmes résolus par l'utilisation des uns peut l'être par l'utilisation des autres.

Gestion de ressources banalisées

    Dans l'exemple ci-dessous, nous considérons un ensemble d'imprimantes qui peuvent être allouées indifféremment à des processus qui les demandent, jusqu'à ce que ces processus les rendent disponibles pour d'autres. La seule chose que l'utilisateur ait à faire est de demander une ressource ou de la rendre. En cas d'impossibilité de satisfaire cette requête, l'utilisateur ne peut s'en rendre compte car il est mis en file d'attente. Tout se passe donc pour lui comme si sa requête était toujours immédiatement satisfaite.
 
moniteur AllocImprimantes;
const int CstMaxImprim = .....;

static int NbImprimLibres = CstMaxImprim;
static boolean   ImprimOccupee [CstMaxImprim];
static condition FileImprim;

int DemanderImprim ()
{
    int Numero;
    îf ( ! NbImprimLibres) attendre (FileImprim);
    for (Numero = 0; ImprimOccupee [Numero]; Numero++);
    ImprimOccupee [Numero] = false;
    NbImprimLibres--;
    return Numero;
}

void RendreImprim (const int numero)
{
    NbImprimLibres++;
    ImprimOccupee [Numero] = false;
    signaler (FileImprim);
}

static void Init()   // doit être exécutée au lancement du moniteur
{
    for (int i = CstMaxImprim; i--; ) ImprimOccupee [i] = false;
}

Gestion de ressources banalisées

Gestion d'un périphérique par un moniteur

    Un moniteur rend plus aisée la gestion des périphériques, ne mettant à la disposition de l'utilisateur que les primitives "points d'entrée" du moniteur. L'accès en exclusion mutuelle de la ressource est assuré par le moniteur. C'est aussi à l'intérieur du moniteur que peuvent être gérés les différents tampons. Enfin, et toujours pour faciliter l'utilisation, le moniteur peut se charger de gérer l'attente de l'événement "fin de transmission" signalé par le périphérique, par exemple par une interruption. La figure ci-dessous illustre la mise en œuvre de deux moniteurs pour assurer l'interface entre la machine physique et les processus utilisateurs.

Gestion des accès disque avec moniteurs

    Le moniteur Disque est chargé de la réalisation des requêtes de transfert :
 
moniteur Disque;
static condition FileTransfert;

void Echange (Requete_t Requete)
{
    //
    if (EtatDisque != pret) TraiterErreur (ERR_DSK_NOT_READY);
    PreparerTransfert (Requete);  // Préparation du programme canal
    LancerTransfert();            // Commander au pilote le transfert
    attendre (FileTransfert)
    if (EtatDisque == erreur) TraiterErreur (ERR_TRANSFERT);
    // ...
}

void TraitantD_Interruption()
{
    if (FileTransfert.Vide) TraiterErreur (ERR_DSK);
    signaler (FileTransfert);
}

    Le moniteur FileRequetes fonctionne comme une boîte aux lettres, suivant le modèle "Producteurs/Consommateurs" vu plus loin.

Le problème des "Lecteurs/Rédacteurs"

    Le modèle général des Lecteurs/Rédacteurs a été présenté précédemment, ainsi qu'une implémentation par sémaphores. Nous ne traiterons ici que la priorité aux lecteurs :
 
moniteur LecteursRedacteurs;
static int NbLecteurs = 0;
static condition FileLecteurs, 
                 FileRedacteurs ;
static boolean  EnTrainD_Ecrire = false ;

void DebutLire()
{
    ++NbLecteurs;
    if (EnTrainD_Ecrire)
    {
        attendre (FileLecteurs);
        signaler (FileLecteurs);
    }
}

void FinLire()
{
    if (!--NbLecteurs) signaler (FileRedacteurs);
}

void DebutEcrire ()
{
    if (EnTrainD_Ecrire || (NbLecteurs > 0)) 
        attendre (FileRedacteurs);
    EnTrainD_Ecrire = true;
}
void FinEcrire ()
{
    EnTrainD_Ecrire = false;
    if (NbLecteurs)
        signaler (FileLecteurs);
    else
        signaler (FileRedacteurs);
}

Lecteurs/Rédacteurs avec moniteurs

    On remarque que NbLecteurs n'est pas le nombre de lecteurs actifs, mais ayant essayé d'accéder à la lecture. Si un rédacteur est actif, NbLecteurs est le nombre de lecteurs en attente de lecture. Le réveil met de nouveau en œuvre un mécanisme en cascade, tout lecteur débloqué commençant par réveiller un (éventuel) lecteur de la file d'attente des lecteurs avant d'entrer lui-même en activité. Le réveil n'a aucun effet si la file des processus en attente est vide.

Le problème des "Producteurs/Consommateurs"

    Le modèle général des Producteurs/Consommateurs a été présenté précédemment, ainsi qu'une implémentation par sémaphores. L'implémentation ci-dessous, en style C++ [1] utilise un tampon circulaire avec un nombre quelconque de producteurs et de consommateurs.
 
typedef .... Message_t;  // défini ailleurs

moniteur BufferCirculaire;

const int CstTailleMax 100;

inline int Succ (int i) { return (i + 1) % CstTailleMax; }

static Message_t Tampon [CstTailleMax];
static int NbMessages = 0;
static int Tete       = 0;
static int Queue      = 0;
static condition TamponPlein,
                 TamponVide;

void Deposer (const Message_t & Message)
{
    if (CstTailleMax == NbMessages) attendre (TamponPlein);
    Transferer (Tampon [Tete], Message); // fonction supposée exister
    ++NbMessages;
    Tete = Succ (Tete);
    signaler (TamponVide);
}

void Retirer (Message_t & Message)
{
    if (0 == NbMessages) attendre (TamponVide) ;
    Transferer (Message, Tampon [Queue]);
    --NbMessages ;
    Queue = Succ (Queue) ;
    signaler (TamponPlein) ;
}

    Le mécanisme d'un moniteur ne permettant qu'à un seul processus d'exécuter son code, l'exclusion mutuelle est réalisée tant pour le buffer lui-même que pour les indicateurs. Les deux variables conditions gèrent chacune une file d'attente, d'un message ou d'une place dans le tampon. Il peut y avoir un nombre quelconque de producteurs et de consommateurs, ayant chacun la structure suivante :
 
void Consommateur ()
{
    Message_t Message ;
    while (1)
    {
        //.....
        BufferCirculaire.Retirer (Message); // retire le message du buffer
        //  .....
    }
} // Consommateur()

void Producteur ()
{
    Message_t Message ;
    while (1)
    {
        // ......
         BufferCirculaire.Deposer (Message);
        // ......
    }
} // Producteur()

Producteurs-Consommateurs – tampon fini

    Comme on le voit, l'utilisateur est totalement déchargé de tous les soucis d'exclusion mutuelle et de cohérence des informations dans le tampon. Il peut considérer l'action de dépôt ou de retrait comme synchrone, même s'il reste bloqué un certain temps dans le moniteur.

Sémantique des moniteurs

    Nous avons vu précédemment l'essentiel du fonctionnement d'un moniteur.  Rappelons-en les trois éléments :     En fait, ces trois règles sont tout à fait insuffisantes pour décrire complètement le comportement d'un moniteur. Voici deux exemples d'ambiguïté que des règles précises doivent fixer.     A titre d'exemple, nous présentons la façon dont l'implémentation des moniteurs en langage Portal règle tous les problèmes de priorité.
Tout d'abord il faut préciser les différents états possibles d'un processus vis-à-vis d'un moniteur donné :     Les actions qui peuvent faire passer un processus d'un état à un autre sont les suivantes :     Nous utiliserons les notations de Schipper pour exprimer tous les états et toutes les transitions envisagées :
signifie qu'un processus Pi passe de l'état ei  à l'état e'i en exécutant l'action a, et
signifie que les processus Pi et Pj passent respectivement des états ei à e'i et ej à e'j lorsque le processus Pi exécute l'action a.

    Le tableau suivant résume les différentes situations (lorsqu'un événement peut provoquer deux transitions différentes, c'est la première qui est prioritaire) :

    Les règles A1 et A2 expriment l'exclusion mutuelle du moniteur : un processus ne peut entrer dans un moniteur que si un autre n'est pas en train de l'occuper. En d'autres termes, un processus déjà entré dans un moniteur a la priorité sur un processus encore à l'extérieur.

    Les règles B1, B2 et B3 traduisent le fait que, lorsqu'un processus P1 exécute la primitive attendre(), pendant qu'un processus P2 attend d'entrer dans le moniteur et qu'un moniteur P3 attend de redémarrer dans le moniteur, c'est ce dernier qui a la priorité. De nouveau, un processus déjà entré dans un moniteur a la priorité sur un processus encore à l'extérieur.

    Les règles C1 et C2 illustrent le réveil d'un processus en attente de signal. Si aucun n'est en attente, le signal est perdu.

    Les règles D1, D2 et D3 montrent que la levée de l'exclusion mutuelle par un processus qui sort d'un moniteur profite en priorité à un processus stoppé parce qu'il a lui-même réveillé un processus plutôt qu'à un processus qui a appelé un point d'entrée du moniteur. De nouveau, un processus déjà entré dans un moniteur a la priorité sur un processus encore à l'extérieur.

    Pour compléter la description du fonctionnement d'un moniteur, il faut indiquer l'ordre dans lequel sont choisis les processus lorsque plusieurs sont bloqués pour la même raison. Il y trois sortes d'ensembles de processus bloqués correspondant aux états suivants :

    Pour illustrer ces différentes règles, considérons le problème des Lecteurs/Rédacteurs avec priorité aux lecteurs que nous avons présenté ci-dessus et supposons que se présentent successivement deux rédacteurs R1 et R2 et un lecteur L1. Voici le chronogramme que nous pouvons obtenir :



[1] Les moniteurs ne sont pas implémentés en C++; et l'écriture "static ...", spécfique du C, n'est utilisée ici que pour indiquer que les variables correspondantes sont "globales"


Page préc.
Les sémaphores
Début de page Page suiv.
Les pipes
Dernière mise à jour : 10/10/2001