String
, comme nous l'avons déjà vu.
La protection de l'état d'un objet est l'un des enjeux de la programmation concurrente, au centre de la notion de
thead-safety
.
Chaudiere
, qui possède une
temperature
;
Thermostat
, qui interroge la chaudière, et augmente la température si celle-ci n'est pas déjà trop haute.
Chaudiere
.
Exemple 59. Accès concurrent à un état : classe
Chaudiere
public class Chaudiere { // un unique champ privé : l'état de notre chaudière private int temperature ; public Chaudiere() { this.temperature = 0 ; } public int getTemperature() { return this.temperature ; } public void augmenteTemperature() { this.temperature = this.temperature + 1 ; } }
Thermostat
.
Exemple 60. Accès concurrent à un état : classe
Thermostat
public class Thermostat { private Chaudiere chaudiere ; // un thermostat s'adresse à une unique chaudiere public Thermostat(Chaudiere chaudiere) { this.chaudiere = chaudiere ; } public boolean plusChaud() { // la temperature limite est de 25° if (chaudiere.getTemperature() < 25) { // l'appel à cette méthode indique simplement à la machine Java // qu'elle doit interrompre momentanément l'exécution de ce thread, // et passer la main au thread suivant Thread.yield() ; chaudiere.augmenteTemperature() ; return true ; } return false ; } }
Thermostat
est simple. Une instance de cette classe est construite sur une instance de
Chaudiere
. Une unique méthode est exposée, qui demande l'augmentation de la température à la chaudière. Si la température de la chaudière est inférieure à 25°, alors cette augmentation est acceptée et la méthode
plusChaud()
retourne
true
, sinon cette méthode retourne
false
.
Faisons à présent fonctionner notre système dans une méthode
main()
. Chaque habitant d'un appartement est un
thread
, qui possède un thermostat.
Exemple 61. Accès concurrent à un état : méthode
main()
// construction de notre unique chaudiere final Chaudiere chaudiere = new Chaudiere() ; // construction d'un habitant = instance de Runnable Runnable habitant = new Runnable() { public void run() { // chaque habitant possède son propre thermostat Thermostat thermostat = new Thermostat(chaudiere) ; int nTry = 0 ; do { // il demande a monter la temperature thermostat.plusChaud() ; nTry++ ; // on lui donne le droit de le faire 5 fois } while (nTry < 5) ; } } // construction de nos habitants Thread [] habitants = new Thread[20] ; // création de nos habitants for (int i = 0 ; i < habitants.length ; i++) { habitants[i] = new Thread(habitant) ; } // lancement de nos habitants for (int i = 0 ; i < habitants.length ; i++) { habitants[i].start() ; } // on attend que chaque thread ait fini son exécution for (int i = 0 ; i < habitants.length ; i++) { // jette InterruptedException habitants[i].join() ; } // on affiche la température de notre chaudière System.out.println("Temperature finale = " + chaudiere.getTemperature()) ;
join()
de la classe
Thread
, qui peut jeter une exception de type
InterruptedException
. Cette méthode a pour but d'attendre l'arrêt de l'exécution de ce
thread
. Elle rend la main quand ce thread a fini de s'exécuter.
On pourra tenter d'exécuter ce code plusieurs fois. Et on a toutes les chances de constater que la température finale de notre chaudière est bien supérieure aux 25° auxquels on pourrait s'attendre ! Même si l'on met la ligne
Thread.yield()
en commentaires, on peut observer, plus rarement, des températures de 26° et plus. Comment cela est-il possible ?
Exemple 62. Accès concurrent à un état : code pathologique
public boolean plusChaud() {
if (chaudiere.getTemperature() < 25) {
chaudiere.augmenteTemperature() ;
return true ;
}
return false ;
}
T1
exécute le test de température. Ce test répond
true
, T1 entre donc dans le bloc d'appel à l'augmentation de température. Avant que cette incrémentation ait pu avoir lieu, la machine Java suspend l'exécution de
T1
, et passe la main à
T2
. Cet autre
thread
effectue le test de température, qui répond toujours
true
. Le
thread
T2
entre donc dans le bloc, et va pouvoir augmenter la température. Mais avant qu'il puisse le faire, la machine Java l'interrompt encore, et passe la main à
T3
, qui va pouvoir, lui aussi, entrer dans le bloc d'augmentation de la température.
Au bout d'un moment, la machine Java va rendre la main à
T1
, puis
T2
, puis
T3
. Chacun de ces
threads
va augmenter la température, puisqu'il a déjà passé le test. À l'issue de cette exécution, la température sera de 27°.
Si l'on poursuit l'analyse un peu plus loin, on se rend compte qu'il existe un autre point sur lequel la machine Java peut interrompre l'exécution de nos
threads
. Il se trouve entre la lecture du champ
temperature
et son incrémentation.
Pour cette explication, nous nous sommes placé dans le cas où tous les
threads
sont exécutés sur le même processeur. Sur une machine multiprocesseurs (ou multicœurs, ce qui revient au même), la réalité est un peu plus complexe, du fait que le champ
temperature
n'est pas
volatile
.
T2
puisse entrer dans le bloc de test et d'augmentation de la température si un
thread
T1
s'y trouve déjà.
On peut parler ici d'
atomicité
: on souhaite que les deux opérations :
plusChaud()
.
Exemple 63. Classe
Thermostat
corrigée
public class Thermostat { private Chaudiere chaudiere ; // un thermostat s'adresse à une unique chaudiere public Thermostat(Chaudiere chaudiere) { this.chaudiere = chaudiere ; } public boolean plusChaud() { synchronized(chaudiere) { if (chaudiere.getTemperature() < 25) { chaudiere.augmenteTemperature() ; return true ; } } return false ; } }
volatile
en Java. Mais ne nous égarons pas, cela ne signifie pas, comme on peut l'entendre parfois, que ce champ est un poulet.
Ce mot-clé ne peut se poser que sur un champ, on ne peut pas déclarer une méthode comme étant
volatile
.
Afin de comprendre son utilité, analysons le fonctionnement de deux
threads
qui partagent le même champ. Supposons que ces deux
threads
T1
et
T2
soient en train de s'exécuter au même moment, sur deux processeurs différents, ou deux cœurs différents d'un même processeur. Cette situation était peut-être rare il y a quelques années, mais avec le temps, elle devient de plus en plus fréquente.
Si l'on n'y prend pas garde, et si l'on ne fait rien pour l'empêcher, chacun de nos deux
threads
peut choisir de copier la valeur de ce champ localement à son processeur, par exemple dans un registre. De cette façon, les accès à la valeur de ce champ seront beaucoup plus rapides.
Montrons le problème que cela peut poser. Supposons que
T1
fixe une valeur pour ce champ. Dans le contexte que l'on vient de décrire, cette valeur est écrite dans un registre du processeur qui exécute
T1
, que
T2
n'a aucune chance de voir. Il en va de même pour
T2
, qui peut modifier ce champ, sans que
T1
puisse en être informé.
T1
et
T2
peuvent donc être dans une situation où, au même moment, ils voient chacun une valeur différente pour ce champ, alors qu'il devrait s'agir de la même en permanence.
Une solution à ce problème est de synchroniser les blocs dans lesquels le champ en question est modifié. Effectivement, lorsqu'un
thread
sort d'un bloc synchronisé, alors toutes les variables qu'il a modifiées sont automatiquement publiées auprès de tous les autres
threads
qui les lisent. Ce comportement fait partie de la sémantique de fonctionnement de la synchronisation.
Cette solution est valide, mais coûteuse. Le caractère
volatile
d'un champ apporte une autre solution, plus légère.
Un champ déclaré
volatile
ne se comporte pas comme un champ normal. Toute écriture sur un de ces champs est appelée
lecture volatile
, et toute écriture est une
écriture volatile
. La règle est que toute lecture volatile d'un champ retourne la valeur fixée par la dernière écriture volatile qui a eu lieu sur ce champ. Cette règle s'applique systématiquement, indépendamment du
thread
dans lequel cette lecture ou cette écriture à eu lieu.
En déclarant un champ
volatile
, on a donc la garantie que la valeur de ce champ, vue de n'importe quel
thread
est toujours à jour.
L'intérêt est que l'accès à un champ
volatile
n'est pas synchronisé, et donc plus performant que l'accès à un champ qui se ferait au travers d'un bloc synchronisé.
Dans la pratique, déclarer un champ
volatile
fait que la machine Java ne stockera pas ce champ dans un registre, ce qui rendra les lectures de ce champ légèrement moins performantes.
Notons deux erreurs qu'il faut éviter de commettre :
volatile
n'est pas synchronisée ;
volatile
ne sont pas atomiques.