Affronter la concurrence sans peur avec Java

L’idée de cet article remonte à la conférence donnée par Alex Snaps lors de l’édition 2012 de Devoxx France et intitulée « Programmation concurrente en Java dans la pratique ». Initialement, je pensais uniquement développer un peu plus en profondeur certains des points clefs abordés au cours de cette présentation, en particulier le fonctionnement de l’instruction Compare-and-Swap (CAS). Mais, lecture après lecture, il m’est apparu une vision du fonctionnement de la machine virtuelle Java complètement différente de celle que je m’étais figuré jusqu’alors. Je vous propose donc de commencer par un rapide tour d’horizon du Java Memory Model avant d’évoquer le contenu de cette conférence.

Un tour d’horizon du Java Memory Model

Le Java Memory Model (JMM) est un élément fondamental de l’écosystème Java car il définit la manière dont les threads interagissent en mémoire ; plus concrètement, c’est ce document qui fixe la sémantique des threads et des mot clefs synchronized, volatile etc.

La dernière spécification du JMM est la JSR-133, finalisée fin 2004 et diffusée la même année avec la version 1.5 de la plate-forme Java. Pour la petite histoire, l’objectif de cette JSR était de corriger les nombreuses défaillances de la précédente spécification, mal maîtrisée par les développeurs et mal implémentée par la plupart des machines virtuelles Java de l’époque.

Ceux d’entre vous qui connaissent le sujet n’auront pas besoin d’être convaincus de l’intérêt de bien comprendre les points clefs du JMM ; pour les autres je vous propose une petite devinette. Soit le programme suivant, non synchronisé :

Est-il possible d’obtenir en sortie de ce programme i = 0 et j = 0 ? Quelques secondes de réflexion semblent indiquer qu’apparemment non car :

  • obtenir i = 0 signifierait que B a terminé son exécution avant que A ne l’ait commencé ;
  • obtenir j = 0 signifierait que A a terminé son exécution avant que B ne l’ait commencé.

Un tel résultat est pourtant possible, et ce pour deux raisons. La première est que, en l’état, les modifications effectuées par A (respectivement B) sont susceptibles de parvenir à B (respectivement A) après un délais plus ou moins long. L’absence de synchronisation a donc un effet sur la visibilité de ces opérations. La seconde raison, plus surprenante peut être, est que le compilateur, la JVM, ou même le processeur utilisé pour exécuter ce programme peuvent, pour des raisons d’optimisation, être amenés à modifier l’ordre d’exécution de ces instructions, et décider par exemple que A affectera d’abord la valeur de y à j avant d’écrire la valeur 1 dans x.

Bien sûr, les modifications réalisées dans l’ordonnancement des instructions ne sont pas censées affecter le sens du programme. On objectera que, dans cet exemple particulier, ce sens est différent puisqu’un résultat qui n’était pas attendu peut désormais apparaître. Mais c’est oublier que, sans indication de votre part, le compilateur comme la JVM n’ont aucun moyen de savoir que vous travaillez dans un environnement multi-threads et que des précautions particulières doivent par conséquent être prises avant de modifier l’ordonnancement de votre code. Il faut donc le leur indiquer, et c’est là que la JSR-133 entre en scène.

La relation happens-before.

Pour tous, synchronized désigne une zone d’exclusion mutuelle, autrement appelée section critique. Mais la sémantique de synchronized ne se limite pas à ce concept, elle permet également d’établir une relation particulière, appelée happens-before, entre des threads se synchronisant à partir d’un même moniteur. Cette relation nous garantie que toute action effectuée par un thread avant la libération d’un moniteur sera perçue par tout autre thread au moment ou celui ci fera acquisition de ce même moniteur (M dans le schéma ci dessous).

La relation happens-before est également utile pour comprendre le fonctionnement du mot clef volatile : toute écriture dans une variable volatile est équivalente à la libération d’un moniteur, et toute lecture de cette même variable est équivalente à l’acquisition de ce même moniteur. Une écriture suivie d’une lecture établit donc également une relation happens-before entre les deux threads effectuant ces tâches, vous assurant ainsi de toujours récupérer le dernier état connu d’une telle variable.

Par ailleurs, la lecture ou l’écriture d’une variable volatile ne peut pas être ré-ordonnée avec la lecture ou l’écriture d’une autre variable (volatile ou non depuis la version 1.5 du SDK, volatile uniquement avec les version antérieures). Le thread B du programme illustré dans le schéma ci-dessous affichera donc 5 ou rien, mais jamais 0.


Quelques bonnes pratiques…

Après ce rapide tour d’horizon du fonctionnement du Java Memory Model, nous pouvons désormais nous pencher sur les bonne pratiques de gestion de la concurrence. Les idées que je développe ici sont en partie tirées de la conférence d’Alex Snaps, et en partie issues de mes propres recherches sur le net (des références sont disponibles en fin d’article).

Astuce n°1 : affinez la granularité de vos verrous.

Une première règle de bon sens mais à laquelle on ne pense pas toujours : évitez d’utiliser un même moniteur pour réguler l’accès à des variables indépendantes. Ainsi, au lieu d’écrire :

private long countAdd = 0; private long countDelete = 0; public synchronized void add() { countAdd++; } public synchronized void delete() { countDelete++; } public synchronized long getCountAdd() { return countAdd; } public synchronized long getCountDelete() { return countDelete; }

Préférez cette version ci, bien plus performantes :

private Object addMonitor = new Object(); private Object deleteMonitor = new Object(); private long countAdd = 0; private long countDelete = 0; public void add() { synchronized (addMonitor) { countAdd++; } } public void delete() { synchronized (deleteMonitor) { countDelete++; } } public long getCountAdd() { synchronized (addMonitor) { return countAdd; } } public long getCountDelete() { synchronized (deleteMonitor) { return countDelete; } }

Astuce n°2 : utilisez des verrous de plus haut niveau et plus performants avec la JSR-166.

Le coût en performance de l’appel à synchronized n’a cessé de diminuer grâce à l’évolution continue de la JVM. On peut même dire aujourd’hui que ce coût est négligeable en situation de faible contention, c’est à dire lorsque que peu de threads sollicitent en même temps le même verrou. Des progrès restent toutefois à réaliser dans les situations de forte contention.

Dans de telles conditions, il peut être intéressant d’utiliser certaines des structures de haut niveau mises à disposition avec la JSR-166, et en particulier la classe ReentrantLock. Fondamentalement, le fonctionnement de ReentrantLock est très semblable à celui de synchronized; il présente toutefois des fonctionnalités supplémentaires, comme la possibilité de configurer un timeout pour l’acquisition du verrou, ou bien de résoudre les problèmes de starvation. Plus intéressant encore : la performance des ReentrantLock souffre moins des situations de forte contention que synchronized, et ce grâce à l’utilisation d’une instruction particulière : Compare-and-Swap.

Astuce n°3 : écrivez des algorithmes non-bloquant avec Atomic.

L’utilisation de verrous dans un algorithme a pour inconvénient de bloquer l’exécution de vos threads, le plus souvent temporairement, indéfiniment dans le pire des cas (situation de deadlock). Aussi, l’élaboration de structures de données et d’algorithmes non-bloquants est aujourd’hui un sujet majeur de la recherche en informatique, et la plupart des processeurs modernes disposent désormais de fonctionnalités permettant d’exécuter de manière sécurisée et non bloquante des instructions en situation de concurrence. C’est notamment le cas de Compare-and-Swap (CAS), une instruction bas niveau prenant en compte trois opérandes (1. l’adresse mémoire d’une variable (var), 2. la valeur supposée de cette variable (x), 3. la valeur qu’on l’on souhaiterait lui affecter (y)) et dont voici le principe de fonctionnement :

Avec son package java.util.concurrent.atomic, Java met à notre disposition des outils qui tirent profit de l’instruction CAS pour concevoir des algorithmes non bloquant. Il est désormais possible d’appliquer une fonction à une variable et d’y inscrire le résultat sans utiliser de verrou :

private AtomicLong atomicLong = new AtomicLong(0); public void compute() { long value; boolean stop = false; do { /* le fonctionnement d'atomicLong repose en partie sur l'utilisation d'une variable volatile long. la valeur retournée par atomicLong.get() est donc la dernière valeur connue. */ value = atomicLong.get(); // compareAndSet retourne true si la valeur encapsulée dans AtomicLong n'a pas été modifié depuis la dernière lecture stop = atomicLong.compareAndSet(value, fonction(value)); } while (!stop); // on itère tant que compareAndSet échoue } public long fonction(final long value) { // ... }

Conclusion

S’il n’y avait qu’une seule chose à retenir de cet article, n’oubliez pas que, en plus de délimiter des zones d’exclusion mutuelle, la synchronisation affecte également :

  • l’ordre dans lequel vos instructions sont exécutées ainsi que la visibilité de ces opérations ;
  • les optimisations mises en œuvre par le compilateur.

Pour finir, voici les différentes ressources qui m’ont été utiles pour écrire cet article :