Chapitre 6 : Synchronisation des processus et des fils Introduction La section critique Matériel spécifique Sémaphores Problèmes classiques de synchronisation Moniteurs
Introduction L’accès concurrent aux données peut conduire a des résultats irrationnels Il faut prévoir des mécanismes pour l’exécution ordonnée des processus qui coopèrent Les “courses” aux ressources communes. Macro-exemple : l'accès concomitant aux mêmes données dans plusieurs programmes.
Gestion du solde d'un client Client C Enregistrement de règlements Prise de commande (1) Mise à jour compte (3) S = S - R Validation du client (2) S Refus (2) OK Enregistrer la commande Mise à jour compte (4) S = S + C
Les courses L’utilisation de la mémoire partagée pour résoudre le problème du producteur / consommateur (ch.4) contient une possibilité de course sur la donnée count.
Les appels producteurs while (true) Les courses Les appels producteurs while (true) { while (count == BUFFER_SIZE) sommeil() ; // ne rien faire, le tampon est plein // le tampon devient disponible : on produit produit_suivant buffer[in] = produit_suivant; in = (in + 1) % BUFFER_SIZE; count++; }
Les appels consommateur Les courses (suite) Les appels consommateur while (true) { while (count == 0) sommeil() ; // ne rien faire, le tampon est vide // le tampon contient quelque chose… consommé_suivant = buffer[out]; out = (out + 1) % BUFFER_SIZE; count--; // consomme l’élément consommé_suivant }
Les courses (suite) On peut programmer count++ ainsi registre1 = count registre1 = registre1 + 1 count = registre1 On peut programmer count-- ainsi registre2 = count registre2 = registre2 - 1 count = registre2 Soit maintenant la séquence suivante d’exécution concomitante des deux fils ,(count étant une variable en mémoire et R1, R2 deux registres différents) : S0: le producteur exécute registre1 <= count //registre1 = 5 S1: le producteur exécute registre1 <= register1 + 1 //registre1 = 6 S2: le consommateur exécute registre2 <= count //registre2 = 5 S3: le consommateur exécute registre2 <= registre2 - 1 //registre2 = 4 S4: le producteur exécute count <= registre1 //count = 6 S5: le consommateur exécute count <= registre2 //count = 4
La section critique Exclusion mutuelle dans le temps Une section critique du processus (ou fil) P est constituée par une séquence d’instructions qui modifient l'état de ressources communes (avec d'autres processus ou fils). Pour éliminer les courses, aucun processus concurrent ne doit exécuter une section critique pendant que P exécute les instructions d'une section critique (un processus concurrent est un processus qui fait appel a une ressource utilisée par P dans la section critique). Les critères qui régissent le fonctionnement correct de processus (ou fils) qui ont des sections critiques sont au nombre de 3 : Exclusion mutuelle dans le temps Le choix du processus qui exécutera la section critique parmi plusieurs demandeurs doit se faire seulement entre les processus concurrents (les autres processus ne participent pas au choix) Continuité : tous les processus doivent exécuter les sections critiques (pour éviter que certains restent en état d'attente indéfinie)
Solution pour le cas de 2 tâches Les deux tâches s’appellent T0 et T1 Les instructions load, store et test sont “atomiques” On présente 3 solutions qui utilisent la même interface ExclusionMutuelle public interface ExclusionMutuelle { public static final int TOUR_0 = 0; public static final int TOUR_1 = 1; public abstract void solliciteSectionCritique(int tour); public abstract void quitteSectionCritique(int tour); } (interface désigne en Java des objets abstraits avec méthodes et champs qui doivent être matérialisés en classes à l’aide du mot clé « implement » )
Le programme TestAlgorithmes Ce programme crée 2 fils utilisés pour tester les algorithmes : public class TestAlgorithmes { public static void main(String args[]) ExclusionMutuelle alg = new Algorithm_1(); Thread first = new Thread( new usine("usine 0", 0, alg)); Thread second = new Thread(new usine("usine 1", 1, alg)); first.start(); second.start(); }
Le fil usine public class usine implements Runnable { private String nom; private int id; // un nombre entier private ExclusionMutuelle mutex; public usine(String nom, int id, ExclusionMutuelle mutex) this.nom = nom; this.id = id; this.mutex = mutex; } public void run() while (true) { mutex.solliciteSectionCritique(id); Exemple.SectionCritique(nom); mutex.quitteSectionCritique(id); Exemple.SectionNormale(nom);
Algorithme 1 public class Algorithm_1 implements ExclusionMutuelle { private volatile int tour; public Algorithm 1() { tour = TOUR_0; } public void solliciteSectionCritique(int t) while (tour != t) // ce n’est pas mon tour…. Thread.yield(); // laisse la place à un autre fil public void quitteSectionCritique(int t) tour = 1 - t; // si c’etait le tour de 1 le suivant sera 0, et inversement } }
Algorithme_1 - explications Les fils se partagent la variable entière tour. Cependant, le partage de cette variable n’est pas sans poser problème a cause des optimisations automatiques qu’un compilateur peut effectuer, comme par exemple lorsqu’il utilise le cache pour stocker une variable du programme qui ne change pas pendant plusieurs cycles d’UC (tour dans la boucle). C’est la raison de l’utilisation du qualificatif volatile Si tour == i, le fil i peut continuer (Exemple.SectionCritique...) Sinon, le fil s’arrête et annonce (Thread.yield()) qu'il peut temporairement céder l'accès à l'UC pour permettre à l’autre fil d'en prendre le contrôle. Problème de continuité : L’alternance des fils est stricte et obligatoire - seulement le fil pour lequel tour == i pourra entrer dans la section critique, même si l’autre fil est dans une section non-critique. Ce qui peut conduire un fil à une attente infinie...
Algorithme_2 On ajoute de l’information pour indiquer l’intention du fil d’entrer dans une section critique, à l’aide de 2 variables booléennes (fanions), initialisées à FAUX Le fil annonce son intention en changeant la valeur du fanion qui lui est associé en VRAI ; avant de pouvoir entrer dans sa section critique, l’autre fil doit avoir positionné le fanion à FAUX (annoncer l’intention de quitter la section critique). Le critère de la continuité n’est toujours pas satisfait : si un changement de contexte intervient après le positionnement du fanion par un fil, mais avant que ce fil entre dans la boucle While, et que le deuxième fil (qui a provoqué le changement de contexte) positionne son fanion à VRAI, chacun des 2 fils attendra l’autre…
Algorithme_2 public class Algorithm_2 implements ExclusionMutuelle { private volatile boolean flag0, flag1; public Algorithm_2() { flag0 = false; flag1 = false; } public void solliciteSectionCritique(int t) { if (t == 0) { flag0 = true; while(flag1 == true) Thread.yield(); else { flag1 = true; while (flag0 == true) } } public void quitteSectionCritique(int t) { flag0 = false; flag1 = false;
Algorithme_3 Combine les idées de 1 et 2 Satisfait-il aux critères de la section critique ?
Algorithme_3 public class Algorithm_3 implements ExclusionMutuelle { private volatile boolean flag0; private volatile boolean flag1; private volatile int turn; public Algorithm_3() { flag0 = false; flag1 = false; turn = TURN_0; } // Suite…
Algorithme_3 (suite) int filno = 1 - t; turn = filno; if (t == 0) { public void solliciteSectionCritique(int t) { int filno = 1 - t; turn = filno; if (t == 0) { flag0 = true; while(flag1 == true && turn == filno) Thread.yield(); } else { flag1 = true; while (flag0 == true && turn == filno) // Suite
Algorithme_3 (suite et fin) public void quitteSectionCritique(int t) { if (t == 0) flag0 = false; else flag1 = false; } } // fin
Spécifications matérielles destinées à la synchronisation des processus Mono-processeurs – désactivation des interruptions Le code en cours (section critique) peut s’exécuter sans être interrompu Sur les systèmes multi-processeurs la solution est inefficace, car la désactivation et la réactivation des interruptions doit être appliquée à tous les processeurs, ce qui peut être très coûteux en temps de traitement. Dans ces systèmes il est en plus très difficile de prendre en compte des chagements de configuration (ajout de processeurs) Utilisation des instructions “atomiques” (indivisibles et non-interruptibles) Instructions TAS (Test-And-Set) Instructions CAS (Compare-And-Swap) Ces instructions sont maintenant répandues dans les architectures multiprocesseurs car elle sont indipensables pour la réalisation de SE fiables. Les instructions atomiques du niveau matériel ne sont généralement pas à la portée des programmeurs d’applications.
Simulation de l’équipement (mémoire et instructions atomiques) public class HardwareData { private boolean data; public HardwareData(boolean data) { this.data = data; } // pour accéder a DATA public boolean get() { return data; public void set(boolean data) { //Suite…
Simulation de l’équipement (mémoire et instructions atomiques) suite public boolean getAndSet(boolean data) { boolean oldValue = this.get(); this.set(data); return oldValue; } public void swap(HardwareData other) { boolean temp = this.get(); this.set(other.get()); other.set(temp); } // fin de la définition de la classe
Fil utilisant une commande “get-and-set” // le verrou est partagé par tous les fils HardwareData lock = new HardwareData(false); while (true) { while (lock.getAndSet(true)) Thread.yield(); // attend la libération du verrou sectioncritique(); lock.set(false); sectionNonCritique(); }
Fil utilisant une instruction “swap” // le verrou est partagé par tous les fils HardwareData lock = new HardwareData(false); // chaque fil possède une copie locale de la clé (key) HardwareData key = new HardwareData(true); while (true) { // boucle infinie pour tester key.set(true); do { lock.swap(key); } while (key.get() == true); sectionCritique(); lock.set(false); sectionNonCritique();
Les Sémaphores C’est une technique courante pour la synchronisation des processus au niveau des programmes utilisateurs Désavantage : le processus qui souhaite acquérir le sémaphore doit attendre si ce dernier est déjà pris par un autre processus : spinlock Un sémaphore c’est une variable de type entier dont l’utilisation se réduit à 3 commandes atomiques : Initialisation Acquisition (obtenir) obtenir(S) { while S <= 0 ; // on ne fait rien, un autre processus a acquis le //sémaphore S--; // on déduit 1 (sémaphore pris) } Libération (liberer) liberer(S) { S++; // on ajoute 1 (sémaphore libéré)
Les Sémaphores comme outils de synchronisation Sémaphore compteur – une variable qui peut prendre n’importe quelle valeur entière Sémaphore binaire – une variable qui peut prendre seulement les valeurs 0 et 1 Pour réaliser l’exclusion nécessaire en cas de sections critiques, Semaphore S; // initialisation à 1 obtenir(S); sectioncritique(); liberer(S);
Les semaphores (suite) Le désavantage principal des algorithmes de synchronisation présentés antérieurement consiste dans le fait que lorsqu’un processus est dans la section critique, tout autre processus qui veut exécuter la section critique doit attendre en bouclant, ce qui constitue une utilisation improductive de l’UC. D’autres processus pourraient l’utiliser. Les verrous qui agissent ainsi portent le nom de verrous tournants (spinlocks). Ils sont utiles lorsque les attentes prévues sont courtes ou dans le cas des multiprocesseurs car un fil peut s’exécuter sur un processeur différent.
Les semaphores (suite) On peut éviter les verrous tournants en utilisant une queue d’attente et le blocage du fil le temps de libérer le processeur occupé par la section critique d’un autre processus. Le fil bloqué passe en état d’attente et l’ordonnanceur donne le contrôle à un autre processus. Le processus bloquant exécute une opération de libération du sémaphore lorsque la section critique s’est terminée et le processus bloqué est réveillé pour le passer dans la queue des processus prêts. Pour fonctionner ainsi, le sémaphore est construit avec une variable entière et une liste de processus.
Synchronisation à l’aide de sémaphore - usine public class usine implements Runnable{ private Semaphore sem; private String nom; public usine(Semaphore sem, String nom) { this.sem = sem; this.nom = nom; } public void run() { while (true) { sem.obtenir(); // en attente du sémaphore MutlExUtil.sectioncritique(nom); sem.liberer(); MutlExUtil.sectionNonCritique(nom);
Synchronisation à l’aide de sémaphore – le programme principal public class SimulateurSemaphore { public static void main(String args[]) { Semaphore sem = new Semaphore(1); Thread[ ] fils = new Thread[5]; for (int i = 0; i < 5; i++) fils[i] = new Thread(new usine(sem, "usine " + (new Integer(i)).toString() )); fils[i].start(); }
Implémentation du sémaphore obtenir(S){ value--; if (value < 0) { ajouter ce processus à la liste d’attente bloquer ; // au lieu de boucler, ce qui permet à l’Ordonnanceur d’en choisir un autre pour exécution. } liberer(S){ value++; if (value <= 0) { enlever un processus de la liste d’attente réveiller(P);
Implémentation du sémaphore Les opérations de blocage, mise en queue d’attente, réveil et remise dans la queue des processus prêts sont implémentées comme des primitives du SE. Doit garantir l’impossibilité que deux processus soient capables d’exécuter obtenir() et liberer() sur le même sémaphore en même temps L’implémentation est ainsi réduite à un problème de section critique, avec les conséquences décrites antérieurement (risques d’attentes prolongées mais facilité de réalisation)
Blocages (Deadlock and Starvation) L’utilisation des sémaphores et des queues d’attente peut donner lieu à 2 situations de blocage : Interblocage : deux ou plusieurs processus attendent indéfiniment un évènement qu’un autre parmi eux peut seul produire (“etreinte mortelle”). Exemple : Soit S et Q deux sémaphores initialisés à 1 P0 P1 obtenir(S); obtenir(Q); obtenir(Q); obtenir(S); . . liberer(S); liberer(Q); liberer(Q); liberer(S); Attente infinie : le processus n’est jamais enlevé de la queue d’accès au sémaphore
Problèmes classiques de synchronisation Le tampon de taille finie (problème du producteur / consommateur) Lecture et écriture dans une base de données Le problème des philosophes
Le tampon de taille finie public class BoundedBuffer implements Buffer { private static final int BUFFER SIZE = 5; // 5 objets produits/consommés private Object[] buffer; private int in, out; private Semaphore mutex; // assure l’exclusion mutuelle lors de l’accès au tampon private Semaphore empty; // compteur du nombre de tampons libres private Semaphore full; // compteur du nombre de tampons occupés // Suite
Le tampon de taille finie (suite) public BoundedBuffer() { // le tampon est initialement vide in = 0; out = 0; buffer = new Object[BUFFER SIZE]; mutex = new Semaphore(1); empty = new Semaphore(BUFFER SIZE); full = new Semaphore(0); } public void insert(Object item) { /* voir suite */ } public Object remove() { /* voir suite */ }
Le tampon de taille finie (suite) public void insert(Object item) { empty.obtenir(); mutex.obtenir(); // ajouter un élément au tampon buffer[in] = item; in = (in + 1) % BUFFER SIZE; mutex.liberer(); full.liberer(); }
Le tampon de taille finie (suite) public Object remove() { full.obtenir(); mutex.obtenir(); // enlève un élément du tampon Object item = buffer[out]; out = (out + 1) % BUFFER SIZE; mutex.liberer(); empty.liberer(); return item; }
Le tampon de taille finie (suite) import java.util.Date; public class Producer implements Runnable { // le producteur private Buffer buffer; public Producer(Buffer buffer) { this.buffer = buffer; } public void run() { Date message; while (true) { // attendre… SleepUtilities.nap(); // produit un message et ajoute au tampon message = new Date(); buffer.insert(message);
Le tampon de taille finie (suite) import java.util.Date; public class Consumer implements Runnable { // le consommateur private Buffer buffer; public Consumer(Buffer buffer) { this.buffer = buffer; } public void run() { Date message; while (true) { // attendre… SleepUtilities.nap(); // enlève un élément du tampon message = (Date)buffer.remove();
Le tampon de taille finie (suite) public class Factory { public static void main(String args[]) { Buffer buffer = new BoundedBuffer(); // création des fils consommateur et producteur Thread producer = new Thread(new Producer(buffer)); Thread consumer = new Thread(new Consumer(buffer)); producer.start(); consumer.start(); }
class Buffer { private static final MAX_AVAILABLE = 100; private final Semaphore available = new Semaphore(MAX_AVAILABLE, true); public Object getItem() throws InterruptedException { available.acquire(); // si disponibilité obtient le sémaphore, sinon bloque le thread return getNextAvailableItem(); } public void putItem(Object x) { if (markAsUnused(x)) available.release(); // libère le sémaphore protected Object[] items = ... Les objets qu’il faut gérer de manière protégée boolean[] used = new boolean[MAX_AVAILABLE]; protected synchronized Object getNextAvailableItem() { for (int i = 0; i < MAX_AVAILABLE; ++i) { if (!used[i]) { used[i] = true; return items[i]; return null; // not reached } protected synchronized boolean markAsUnused(Object item) { for (int i = 0; i < MAX_AVAILABLE; ++i) { if (item == items[i]) { if (used[i]) { used[i] = false; return true; else return false; } return false;
Lecture et écriture dans une base de données public class Reader implements Runnable { // la fonction “lecture” private RWLock db; public Reader(RWLock db) { this.db = db; } public void run() { while (true) { // attendre db.obtenirReadLock(); // Accès accordé // Lecture db.libererReadLock();
Lecture et écriture dans une BD (suite) public class Writer implements Runnable { // la fonction “écriture” { private RWLock db; public Writer(RWLock db) { this.db = db; } public void run() { while (true) { db.obtenirWriteLock(); // Accès accordé // Ecrit db.libererWriteLock();
Lecture et écriture dans une BD (suite) public interface RWLock { public abstract void obtenirReadLock(); public abstract void obtenirWriteLock(); public abstract void libererReadLock(); public abstract void libererWriteLock(); }
Lecture et écriture dans une BD (suite) La base de données public class Database implements RWLock { private int readerCount; private Semaphore mutex; private Semaphore db; public Database() { readerCount = 0; mutex = new Semaphore(1); db = new Semaphore(1); } public int obtenirReadLock() { /* voir suite */ } public int libererReadLock() {/* voir suite */ } public void obtenirWriteLock() {/* voir suite */ } public void libererWriteLock() {/* voir suite */ }
Lecture et écriture dans une BD (suite) Méthodes utilisée pour la lecture public void obtenirReadLock() { mutex.obtenir(); ++readerCount; // le premier lecteur en cours de lecture if (readerCount == 1) db.obtenir(); mutex.liberer(); } public void libererReadLock() --readerCount; // le dernier lecteur a terminé if (readerCount == 0) db.liberer();
Lecture et écriture dans une BD (suite) Méthodes utilisée pour l’écriture public void obtenirWriteLock() { db.obtenir(); } public void libererWriteLock() { db.liberer();
Le problème des philosophes chinois
Le problème des philosophes chinois - enoncé 5 philosophes réfléchissent autour d’une table ronde Chacun a devant lui un plat de riz Entre chaque plat de riz est disposée une baguette Pour manger, un philosophe doit utiliser 2 baguettes, mais ne peut utiliser que celles qui se trouvent autour de son plat Baguettes = données partagées (stick = baguette). Le philosophe qui veut manger prend les baguettes dans l’ordre gauche -> droite Seulement 2 philosophes peuvent manger en même temps Les deux philosophes qui mangent ne peuvent pas se trouver l’un a coté de l’autre Après avoir mangé, un philosophe pose les baguettes utilisées au même endroit et dans le même ordre. Ce problème est représentatif d’une large classe de problèmes de synchronisation de processus
Le problème des philosophes chinois (suite) On numérote les baguettes et les philosophes de 0 à 4. La baguette de gauche porte le même numéro que le philosophe, celle de droite, le numéro du philosophe + 1. Chaque baguette joue le rôle d’un sémaphore Les philosophes essayent de prendre systématiquement les baguettes de leur gauche en premier. Si le philosophe a réussi a obtenir la baguette de gauche il essaye de récupérer celle de droite. Lorsque la baguette demandée est prise par un autre philosophe, celui qui la demande attend.
Le problème des philosophes chinois (suite) On peut essayer de traiter le problème en représentant chaque baguette (stick) par un sémaphore : Semaphore baguette[] = new semaphore[5] Philosophe i: while (true) { // prend la baguette de gauche baguette[i].obtenir(); // prend la baguette de droite baguette[(i + 1) % 5].obtenir(); manger(); // pose la baguette de gauche baguette[i].liberer(); // pose la baguette de droite baguette[(i + 1) % 5].liberer(); reflechir(); // Réfléchit… }
Le problème des philosophes chinois (suite) La solution ne satisfait pas car elle peut conduire à un blocage : c’est le cas ou tous les philosophes décident en même temps de manger Pour contourner le problème on peut : Obliger au maximum 4 philosophes de décider simultanément de manger Permettre à un philosophe de manger seulement si les deux baguettes sont à sa portée, mais dans ce cas l’opération doit être réalisée de manière « atomique » (section critique). Utiliser une règle d’assymetrie selon laquelle : les philosophes « impairs » commencent par prendre la baguette de droite et les philosophes « pairs » commencent par prendre la baguette de gauche La solution ne doit pas seulement éviter le blocage, il faut également faire en sorte qu’aucun philosophe ne meurt de faim… parce qu’il n’a jamais accès aux baguettes !
Problème des sémaphores Des erreurs de programmation peuvent se produire dans la logique d’acquisition ou dans le « timing » des acquisitions / libérations des sémaphores. Si il y a des erreurs, elle sont difficilement détectables car elles se manifestent de manière « aléatoire » (du point de vue de l’utilisateur), c.a.d. seulement lorsque sont exécutées certaines séquences qui n’ont pas un caractère régulier
Problème des sémaphores (suite) Exemples d’erreurs Inversion de l’ordre des accès aux sémaphores : plusieurs processus peuvent exécuter leur section critique en même temps… smphr.liberer() SectionCritique() smphr.obtenir() Demande double du même sémaphore : blocage sectioncritique() Oubli d’une des demandes (obtenir ou liberer) : tout peut arriver !
Les moniteurs : une approche différente de la synchronisation Le moniteur est un module qui réalise une abstraction sécurisée de l’exécution des fils Dans le moniteur, un seul fil peut être actif à la fois, l’exclusion réciproque des fils est assurée par le moniteur Les structures internes du moniteur ne sont pas visibles de l’extérieur et aucun fil ne peut les adresser directement. Des varibles du type CONDITION permettent au programmeur de faire appel aux méthodes WAIT et SIGNAL du moniteur sous la forme X.Wait X.Signal L’opération Wait met en attente le fil dans une queue si la condition X est remplie jusqu’au moment ou un autre fil envoie le message Signal. Si aucun fil n’est suspendu lorsque Signal arrive, il ne se passe rien. Pour assurer un fonctionnement sans erreurs, lorsque un processus P envoie le message Signal il quitte le Moniteur et un processus Q en attente le remplace immédiatement.
Moniteur avec variables de condition Queue des demandes d’accès au moniteur Données partagées Opérations Code moniteur
Les philosophes : solution avec moniteur. monitor Philosophes { int[] etat = new int[5]; static final int REFLECHIT = 0; static final int FAIM = 1; static final int MANGE = 2; condition[] self = new condition[5]; // instantiation de la classe ; tous les philosophes sont en train de réfléchir public Philosophes { for (int i = 0; i < 5; i++) state[i] = THINKING; } // Suite
Les Philosophes – solution avec MONITEUR (suite) Le rôle des variables : int[] etat = new int[5]; // décrit l’état de chaque Philosophe static final int REFLECHIT = 0; // état “en cours de réflexion” static final int FAIM = 1; // état “a faim” et veut manger static final int MANGE = 2; // état “en train de manger” condition[] self = new condition[5]; // permet d’enregistrer l’état d’attente lorsque le processus ne peut pas continuer Notre solution impose que la personne qui mange soit entourée de personne qui ne sont pas en train de manger, c.a.d. qu’est remplie la condition : (etat[i+4]%5 != MANGE) && (etat[i+5]/%5 != MANGE)
Les philosophes : solution avec moniteur (suite). public levebag(int i) { etat[i] = FAIM; test(i); if (etat[i] != MANGE) self[i].wait; } public posebag(int i) { etat[i] = REFLECHIT; private test(int i) { if ( (etat[(i + 4) % 5] != MANGE) && (etat[i] == FAIM) && (state[(i + 1) % 5] != MANGE) ) { setat[i] = MANGE; self[i].signal;
Les philosophes : solution avec moniteur (suite). Avant de manger, un philosophe doit essayer de lever les baguettes (levebag). Cette méthode fait appel à le méthode test qui vérifie que les deux baguettes sont disponibles. Si la demande réussit, l’état du philosophe passe à MANGE (etat[i]=MANGE dans test), sinon le fil est mis en attente (self[i].wait dans levebag) Les opérations doivent se dérouler dans l’ordre suivant : philor.levebag(i); mange() ; // opération se déroulant sans risque philo.posebag(i); ou mntr est une instance de la classe PHILOSOPHES
Méthodes spécifiques Méthodes spécifiques : synchronized, wait(), notify() Notifications multiples Synchronization des blocs Les Semaphores Java Les moniteurs Java
Le qualificatif synchronized Java associe à chaque objet un verrou. Lors de l’invocation des méthodes de l’objet le verrou est ignoré. Le verrou est activé seulement si la méthode a été déclarée « synchronized » Pour appeler une méthode synchronisée il faut “obtenir” le verrou. Si le verrou est possédé par un autre thread, celui qui appelle la méthode sera mis en attente, dans la queue des threads qui demandent l’accès à la méthode verrouillée (entry set). Le verrou est libéré lorsque le thread quitte la méthode synchronisée Pour la sélection du thread qui prendra le contrôle du verrou, la machine virtuelle Java (JVM) applique normalement un algorithme FIFO
Exemple « producteur / consommateur » Public synchronized void insert(Object o) { while (cmpt == TAILLE_TAMPON) thread.yield(); cmpt++; tampon[i] = o; i = (i + 1)%TAILLE_TAMPON; } Public synchronized Object enleve(){ Object o; while (cmpt == 0) --cmpt; o=tampon[k]; k = (k+1)%TAILLE_TAMPON; return o; Ces méthodes sont appliquées au tampon commun du producteur et du consommateur. Elles nous assurent qu’il n’y aura pas de « course » entre les deux pour modifier cmpt.
Problème posé par synchronized La solution exposée peut conduire à une situation de blocage réciproque : Le consommateur « dort » Le tampon est plein Le producteur produit et se met en attente de consommation, mais a trouvé le verrou disponible et en a pris le contrôle… Le consommateur se « reveille » mais ne peut pas accéder à l’objet, car il est verrouillé !
Solution : les méthodes wait() et notify() Chaque objet Java possède en plus du verrou, un ensemble appelé wait set, destiné à contenir des instances en attente d’exécution. Si le thread entre dans une méthode synchronisée qu’il ne peut pas exécuter jusqu’à sa fin (cas du producteur qui se trouve devant un tampon plein), il va libérer le verrou acquis (en entrant dans la méthodes synchronisée), sera mis en état « arrêté » sera placé dans l’ensemble d’attente (le wait set), le temps nécessaire pour que la condition qui l’a empêché de continuer soit enfin réalisée Le thread qui peut débloquer la situation (dans notre exemple, le consommateur), doit annoncer que le thread bloqué peut continuer. On utilise à cet effet la méthode notify() qui : Extrait (au « hasard ») un thread de la liste d’attente Déplace le thread dans la liste des processus demandeurs d’accès au verrou (entry set) Change l’état du thread, de bloqué en exécutable
Producteur / conommateur : gestion du verrou Public synchronized void insert(Object o){ while (cmpt == TAILLE_TAMPON){ // le tampon est-il plein ? try{ wait(); // oui : enlève le verrou et déplace le thread -> queue d’attente } catch (InterruptedException e){ } cmpt++; // le thread peut continuer… tampon[i] = o; i = (i+1)%TAILLE_TAMPON; notify(); // annonce la fin de la méthode synchronisée
Producteur / consommateur : gestion du verrou Public synchronized Object enleve(){ Object o; while (cmpt == 0){ // le tampon est-il vide ? try { wait(); // oui : enlève le verrou et déplace le thread -> queue d’attente } catch(InterruptedException e) { } --cmpt; // le thread peut continuer… o=tampon[k]; k = (k+1)%TAILLE_TAMPON; notify(); // annonce la sortie de la méthode synchronisée return o;
notifyAll() Plusieurs threads peuvent se trouver dans la queue d’attente pour des raisons différentes. Il devient difficile de gérer leur réactivation avec notify(). La méthode notifyAll() permet de débloquer tous les threads de la queue d’attente pour les déplacer dans la queue d’entrée. La méthode notifyAll() réduit les performances mais est plus sécurisante.
Synchronisation des blocs Les procédures synchronisées peuvent être pénalisantes si elles sont complexes. Java donne la possibilité de synchroniser seulement les parties « sensibles » du code, en utilisant un objet que l’on peut appelé Verrou, comme dans l’exemple ci-dessous : Object Verrou = new Object(); ………… Public void uneMéthode() { sectionNonCritique() ; synchronized(Verrou) { sectionCritique() } sectionNonCritique
Gestion des interruptions Un thread peut être interrompu par un autre thread en utilisant la méthode interrupt() ce qui positionne un fanion dans le thread interrompu, mais ne l’arrête pas : l’arrêt effectif est de la responsabilité du thread interrompu, qui doit donc tester le fanion (isInterrupted()) La méthode wait() teste également ce fanion. Si il est « vrai », une exception est générée (le fanion est en même temps remis à « faux »). L’utilisation de wait() nous a obligé de prévoir la paire de blocs try{} / catch{} qui permettent de gérer les méthodes dans lesquelles il peut se produire des interruptions. Les interruptions ainsi générées peuvent être traitées dans le thread, mais cela n’a pas été fait dans les exemples précédents pour des raisons de clarté.
Quelques règles concernant la synchronisation des processus Un thread qui contrôle un verou d’un objet, peut entrer dans une autre méthode synchronisée et prendre le contrôle d’un autre verrou du même objet : verrouillage récursif. Le même thread peut contrôler des verrous appartenant à des objets différents Une méthode non-synchronisée peut être appelée quelle que soit l’état de l’objet auquel elle est rattachée : elle sera utilisable même si un verrou a été pris par un thread sur le même objet (c.a.d. qu’un autre thread exécute une méthode synchronisée). Si la queue d’attente (le wait set) d’un objet est vide, les méthodes notify() et notifyall() sont sans effet. Les méthodes wait(), notify() et notifyAll() peuvent être invoquées seulement à partir de méthodes synchronisées..