Java et la synchronisation

Romain Vimont (®om)

- Titre : Java et la synchronisation

- Auteur : Romain Vimont (®om)

- Parution : 21 septembre 2006

- Mise à jour : 8 novembre 2019

- Licence : Ce document est issu de http://www.developpez.com et reste la propriété exclusive de son auteur. La copie, modification et/ou distribution par quelque moyen que ce soit est soumise à l'obtention préalable de l'autorisation de l'auteur.

Sommaire

- I. Introduction

- I-A. Présentation des problèmes généraux

- I-B. Objectifs

- II. Exclusion mutuelle de sections critiques

- II-A. Mot-clé synchronized

- II-B. Package java.util.concurrent.locks

- III. Synchronisation coopérative

- III-A. Méthodes de la classe Object

- III-A-1. Utilisation

- III-A-2. Limites

- III-B. Package java.util.concurrent.locks

- III-C. Threads

- III-D. Sémaphores

- III-D-1. Définition

- III-D-2. Utilisation

- IV. Solutions implantées dans Java 1.5

- V. Interblocage

- VI. Éviter la synchronisation inutile

I - Introduction

La synchronisation est un élément essentiel dès lors que vous utilisez plusieurs threads (c'est-à-dire dans quasiment toutes les applications). En effet, sans synchronisation, il est impossible de développer une application robuste qui fonctionne, quel que soit l'entrelacement de l'exécution des threads.

Prérequis : connaître la syntaxe Java, la programmation orientée objet et le fonctionnement général des threads et de l'ordonnanceur.

I-A - Présentation des problèmes généraux

Tout d'abord, voyons un problème de synchronisation classique. Plusieurs entités récepteurs (threads) reçoivent une demande d'un client. Ils peuvent déposer des travaux dans une file. Les ouvriers prennent les travaux déposés, les traitent, et fournissent un résultat dans une autre file. L'émetteur récupère ces résultats et peut les envoyer au client.

Plusieurs problèmes de synchronisation se posent ici :

1. Plusieurs récepteurs ne peuvent pas déposer simultanément un travail dans la file des travaux, sinon les travaux seraient sur la même case ;

2. Si la file des travaux est pleine, les récepteurs doivent attendre qu'une case se libère pour déposer un nouveau travail ;

3. Plusieurs ouvriers ne peuvent pas prendre chacun un travail simultanément, sinon ils auraient le même travail à traiter ;

4. Si la file des travaux est vide, les ouvriers doivent attendre qu'un travail soit déposé pour pouvoir le traiter ;

5. Plusieurs ouvriers ne peuvent pas déposer simultanément le résultat d'un travail dans la file des résultats, sinon les résultats seraient sur la même case ;

6. Si la file des résultats est pleine, les ouvriers doivent attendre qu'une case se libère pour déposer un nouveau résultat ;

7. Si la file des résultats est vide, l'émetteur doit attendre qu'un résultat soit disponible.

I-B - Objectifs

Le but est de résoudre ces problèmes généraux, auxquels on peut dans la majorité des cas se ramener.

Pour les résoudre correctement, il faut assurer :

- la sûreté : rien de mauvais ne se produit, quel que soit l'entrelacement des threads (deux ouvriers ne peuvent jamais prendre le même travail) ;

- la vivacité : quelque chose de bon finit par se produire (si la file des travaux n'est pas vide, un ouvrier finira par prendre les travaux).

Il faut aussi prendre en compte les cas possibles ; par exemple, deux travaux déposés consécutivement peuvent être exécutés séquentiellement par le même ouvrier.

II - Exclusion mutuelle de sections critiques

L'exclusion mutuelle permet de résoudre les problèmes de synchronisations 1, 3 et 5 de la présentation des problèmes généraux.

Voici un exemple de programme qui ne respecte pas la sûreté, si plusieurs threads peuvent y accéder simultanément :

class ListeTab {

private String[] tab = new String[50];
private int index = 0;

void ajoute(String s) {
tab[index] = s;
index++;
}

}

Ce programme, comme vous l'aurez deviné, permet de gérer une liste de Strings en utilisant un tableau.

void ajoute(String s) {
tab[index] = s; //(a1)
index++;        //(a2)
}

void ajoute(String s) {
tab[index] = s; //(b1)
index++;        //(b2)
}

Soient deux threads T1 et T2 qui exécutent en parallèle (ou en pseudoparallèle sur un monoprocesseur) la fonction ajoute(String) sur la même ListeTab. Leurs actions peuvent être entrelacées, ainsi, plusieurs exécutions sont possibles.

Par exemple :

- (a1) (a2) (b1) (b2), est une exécution possible, cohérente ;

- (b1) (b2) (a1) (a2), est une exécution possible, cohérente ;

- (a1) (b1) (b2) (a2), est une exécution possible, mais incohérente : le tableau ne contient pas la chaîne de caractères ajoutée par T1, et une case de la liste est vide.

Plusieurs exécutions différentes peuvent conduire à des résultats différents, dont certains sont incohérents. Ce problème récurrent peut être résolu par l'exclusion mutuelle (mutex).

La fonction ajoute est appelée section critique. Plusieurs sections critiques dépendantes ne doivent jamais exécuter leur code simultanément (par plusieurs threads différents) : on dit qu'elles sont en exclusion mutuelle. Dans l'exemple précédent, ajoute était en exclusion mutuelle avec elle-même, mais on peut bien sûr imaginer qu'elle sera aussi en exclusion mutuelle avec la fonction supprime…

Pour mettre en place l'exclusion mutuelle, il faut utiliser des verrous. Lorsqu'un thread entre dans une section critique, il demande le verrou. S'il l'obtient, il peut alors exécuter le code. S'il ne l'obtient pas, parce qu'un autre thread l'a déjà pris, il est alors bloqué en attendant de l'obtenir. Il est possible d'utiliser un nombre potentiellement infini de verrous, et donc faire des exclusions mutuelles précises : par exemple, a() doit être en exclusion mutuelle avec lui-même et avec b(), tandis que c() doit être en exclusion mutuelle avec lui-même et avec d(), mais pas avec a() et b()…

Une section critique est vue comme une opération atomique (une seule opération indivisible) par une autre section critique utilisant le même verrou.

II-A - Mot-clé synchronized

Pour réaliser ceci en Java, la méthode la plus simple est d'utiliser le mot-clé synchronized.

Voici comment il s'utilise :

synchronized(unObjet) {
//section critique
}

unObjet représente un verrou, qui peut être un objet Java quelconque. Attention cependant, il vaut mieux utiliser des références déclarées final, pour être sûr que la référence vers l'objet n'est pas modifiée ; en effet, le cas de figure ci-dessous ne fonctionne pas, car lorsque plusieurs threads arrivent au bloc synchronisé, la variable maListe ne référence pas toujours le même objet, et donc ne représente pas toujours le même verrou.

synchronized(maListe) {
maListe = new ArrayList<String>();
}

Cas particulier : lorsque l'objet qui sert de verrou pour la synchronisation est this, et qu'il englobe tout le code d'une méthode, on peut mettre le mot-clé synchronized dans la signature de la méthode. Les deux codes ci-dessous sont strictement équivalents :

void methode() {
synchronized(this) {
//section critique
}
}

synchronized void methode() {
//section critique
}

II-B - Package java.util.concurrent.locks

Une autre méthode pour réaliser des verrous est apparue dans Java 1.5. À première vue, elle peut paraître plus compliquée, mais elle est aussi plus puissante, dans le sens où elle permet de faire des choses que le mot-clé synchronized ne permet pas (nous y reviendrons plus tard).

Voici comment l'utiliser :

Lock l = new ReentrantLock();
l.lock();
try {
//section critique
} finally {
l.unlock();
}

Pour comparer cette méthode avec la précédente, l.lock() et l.unlock() correspondent respectivement au début et à la fin du bloc synchronized.

Cependant, si vous désirez faire une exclusion mutuelle aussi simple que celles des exemples présentés ici, je vous conseille d'utiliser synchronized, et de réserver cette méthode pour des cas bien particuliers, que nous verrons ultérieurement.

III - Synchronisation coopérative

La synchronisation coopérative permet de résoudre les problèmes de synchronisations 2, 4, 6 et 7 de la présentation des problèmes généraux.

Les deux premières solutions, présentées dans des exemples simples, utilisent plus ou moins le design pattern Moniteur.

III-A - Méthodes de la classe Object

III-A-1 - Utilisation

La manière la plus basique de synchronisation entre plusieurs threads Java est l'utilisation des méthodes wait() et notify() (et éventuellement notifyAll()) définies dans la classe Object. Pour comprendre leur fonctionnement, complétons notre exemple de tout à l'heure :

class ListeTab {

private String[] tab = new String[50];
private int index = 0;

synchronized void ajoute(String s) {
tab[index] = s;
index++;
notify();
System.out.println("notify() exécuté");
}

synchronized String getPremierElementBloquant() {
//tant que la liste est vide
while(index == 0) {
try {
//attente passive
wait();
} catch(InterruptedException ie) {
ie.printStackTrace();
}
}
return tab[0];
}

}

La méthode getPremierElementBloquant retourne le premier élément de la liste ; si la liste est vide, et bien elle attend qu'il y ait un élément ajouté.

Vous avez peut-être été surpris par la boucle while de la méthode getPremierElementBloquant plutôt qu'un simple if. Nous allons voir en détail ce qu'il se passe.

Supposons qu'un thread T1 exécute ajoute et qu'un thread T2 exécute getPremierElementBloquant.

Supposons que T2 ait d'abord la main, il prend le verrou (la méthode est définie synchronized), il trouve index == 0 vrai, donc il exécute wait(). Ce wait() est bloquant tant qu'un notify() sur le même objet ne le libère pas.

Remarque : si plusieurs threads exécutent unObjet.wait(), chaque unObjet.notify() débloquera un thread bloqué, dans un ordre indéterminé.

Maintenant, T1 prend la main. il exécute ajoute, et donc demande le verrou. Mais vous allez me dire, il ne l'obtiendra pas, car c'est T2 qui a le verrou ! Et bien si, il l'obtiendra, car T2 a lâché le verrou dès lors qu'il a appelé la méthode wait().

Remarque : l'appel à la méthode wait() libère le verrou uniquement parce que l'objet sur lequel a été appliqué wait() (ici, this) est le même que le verrou.

T1 peut donc exécuter le code de la méthode ajoute. Lorsqu'il exécute notify(), il débloque T2 (de manière asynchrone). Mais, maintenant, bien sûr, T2 n'a pas le verrou, puisque c'est T1 qui l'a (pour exécuter le System.out.println(…)). T2 est donc bloqué en attente du verrou, et d'autres threads (imaginons T3, T4…) peuvent l'obtenir avant lui. Supposons que T3 supprime tous les éléments de la liste (par une éventuelle méthode vider()), et qu'ensuite T2 prenne la main. T2 sort alors du wait(). Il a donc besoin de revérifier la condition index == 0, sinon il essaierait de récupérer l'élément à l'index 0 du tableau, qui est vide. C'est pour cela qu'il faut utiliser la boucle while.

Remarque : il ne faut surtout pas confondre cette boucle while avec une attente active qui vérifierait en permanence que l'index est différent de 0. Ici, nous regardons si l'index est 0, si c'est le cas, nous mettons le thread en attente passive, qui sera réveillé uniquement lorsqu'un élément aura été ajouté. Une fois qu'il est réveillé, pour la raison que nous venons de voir, nous avons besoin de revérifier la condition.

Cette manière de procéder est pratique pour des problèmes de synchronisation simples ; pour des problèmes plus complexes, il vaut mieux utiliser d'autres méthodes. En l'occurrence, la liste bloquante faisait objet d'exemple est totalement codée dans l'API Java 1.5, nous y reviendrons.

III-A-2 - Limites

Hormis la difficulté d'implantation pour des problèmes complexes, cette solution atteint ses limites dès lors qu'une section critique doit être synchronisée sur plusieurs verrous.

class UneClasse {

private final Object lock1 = new Object();
private final Object lock2 = new Object();

void methode() {
synchronized(lock1) {
synchronized(lock2) {
while(condition) {
lock2.wait();
}
}
}
}

void notifie() {
lock2.notify();
}

}

En effet, pour appliquer le wait(), nous sommes obligés de choisir l'un des deux verrous, et donc un seul des deux est libéré pendant l'attente. En l'occurrence, ici, l'appel à methode() verrouille totalement lock1 tant que lock2.wait() ne s'est pas terminé.

III-B - Package java.util.concurrent.locks

La solution que nous avons vue pour réaliser l'exclusion mutuelle utilisant ce package permet aussi d'aller au-delà des limites des méthodes de la classe Object. Le principe est de n'utiliser qu'un seul verrou, mais avec plusieurs variables condition (sur lesquelles nous pourrons effectuer les opérations similaires à wait() et à notify()).

class UneClasse {

private final Lock lock = new ReentrantLock();
private final Condition cond1 = lock.newCondition();
private final Condition cond2 = lock.newCondition();

void methode1() throws InterruptedException {
lock.lock();
try {
cond1.await();
cond2.signal();
} finally {
lock.unlock();
}
}

void methode2() throws InterruptedException {
lock.lock();
try {
cond1.signal();
cond2.await();
} finally {
lock.unlock();
}
}

}

Maintenant, les appels à cond1.await() et cond2.await() libèrent bien le verrou lock.

Notez bien qu'il n'est pas garanti que le programme termine si deux threads effectuent respectivement des appels à methode1() et à methode2(). En effet, si cond1.signal() a lieu avant cond1.await(), le await() ne sera jamais réveillé.

Une interface ReadWriteLock est aussi disponible dans ce package, permettant de résoudre les problèmes de lecture-écriture (à tout moment, soit un nombre quelconque de lecteurs, soit un et un seul écrivain). Je vous laisse consulter la documentation pour son utilisation, mais il est bon de savoir que cela existe.

III-C - Threads

Jusqu'ici, le mot thread désignait un processus léger. Dans cette section, le mot Thread signifiera la classe Thread de Java. La relation existant entre les deux est que l'appel à la méthode start() de la classe Thread crée un nouveau thread dans lequel du code est exécuté.

Ceci n'est pas un article sur la classe Thread, mais voici quelques méthodes à connaître :

- static Thread currentThread() : permet d'obtenir le thread courant ;

- static void yield() : laisse une chance aux autres threads de s'exécuter ;

- static void sleep(long ms) throws InterruptedException : suspend l'exécution du thread appelant pendant la durée indiquée (ne pas utiliser ceci à des fins de synchronisation !) ;

- void interrupt() : provoque soit la levée de l'exception InterruptedException si l'activité est bloquée sur une opération de synchronisation, soit le positionnement d'un indicateur interrupted ;

- void join() : attente bloquante de la terminaison de l'exécution du thread (jusqu'à ce que la méthode run() associée au Thread ait fini de s'exécuter).

III-D - Sémaphores

Impossible d'écrire un tutoriel concernant la synchronisation sans parler des sémaphores. Dans une présentation plus théorique des problèmes de synchronisation, les sémaphores seraient présentés avant les deux solutions précédentes ; mais, en pratique, vous n'aurez probablement pas besoin de les utiliser en Java. Cependant, je trouve que la compréhension de leur fonctionnement est importante.

La classe Semaphore est apparue dans Java 1.5.

III-D-1 - Définition

Un sémaphore encapsule un entier, avec une contrainte de positivité, et deux opérations atomiques d'incrémentation et de décrémentation :

- variable entière (toujours positive ou nulle) ;

- opération P (acquire()) : décrémente le compteur s'il est strictement positif ; bloque s'il est nul en attendant de pouvoir le décrémenter ;

- opération V (release()) : incrémente le compteur.

On peut voir le sémaphore comme un ensemble de jetons, avec deux opérations :

- prendre un jeton, en attendant si nécessaire qu'il y en ait ;

- déposer un jeton.

Remarque : les jetons déposés ne sont pas forcément ceux qui ont été pris.

III-D-2 - Utilisation

Voyons un exemple très particulier de sémaphore à un jeton :

Semaphore sem = new Semaphore(1);
try {
sem.acquire();
//section critique
sem.release();
} catch(InterruptedException e) {
e.printStackTrace();
}

Un sémaphore à un jeton est très similaire à un verrou. Cependant, il ne faut pas les confondre ! En effet, si l'on effectue plusieurs fois l'opération V, un sémaphore garde en mémoire ces demandes en incrémentant l'entier qu'il utilise ; pour un verrou, effectuer plusieurs déverrouillages est strictement équivalent à n'effectuer qu'un seul déverrouillage. On peut ainsi voir plusieurs opérations V successives sur un sémaphore comme des notify() « retardés » sur un verrou.

IV - Solutions implantées dans Java 1.5

La version 1.5 de Java et son package java.util.concurrent (ainsi que ses sous-packages) fournissent des outils de synchronisation de plus haut niveau.

Par exemple, l'exemple que j'ai utilisé censé simuler une liste bloquante est implanté par BlockingQueue (liste bloquante non bornée) et ArrayBlockingQueue (liste bloquante à tampon borné).

Un autre outil très utile est Executor. Il s'agit d'une liste d'attente bloquante d'actions à effectuer. On retrouve exactement le même mécanisme lorsque l'on utilise le thread dédié à l'affichage graphique (EventDispatchThread) de Swing : SwingUtilities.invokeLater(Runnable) met en file d'attente une action à effectuer dans le thread dédié à l'affichage graphique.

Voici un exemple d'utilisation d'Executor :

Executor executor = Executors.newSingleThreadExecutor();
executor.execute(new Runnable() {
public void run() {
System.out.println("appel asynchrone 1");
}
});
executor.execute(new Runnable() {
public void run() {
System.out.println("appel asynchrone 2");
}
});

Cet exemple exécute les Runnables de manière asynchrone vis-à-vis du thread courant, mais assure qu'ils seront appelés dans l'ordre.

D'autres outils sont également disponibles, consultez la documentation.

V - Interblocage

Le problème le plus courant en synchronisation lors du développement est l'interblocage. Un interblocage (ou deadlock) est un blocage qui intervient lorsque par exemple un thread A attend un thread B en même temps que B attend A.

Voici un exemple très simple d'interblocage :

class DeadLock {

private final Object lock1 = new Object();
private final Object lock2 = new Object();

void a() throws InterruptedException {
lock1.wait();
lock2.notify();
}

void b() throws InterruptedException {
lock2.wait();
lock1.notify();
}

}

Si un thread T1 exécute a() et qu'un thread T2 exécute b(), il y a interblocage. En effet, T1 attend le notify() de T2, mais pour que T2 appelle le notify(), il faut d'abord que T1 exécute son notify()…

Il faut absolument veiller à ne jamais avoir d'interblocage.

VI - Éviter la synchronisation inutile

La synchronisation est quelque chose d'essentiel dans beaucoup de situations. Cependant, elle coûte cher en ressources processeur : il ne faut pas tout synchroniser. Si une méthode ne peut être appelée que d'un seul et unique thread, ne la synchronisez pas. En l'occurrence, les méthodes d'écouteurs Swing seront forcément appelées dans l'EDT, donc inutile de synchroniser quoi que ce soit (sauf si vous créez à partir de là de nouveaux threads).

Lorsque vous utilisez beaucoup de synchronisation, essayez de voir si le modèle d'une file d'attente unique ne convient pas mieux (Executor), cela simplifiera de plus beaucoup votre code…

Évitez les anciennes Collections datant de Java 1.0 (Vector, Hashtable…) qui sont par défaut synchronisées, mais utilisez les nouvelles datant de Java 1.2 (ArrayList, HashMap…) qui ne sont pas synchronisées (meilleures performances). Pour récupérer une vue synchronisée d'une Collection, il suffit de faire Collections.synchronizedCollection(Collection).