Synchronisation et volatilité

Les deux notions de synchronisation et de volatilité sont en train de prendre, depuis quelques années, une importance croissante. La généralisation des processeurs multicœurs, jusque dans les téléphones portables, fait surgir des bugs dans les applications Java, qui ne s’observaient pas auparavant.

Nous avons de la chance, nous autres développeurs Java, pour deux raisons. La première, c’est que nous avons une doc, la JLS (Java Language Specification, nous vivons sous le règne de la v3), qui spécifie tous les aspects du langage. La deuxième, c’est que cette doc comporte un chapitre 17, qui traite précisément des problématiques de la programmation concurrente.

Nous avons aussi deux malchances. La première, c’est que la JLS est vraiment un document pénible à lire. Et la deuxième, c’est que le chapitre 17 est totalement hermétique. Finalement, traiter des problèmes complexes dans des documents illisibles a au moins un mérite : ça peut justifier l’écriture d’articles dans des blogs techniques.

Donc, ne reculant devant rien pour te satisfaire, précieux et intrépide lecteur, je te propose une petite explication de texte sur la synchronisation en Java, qui, je l’espère éclairera ta lanterne sur une problématique majeure du développement, actuellement et pour les années qui viennent.

Introduction

De quoi vais-je parler dans cet article ? Tout d’abord des opérations de base qui permettent de faire de la synchronisation : lock et unlock. Jusqu’à Java 5, ce sont ces opérations qui ont orchestré la programmation concurrente, et elles sont toujours d’actualité. Leur fonctionnement est simple, mais il comporte quelques subtilités qu’il est bon de rappeler.

Puis en Java 5, on change de partition, le package de Doug Lea edu.oswego fait son entrée dans l’API standard sous le nom java.util.concurrent, et le chapitre 17 de la JLS, qui traite de la programmation concurrente, est entièrement réécrit. Je vais donc passer un peu de temps à expliquer les notions fondamentales d’ordonnancement d’actions dans l’exécution d’un programme, et des deux relations introduites dans la JLS.

Synchronisation : opérations lock et unlock

Ces deux opérations existent depuis que Java existe, et sont bien connues.

On peut définir un bloc synchronisé en Java de deux façons :

  • en le déclarant explicitement, auquel cas on passe un paramètre au mot-clé synchronized, qui peut être n’importe quel objet ;
  • en posant le mot-clé synchronized sur une méthode, statique ou non.

Dans ces deux cas, le bloc synchronisé est associé à un objet. Cet objet est :

  • l’argument passé au mot-clé synchronized ;
  • l’instance de l’objet dans lequel on se trouve si ce mot-clé est posé sur une méthode non statique ;
  • l’instance de Class si cette méthode synchronisée est statique.

Tout objet Java possède un moniteur. Lors de la conception du langage, tout le monde trouvait cette idée excellente, il semblerait que le vent a un peu tourné depuis. Par ailleurs, rappelons-nous qu’il est toujours bon de ne pas exposer l’objet dont on utilise le moniteur.

La sémantique du lock est assez simple, et très classique.

On peut opérer deux actions sur ce moniteur : lock (verrouillage) et unlock (déverrouillage). Quand un thread lance une action de lock sur le moniteur d’un objet, on dit qu’il prend le lock de cet objet. Un même thread peut verrouiller un même moniteur autant de fois qu’il le souhaite.

Si un autre thread tente une action de lock sur un moniteur déjà verrouillé, alors la machine Java le bloque, jusqu’à ce que le thread qui possède ce lock le libère.

Lorsqu’un thread tente d’entrer dans un bloc synchronisé, il tente une action lock. Ce thread ne peut entrer dans ce bloc synchronisé que lorsque cette action rend la main. À la fin de l’exécution de ce bloc (éventuellement sur une exception), le thread exécute une action unlock. Un thread qui tente d’entrer dans un bloc synchronisé avec un objet dont il possède déjà le lock, entre dans ce bloc.

Ce simple mécanisme permet de garantir que deux threads ne peuvent pas exécuter en même temps un même bloc synchronisé. Un bloc synchronisé est donc « à exclusion mutuelle d’exécution ».

On confond parfois la notion de monitor en Java et la notion de mutex. À mon avis ce n’est pas une bonne idée, car la synchronisation, comme nous allons le voir, fait plus qu’une simple exclusion d’exécution.

Visibilité : le Java Memory Model

En toute rigueur, le Java Memory Model spécifie deux choses pour la programmation concurrente en Java : la visibilité et la causalité. La causalité est une garantie de bon fonctionnement, qui n’a pas d’impact sur la façon d’écrire un code concurrent. Je ne m’attarderai donc pas sur ce point, qui reste, du point de vue du développeur, très théorique. En revanche, la visibilité impose d’écrire du code concurrent d’une certaine manière, c’est donc l’objet de cette partie.

Le Java Memory Model est décrit dans le paragraphe 17.4 de la JLS, et il s’agit sans aucun doute d’un morceau de choix. Les auteurs de la JLS ont choisi une approche vraiment hermétique pour décrire les concepts. L’approche « théorie des graphes » qu’ils ont utilisée sert peut-être les personnes qui implémentent des JVM. Mais pour nous autres pauvres développeurs, sachant tout juste lire et écrire, elle ne fait qu’ajouter une couche de complexité à un problème qui n’est pas spécialement simple. Si l’on y ajoute les fautes de frappe, les explications trop rapides et l’utilisation de notations non définies, on obtient un plat vraiment très indigeste.

Ce plat indigeste, précieux lecteur, nous allons le déguster ensemble !

L’objet du Java Memory Model (JMM) est simple : définir sans ambiguïté la valeur des variables qu’un thread lit. Lorsque l’on est en contexte monothread la solution est simple : toute lecture d’une variable doit retourner la dernière valeur écrite dans cette variable. La notion de monothread est à prendre dans un sens simplifié : elle signifie juste que toutes les lectures et écritures des variables que l’on manipule sont faites par une unique thread.

Dans un contexte multithread les choses sont plus délicates. C’est précisément cette notion de « dernière » valeur écrite qui pose problème, et qui est à l’origine de l’écriture de la section 17.4. Si l’on y réfléchit, on se rend compte que la « dernière » écriture fait référence à une notion de ligne de temps, éventuellement virtuelle, dans laquelle on peut dire que telle action s’est déroulée avant telle autre. C’est de ce constat que vient l’introduction dans le Java Memory Model de la relation « exécuté avant » (traduction personnelle de « happen before« ), que nous allons définir maintenant.

Exécution ordonnée et synchronisée

Au cœur du problème de la lecture des variables partagées entre plusieurs threads, se trouve cette notion d’ordonnancement. L’une des premières choses que l’on doit faire, est donc de définir une relation d’ordre entre les actions menées par différents threads.

Pour cela, la JLS définit tout d’abord la notion d’exécution d’un programme. L’exécution d’un programme est tout simplement l’exécution de l’ensemble des actions exécutées par chaque thread pris individuellement. Rien de plus simple.

La relation d’ordre définie entre toutes ces actions est la relation « exécuté avant ». Au sein d’un même thread, savoir si une action A est « exécutée avant » une action B est trivial : il suffit de voir où se trouve A par rapport à B dans l’écriture du code. Il est important de comprendre que cette notion est complètement détachée du fait que l’action A va s’exécuter plus tôt dans le temps que l’action B. Il se peut qu’elle soit exécutée après, ou en même temps. Ce qui importe c’est la sémantique associée.

Si A et B sont exécutées par deux threads différents, cette mise en relation devient moins triviale, nous allons voir comment résoudre ce problème.

Quelle est l’utilité de la définition d’une telle relation ? La JLS impose que si une action A est « exécutée avant » une action B, alors ce qu’a fait A est visible de B. En particulier, les valeurs des variables qui ont été fixées ou changées par A sont visibles de B.

Pour savoir si une action donnée est « exécutée avant » une autre action dans un autre thread, nous avons besoin de points de repères. Ces points de repères sont définis dans la JLS, grâce à une deuxième relation « synchronisé avec » (traduction personnelle de « synchronized with« . Cette deuxième relation définit la notion de synchronisation et c’est aussi une relation d’ordre. Une action A « synchronisée avec » une action B, par définition est « exécutée avant » B.

Cette relation « synchronisée avec » est fixée par définition dans la JLS entre certaines actions particulières. C’est l’existence de cette relation qui permet de déterminer si l’action A est « exécutée avant » B. Savoir si une action particulière A est « exécutée avant » une autre action B revient donc à rechercher les actions en relation « synchronisé avec » des threads qui exécutent A et B, et de repérer où se trouvent A et B par rapport à ces actions synchronisées.

L’introduction en Java 5 de classes permettant de synchroniser du code sans passer par les blocs synchronized de Java 1, a repris ce concept. La documentation de l’API récapitule l’ensemble des relations « exécuté avant » qui existent entre les différentes actions définies. Sont donc en relation « exécuté avant » les opérations suivantes.

  • L’ajout d’un objet à une collection concurrente et l’accès en lecture ou modification à cette collection.
  • La soumission d’une instance Runnable ou de Callable et l’exécution de celle-ci.
  • Les actions asynchrones d’une instance de Future et le résultat de l’invocation de sa méthode get().
  • La libération des instances de LockSemaphoreCondition et CountDownLatch et les actions qui bloquent ces instances.
  • Les actions déclarées avant l’appel à la méthode Exchanger.exchange() et celles après.
  • Les actions déclarées avant l’appel à CyclicBarrier.await() sont « exécutées avant » celles associées à cette barrière, elles-mêmes « exécutées avant » celles déclarées après le retour de la méthode await().

Actions synchronisées

Tout d’abord, la relation « synchronisé avec » existe entre toutes les actions exécutées par un thread donné, prises dans l’ordre dans lequel elles sont écrites dans le code. La JLS définit six actions en relation « synchronisé avec ».

  • Une action qui démarre un thread est « synchronisée avec » toutes les actions exécutées par ce thread.
  • La dernière action d’un thread t est « synchronisée avec » les actions t.join() exécutées dans un autre thread.
  • Si un thread T1 interrompt un thread T2, l’interruption par T1 est « synchronisée avec » toutes les détections de l’interruption de T2 (exception, appels aux méthodes interrupted() ou isInterrupted()).
  • Une action unlock sur le moniteur d’un objet donné est « synchronisée avec » toutes les actions lock qui suivent sur ce même moniteur.
  • Une écriture sur une variable volatile est « synchronisée avec » toutes les lectures sur cette variable.
  • L’écriture de la valeur par défaut dans les champs d’un objet est « synchronisée avec » toutes les actions prises sur cet objet.

Supposons qu’un premier bloc synchronisé sur un objet key mette à jour un ensemble de variables. Un deuxième bloc, synchronisé sur le même objet key utilise les valeurs de ces variables. Un premier thread exécute le premier bloc, temps durant lequel il est donc impossible qu’un autre thread puisse exécuter le deuxième bloc. Un autre thread exécute le deuxième bloc, le quatrième point nous apporte la garantie que ce deuxième thread va lire les valeurs fixées par le premier.

Dans la pratique, on lit souvent que sur l’acquisition d’un lock, un thread met à jour ses propres variables avec les valeurs qu’il lit dans la mémoire partagée. Sur libération de ce lock, il recopie les valeurs de ces variables dans cette mémoire partagée. Ce point n’est toutefois pas présent dans la JLS v3.

Cas des champs volatile

Le mot-clé volatile posé sur un paramètre, permet de garantir qu’une valeur écrite par un thread sera lue correctement par un autre thread. Comme nous le verrons dans la suite, ce mot-clé impose de plus l’atomicité des opérations de lecture et d’écriture pour les champs codés sur 64 bits (long, double et références vers des objets, suivant les implémentations). Les autres opérations ne sont pas atomiques sur les champs volatile.

Définition d’une Data race

La data race est définie très précisément dans la JLS. Il s’agit d’une configuration dans laquelle une action de lecture et une action d’écriture sur une même variable ne sont reliées par aucune relation « exécuté avant ». Une data race est donc un cas dans lequel la valeur d’une variable ne peut pas être définie.

public class DataRace {

	private int x, y, r1, r2 ;
	private Object lock = new Object() ;

	public void method1() {
		x = 1 ;
		synchronized(lock) {
			y = 1 ;
		}
	}

	public void methode2() {
		synchronized(lock) {
			r1 = y ;
		}
		r2 = x ;
	}
}

Cet exemple est une traduction en Java de l’exemple 17.4.5 de la JLS. Supposons que deux threads T1 et T2 exécutent chacun les deux méthodes method1() et method2() en concurrence d’accès. Examinons ce qui peut se passer.

  • Si T1 verrouille le moniteur de lock avant T2, alors le déverrouillage de lock par T1 est « exécuté avant » le verrouillage par T2, car, par définition un déverrouillage est « synchronisé avec » un verrouillage. Donc l’action x = 1 est « exécutée avant » r2 = x.
  • En revanche, si T2 verrouille le moniteur de lock avant T1, alors tout ce que l’on sait, c’est que x = 1 est exécutée avant le verrouillage par T1, et que r2 = x est exécutée après le déverrouillage. Mais rien ne nous dit si x = 1 sera « exécutée avant » r2= x. Donc on ne peut pas prévoir a priori la valeur de r2 à l’issue de cette exécution.

Un programme est correctement synchronisé s’il ne contient pas de data race.

Cet autre exemple est peut-être plus subtil et plus fréquemment rencontré.

public class AutreDataRace {

	private int valeur ;
	private Object lock = new Object() ;

	public void setValeur(int valeur) {
		synchronized(lock) {
			if (valeur < 50) {
				this.valeur = valeur ;
			}
		}
	}

	public int getValeur() {
		return this.valeur ;
	}
}

L’écriture du champ valeur est bien synchronisée, on a donc la garantie que ce champ ne pourra pas prendre de valeurs supérieures à 50. En revanche, la lecture de ce champ n’est pas synchronisée. Il n’existe donc pas de relation « exécuté avant » entre la lecture et l’écriture de ce champ, et rien de garantit que la méthode getValeur() puisse retourner une autre valeur que la valeur par défaut du champ valeur, c’est-à-dire 0. On peut lever ce problème de deux façons : synchroniser la lecture, ou rendre ce champ volatile.

Cas des champs finaux

Comme tout le monde le sait un champ final ne peut voir sa valeur fixée qu’une seule fois, et ne peut plus voir sa valeur changée une fois le constructeur de l’objet qui le porte totalement exécuté. Il faut toutefois prendre garde aux quelques cas pathologiques dans lesquels il est possible de lire un champ final avant qu’il ait été initialisé, précisément durant la construction d’un objet.

La JLS nous garantit que la lecture d’un champ final par un thread, quel qu’il soit, retourne la valeur qu’aurait ce champ une fois la construction de cet objet terminée.

public class FinalField {

	public static FinalField field ;
	private final int i ;
	private int j ;

	public FinalField() {
		j = 1 ;
		i = 1 ;
	}

	public static void write() {
		f = new FinalField() ;
	}

	public static void read() {
		if (f == null) {
			int x = f.i ;
			int y = f.j ;
			// suite du code
		}
	}
}

Mettons-nous dans le cas où un thread T1 appelle la méthode write() et un thread T2 appelle read(). L’application de cette règle fait que T2 verra toujours la valeur 1 pour i, mais verra peut-être la valeur 0 pour j. Comme on le voit sur cet exemple, respecter ce point peut imposer à la JVM de réordonner les opérations écrites dans le code.

En programmation concurrente, il faut prendre garde de ne pas exposer la référence vers un objet tant que sa construction n’est pas terminée. Cet exemple de bug, fourni dans la JLS, s’applique rigoureusement au cas du singleton implémenté en double-checked locking.

Cas de la classe String

La façon dont la classe String a été écrite est une conséquence directe de ce point. La classe String est construite sur trois champs :

  • value : le tableau de char qui porte la valeur de cette chaîne de caractères ;
  • offset : l’index du premier caractère de cette chaîne dans le tableau value ;
  • et count : le nombre de caractères dans ce tableau.

L’implémentation de String fait que cette classe est immutable : une fois fixée une valeur pour une instance de String, on ne peut plus la changer. Cela permet de nombreuses optimisations, comme le calcul du code de hachage, qui n’est fait qu’une seule fois. Cela permet d’autres optimisations, plus subtiles. Si l’on regarde l’implémentation de la méthode substring(), on se rend compte qu’elle ne fait que créer une nouvelle instance de String, sur le même tableau value que la chaîne originelle, mais avec des valeurs offset et count différentes.

Ces trois champs sont déclarés final, ce qui est normal du fait que String est immutable. Du point de vue de la programmation concurrente, cela permet de garantir qu’en cas de concurrence d’accès, un thread T2 ne verra jamais d’intermédiaire dans le calcul d’une sous-chaîne effectué par un thread T1.

Il reste possible de changer la valeur d’un champ final une fois construit l’objet qui le porte, en utilisant l’introspection. Ce mécanisme est utilisé en particulier lors de la désérialisation des objets. Dans ce cas, il est possible qu’un thread ne se synchronise pas correctement, et ne « voit » pas la modification faite sur ce champ. La bonne façon de faire est ne pas s’exposer à ce problème, est de ne rendre accessible l’objet désérialisé aux autres threads que lorsqu’il est entièrement reconstitué.

Cas des champs sur 64 bits

Le cas de ces champs est également pathologique. Il y a trois champs qui vérifient ce critère :

  • les entiers de type long ;
  • les flottants de type double ;
  • les références vers des objets, ce point dépend de la machine sur laquelle on se trouve.

Les écritures sur ces champs ne sont pas atomiques : écrire un long peut se faire en deux temps. En contexte fortement concurrent, un thread donné peut donc voir un long corrompu : ses 32 bits de poids faible écrits par un premier thread, et, pourquoi pas, ses 32 bits de poids fort écrits par un autre thread.

La JLS spécifie que si ces champs sont déclarés volatile, alors leur accès est atomique. Tous les champs codés sur 64 bits doivent donc être déclarés volatile dans le contexte de la programmation concurrente. Bien sûr, cela ne signifie pas que toutes les opérations sur ces champs sont automatiquement atomiques.

Object.wait(), Thread.sleep() et Thread.yield()

Ces méthodes interagissent également avec les verrous des moniteurs, raison pour laquelle j’en parle ici.

La méthode Object.wait() ne peut être appelée que par un thread qui possède le lock sur le moniteur de cet objet. Lorsqu’elle est invoquée, des actions unlock sont lancées par ce thread sur le moniteur de l’objet, autant que le thread avait lancé d’actions lock. Ce faisant, un autre thread pour entrer dans le bloc synchronisé dans lequel ce thread se trouvait.

Puis ce thread est placé dans une liste d’attente ce qui le retire de la liste des threads en attente du lock du moniteur de cet objet. Il peut en sortir de quatre façons.

  • À la fin de la durée qui a été passée en paramètre de l’invocation de wait().
  • Sur invocation de la méthode notify() sur cet objet, ou notifyAll(). Notons que l’invocation de notify() ne réveille qu’un seul thread de cette liste d’attente, pris au hasard.
  • Sur interruption.
  • De façon intempestive. La JLS prévoit une manière supplémentaire de sortir de cette liste, laissée à l’appréciation des implémenteurs de JVM.

Lorsqu’il reprend son activité, un thread qui était dans cette liste relance autant d’actions lock sur le moniteur de cet objet, qu’il en avait lancées au moment où l’appel à wait() avait été fait.

À la différence de Object.wait(),  Thread.sleep() met le thread courant en attente, sans libérer le moniteur que le thread courant peut éventuellement tenir. Thread.yield() passe la main à un autre thread. Il est à noté qu’aucune opération de synchronisation n’est effectuée lors de l’appel à ces deux dernières méthodes, en particulier aucune valeur de variable modifiée par ce thread n’est publiée auprès des autres threads.

L’exemple suivant, que l’on rencontre souvent, est donc buggé.

public class BuggySleep {

	public boolean done ;

	public void someMethod() throws InterruptedException {
		while (!this.done) {
			Thread.sleep(1000) ;
		}
		// reste du code
	}
}

Dans la mesure où le champ done n’est pas volatile, un thread qui viendrait le modifier n’a aucune garantie que le thread qui exécute someMethod() verra cette modification.

Conclusion

Comme nous l’avons vu, la synchronisation des actions en Java est bien plus qu’une simple exécution exclusive et atomique de blocs de code. Elle recouvre deux choses :

  • l’atomicité bien sûr ;
  • et la visibilité, au travers de la publication des valeurs modifiées auprès des autres threads.

Alors que la synchronisation recouvre ces deux choses, la volatilité ne recouvre que la visibilité, à l’exception des affectations de variables 64 bits, qui sont atomiques. Le support de la visibilité est également précisé pour les objets de synchronisation définis dans le package java.util.concurrent.

Références

Chapitre 17 de la JLS : http://java.sun.com/docs/books/jls/third_edition/html/memory.html

JSR 133 : http://www.jcp.org/aboutJava/communityprocess/review/jsr133/index.html (traite du Java Memory Model)

La FAQ de la JSR 133 : http://www.cs.umd.edu/users/pugh/java/memoryModel/jsr-133-faq.html

2 réflexions au sujet de « Synchronisation et volatilité »

  1. Les références 64 bits sont garanties d’avoir un comportement atomique même si elles ne sont pas volatiles.

    Le JMM dit :

    Writes to and reads of references are always atomic, regardless of whether they are implemented as 32 or 64 bit values.

    Deux lectures recommandables :
    * Java Concurrency in practice http://www.amazon.fr/Java-Concurrency-Practice-Brian-Goetz/dp/0321349601/
    * Memory Barriers: a Hardware View for Software Hackers http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf

Les commentaires sont fermés.