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 :
-
les variables sont locales, totalement inaccessibles de l'extérieur (privées),
-
certaines procédures ou fonctions sont appelées des points d'entrée du moniteur, ce sont les seuls éléments du moniteur à être exportés. On dit qu'un processus "entre" dans le moniteur quand il exécute les instructions du moniteur. Dès lors, il peut accéder aux variables privées. L'utilisation du moniteur garantit que ses variables sont utilisées en exclusion mutuelle. Celle-ci est obtenue en assurant que les accès au moniteur lui-même sont réalisés en exclusion mutuelle. En d'autres termes, quand un processus accède à un point d'entrée du moniteur, aucun autre processus ne peut plus y accéder jusqu'à ce que le premier en soit sorti. C'est le moniteur qui gère la liste des processus en attente,
-
un processus qui entre dans un moniteur peut être mis en attente d'un événement. Il sera réactivé lorsque cet événement surviendra. En pratique, le moniteur peut contenir des "variables" particulières appelées conditions à chacune desquelles n'est associée aucune valeur mais une file d'attente. Soit une telle variable c. Un processus entré dans un moniteur peut se mettre en attente dans la file correspondant à la condition c par l'appel de la primitive attendre(c). Dès lors il ne pourra se réactiver lui-même. Pour cela, un autre processus doit donc entrer lui aussi dans le moniteur (les variables du moniteur sont locales), et envoyer à la condition un signal par l'intermédiaire de la primitive signaler(c). Si plusieurs processus sont en attente dans la file d'attente, c'est en principe le premier qui sera réveillé (FIFO). Le moniteur étant accédé en exclusion mutuelle, si un processus était mis en attente à l'intérieur du moniteur, aucun autre ne pourrait plus y entrer pour le réveiller. Ainsi, la primitive attendre() doit mettre le processus "en dehors" du moniteur. Nous verrons ultérieurement la sémantique détaillée des moniteurs,
-
le moniteur peut aussi contenir une procédure qui ne sera exécutée qu'une seule fois au moment du chargement. C'est dans cette phase que peuvent être effectuées les initialisations nécessaires. Elle est appelée le corps du moniteur. Ecrit dans le style C++, ces initialisations apparaissent généralement, mais pas toujours, à la définition des variables,
-
certains auteurs ajoutent la primitive vide(c) qui permet de tester si la file d'attente associée à la variable condition c est vide.
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 :
Si l'une des fonctions de ce moniteur est Entree_1, elle sera appelée par :
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 :
-
à un instant donné, au maximum un seul processus peut être en cours d'exécution à l'intérieur d'un moniteur.
-
lorsque le processus actif exécute la primitive attendre(), il se met dans l'état attente, et "en même temps", il libère le moniteur de l'utilisation en exclusion mutuelle. De ce fait, un autre processus peut y pénétrer (sinon il y aurait à tout coup un interblocage !).
-
lorsque le processus actif exécute la primitive signaler(), c'est le processus qu'il débloque qui devient actif
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.
-
Un processus Pl actif réveille un processus P2 en attente sur une condition. Pl se met en attente et P2 devient actif. Puis P2 réveille lui-même un processus P3 qui devient à son tour actif pendant que P2 devient en attente. P3 retourne en file d'attente par l'exécution de la primitive attendre(). Lequel des processus Pl et P2 doit redevenir actif ?
-
Un processus Pl actif réveille un processus P2 en attente sur une condition. Pl se met en attente et P2 devient actif. Pendant ce temps, un processus P3 veut pénétrer dans le moniteur et se met en attente dans la file d'attente d'entrée du moniteur. Le processus P2 termine son exécution dans le moniteur et libère celui-ci de l'exclusion mutuelle. Lequel des processus Pl et P3 doit devenir actif ?
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é :
-
actif : c'est l'état du seul processus qui est en train de s'exécuter dans le moniteur.
-
activable : c'est l'état du seul processus qui peut s'exécuter. Il ne lui manque que le processeur pour devenir actif. S'il y a un processus actif il n'y en a pas d'activable et inversement.
-
stoppé : c'est l'état d'un processus qui attend la levée de l'exclusion mutuelle du moniteur pour devenir activable. Nous distinguerons deux états stoppé correspondant aux deux causes qui peuvent conduire un processus à ces états :
-
stoppé-entrée : le processus veut entrer à l'un des points d'entrée du moniteur qui est actuellement occupé par un autre.
-
stoppé-signaler : le processus stoppé vient lui-même d'en réveiller un autre par la primitive signaler().
-
c-attente : un processus passe dans cet état en exécutant la primitive attendre(c) sur la condition c.
-
sorti : c'est l'état d'un processus qui est en dehors du moniteur considéré, soit avant d'avoir appelé une fonction "point d'entrée" du moniteur, soit après en être sorti.
Les actions qui peuvent faire passer un processus d'un état à un autre sont les suivantes :
-
entrer : c'est l'action d'essayer d'entrer dans le moniteur par un de ses points d'entrée.
-
sortir : c'est l'action de terminer l'exécution des instructions dans le moniteur, et d'en lever l'exclusion mutuelle.
-
attendre(c) : voir la définition des moniteurs.
-
signaler(c) : " "
"
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 :
-
stoppé-entrée : ils sont naturellement gérés par une file FIFO.
-
stoppé-signaler : ils sont gérés grâce à une pile.
-
c-attendre : ils sont naturellement gérés par une file FIFO.
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"
Dernière mise à jour : 10/10/2001