Introduction à la programmation concurrente en Java (2/2)

5

Dans un précédent article, nous avons vu comment lancer plusieurs threads pour exécuter des traitements concurrents en Java, manuellement ou via le framework Executor.

Dans cet article, nous étudierons les problèmes qui se posent lorsque plusieurs threads tentent d’accéder simultanément à une ressource, ainsi que quelques techniques simples pour les résoudre.

La problème des accès concurrents

Le problème

Avant toute chose, il faut se rappeler que les différents threads s’exécutant au sein d’une application sont autorisés à accéder, en lecture comme en écriture, à toutes les données présentes dans l’espace mémoire réservée par cette application.

A cause de cet accès libre et de l’exécution concurrente des threads, il peut arriver que deux d’entre eux tentent d’accéder simultanément à une même donnée ; on parle alors de race condition.

Des accès simultanés en lecture seule sont évidemment inoffensifs. Mais deux problèmes fondamentaux surviennent lors des accès en écriture :

  • Un problème de visibilité : la modification apportée par un thread est-elle immédiatement visible par les autres threads ?
  • Un problème de cohérence : que se passe-t-il en cas de modification concurrente ? Est-ce le dernier thread qui écrit qui gagne, ou les données risquent-elles d’être corrompues ?

Ces problèmes sont en réalité les mêmes que ceux posés par le partage d’un fichier sur un réseau : que se passe-t-il si plusieurs personnes tentent de l’éditer simultanément ? Les modifications sont-elles immédiatement visibles par tous les participants ? Et si une personne effectue une copie locale du fichier au lieu de l’éditer à distance ?

Corruption des données

Pour démonter le risque de corruption des données en environnement multithreadé, prenons l’exemple d’un simple compteur. Notez que les méthodes increment(), decrement() ou getValue() lisent ou modifient la valeur du champ value, qui est donc notre donnée partagée.

Pour tester le comportement de ce compteur, le test ci-dessous lance NB_THREADS threads qui effectuent chacun NB_INCREMENTS appels à la méthode increment(). A la fin du test, on s’attend donc à ce que la valeur du compteur soit de NB_THREADS * NB_INCREMENTS.

Exemple : avec 5 threads et 100 incréments, le compteur vaut normalement 500.

Le test est répété plusieurs fois afin de vérifier sa stabilité.

Avec 1 thread, tout se passe bien :

Tentons avec 2 threads :

Le résultat n’est visiblement pas celui que l’on espérait. Non seulement la valeur obtenue n’est pas correcte, mais elle varie à chaque test ! Comment expliquer ce comportement ?

Explication

Plusieurs facteurs expliquent les résultats surprenants obtenus plus haut.

Indiana Jones and the Lost Updates

Premièrement, certaines mises à jour ont été malencontreusement “écrasées”.
Cela est dû au fait que, malgré les apparences, l’opération ++ utilisée pour incrémenter le compteur n’est pas atomique. Elle consiste en réalité en 3 opérations successives :

  • lecture de la valeur actuelle en mémoire
  • incrémentation de la valeur
  • écriture de la nouvelle valeur en mémoire

La séquence suivante peut alors se produire :

  • Un thread A lit la valeur actuelle (ex: 42) puis est mis en pause par le processeur ;
  • Un thread B lit également la valeur 42, l’incrémente, puis écrit 43 en mémoire ;
  • Le thread A se réveille, incrémente la valeur qu’il a lue et écrit également 43 en mémoire.

Une mise à jour a donc été oubliée au passage, ce qui explique (en partie) les valeurs incorrectes obtenues lors du test. La littérature anglophone parle de “lost updates“.

La solution à ce problème consiste à s’assurer que les trois opérations seront toujours exécutées de manière atomique, c’est-à-dire que leur séquence sera toujours exécutée en intégralité et sans interruption de la part d’autres threads.
Les moniteurs et les locks de Java donnent justement cette garantie.

Cachez cette valeur que je ne saurais voir

La deuxième source de corruption vient du fait que chaque thread est autorisé, sous certaines conditions, à mettre en cache certaines valeurs dans des registres locaux plutôt que de les lire ou écrire dans la mémoire commune. On touche donc ici à un problème de visibilité et de fraîcheur des données.

La JVM est s’autorise ce genre de manipulation pour des raisons de performances : il est toujours plus rapide d’accéder à un registre de thread qu’à la mémoire commune.

Là encore, l’emploi judicieux de moniteurs ou de locks permet de garantir la bonne visibilité mémoire des données partagées.

Contrôler l’accès aux ressources

Voyons maintenant comment utiliser les mécanismes que Java met à notre disposition pour garantir l’atomicité des opérations et la fraîcheur des données.

La solution la plus simple est souvent la plus sûre et la plus maintenable (à défaut d’être la plus performante). Puisque le problème vient de l’accès concurrent à la donnée, supprimons l’accès concurrent ! Ou du moins contrôlons-le, par exemple à l’aide de verrous.

Java propose deux types de verrous, les moniteurs et les locks. Si leur syntaxe d’utilisation diffère, leur principe de fonctionnement est identique : un thread souhaitant accéder à une donnée partagée doit préalablement obtenir le verrou qui la protège, et le libérer ensuite.

Pour chaque donnée exposée à une utilisation concurrente, il convient donc de définir (et de documenter[1] !) quel verrou la protège, puis de s’assurer que tous les accès à cette donnée (en lecture comme en écriture) sont convenablement protégés.

Maintenant, mettons toute cette théorie en pratique.

Les moniteurs

Tout d’abord, voyons les moniteurs, également appelés “verrous implicites” ou “verrous intrinsèques”.

Chaque instance d’objet Java possède un moniteur, sur lequel on peut décider de se baser pour protéger une donnée. Attention, ce moniteur n’est pas un champ de l’instance ; c’est plutôt une de ses propriétés intrinsèques.

Mode d’emploi

Le mot-clé synchronized permet de demander l’acquisition d’un moniteur donné pour la durée d’un bloc de code :

Une question se pose alors : quel moniteur utiliser ?

Une bonne pratique générale consiste à séparer les données métiers des données techniques. Dans une table de base de données par exemple, la colonne servant de clé primaire est souvent une clé purement technique.

Le même raisonnement s’applique avec les moniteurs : il est fréquent de créer une instance d’un quelconque objet (généralement un simple Object) dans l’unique but de disposer de son moniteur.

Avantage additionnel, le nom de cette instance technique permet d’expliciter son rôle. Par exemple, le verrou protégeant la donnée clientList peut être appelé clientListMonitor.

Passons à la mise en oeuvre.
Pour sécuriser notre compteur, nous utiliserons une instance technique appelée valueMonitor, déclarée private et final.

L’exécution du test avec 5 threads donne cette fois le résultat attendu, et de manière reproductible :

Syntaxe alternative

Note : il existe également une syntaxe alternative, que personnellement je déconseille : l’utilisation du mot-clé synchronized directement dans la signature des méthodes :

Si cette syntaxe vous est sans doute plus familière, elle présente beaucoup d’inconvénients :

  • Le moniteur utilisé est celui de l’instance courante de la classe, c’est-à-dire un objet utile à l’application
  • Le moniteur n’est pas nommé, donc peu explicite
  • Le moniteur est acquis pendant toute la durée de la méthode, même si 90% de son code n’en a pas besoin

Utilisez donc plutôt un moniteur explicite !

Avantages et limitations

Les moniteurs présentent deux avantages :

  • Ils sont simples à utiliser,
  • Il est impossible d’oublier de libérer le verrou : la sémantique du langage le garantit, à la sortie du bloc protégé.

Mais ils présentent également un inconvénient majeur : un thread en attente d’un moniteur est bloqué jusqu’à ce qu’il l’obtienne. Impossible d’abandonner après un certain temps, d’être prévenu par un autre thread que ce n’est plus la peine d’attendre (interruption), de simplement tester l’état du moniteur avant d’essayer de le verrouiller…

Les moniteurs doivent donc être utilisés avec beaucoup de précaution, pour éviter de provoquer des deadlocks irrattrapables.

Les locks

Pour pallier les problèmes des moniteurs, Java 5 propose dans son package java.util.concurrent.locks une nouvelle variété de verrous : les locks, représentés par l’interface Lock.

Mode d’emploi

Le principe d’utilisation d’un lock est exactement le même que celui d’un moniteur : il faut le verrouiller avant d’accéder à la ressource partagée, puis le déverrouiller ensuite.

Par contre, sa syntaxe est plus verbeuse, car il n’existe pas de support natif des locks au niveau du langage même.

L’usage canonique est le suivant :

Mettons à jour notre compteur pour utiliser un lock au lieu d’un moniteur :

L’exécution du test avec 5 threads produit évidemment le même résultat qu’avec un moniteur.

Fonctionnalités avancées

Pour le moment, les locks semblent moins pratiques que les moniteurs ; leur syntaxe est plus verbeuse, et le risque d’erreur est plus élevé. Toute leur puissance réside en fait dans leurs fonctionnalités avancées, qui les rendent indispensables sur des projets complexes.

Read/Write Lock

Tout d’abord, étudions le ReadWriteLock.

Cette classe part du constat qu’il n’est pas dangereux de laisser plusieurs threads accéder en lecture seule à une ressource : en l’absence de toute de modification, il est impossible de corrompre la donnée. Dans ces conditions, imposer l’acquisition d’un verrou exclusif est donc inutile, et ne ferait que brider artificiellement les performances du système.
En revanche, toute opération de modification doit être réalisée sous la protection d’un verrou exclusif.

L’interface ReadWriteLock, et son implémentation ReentrantReadWriteLock, propose donc de différencier les deux modes d’accès grâce à deux sous-verrous : un pour la lecture (readLock), et un pour l’écriture (writeLock). Le premier peut être acquis par plusieurs threads simultanément, alors que le second est exclusif (et exclusif avec les verrous en lecture également).

Dans notre exemple, la méthode getValue() est une opération de lecture : elle peut donc être protégée par un readLock. La méthode increment(), au contraire, modifie la valeur du compteur, et doit donc être réalisée de manière atomique ; elle sera donc protégée par un writeLock.

L’utilisation d’un ReadWriteLock dans notre exemple n’améliore pas significativement ses performances, car nous appelons principalement la méthode increment(). En revanche, une structure de type cache, utilisée à 90% en lecture et 10% en écriture, verrait sa scalabilité considérablement améliorée.

Lock avec timeout

Le plus grand danger auquel font face les applications multithreadées est le fameux, terrible deadlock.

Cette situation fort déplaisante survient lorsque deux (ou plus) threads tentent chacun d’obtenir un verrou déjà possédé par l’autre. Ces threads se bloquent alors mutuellement, et ne peuvent être débloqués que par un arrêt de la JVM – un peu gênant en production…

Pour éviter cela, les locks proposent une variante de l’opération de verrouillage, permettant de définir un délai maximal d’attente (timeout) : tryLock().
Cette méthode tente d’obtenir le verrou, mais renonce après un certain temps, évitant ainsi de rester bloqué indéfiniment.

Notez la présence d’un bloc try/finally assurant la libération du verrou en toutes circonstances.

Pour illustrer son utilisation, je vous propose un exemple légèrement plus complexe ; prenez le temps de l’étudier !

L’application ci-dessous demande son nom à l’utilisateur, puis attend une saisie sur l’entrée standard (System.in). Un thread tourne en arrière-plan, et vérifie périodiquement si un nom a été saisi ; dans la négative, il demande à l’utilisateur de se dépêcher.

Afin de réagir immédiatement à la saisie du nom de l’utilisateur (et non après un certain temps), un lock est utilisé. Celui-ci est détenu par le thread principal, et relâché dès que la saisie est effectuée. Le thread de vérification, quant à lui, utilise tryLock() pour patienter deux secondes entre chaque relance.

Ce qui donne :

Avantages et limitations

Les locks offrent des fonctionnalités uniques qui permettent de déjouer certains problèmes de deadlock. Utilisés à bon escient, ils peuvent rendre les applications concurrentes considérablement plus robustes et performantes.

Toutefois, leur syntaxe plus verbeuse et l’absence de support natif par le langage, notamment pour le déverrouillage automatique, peut augmenter les risques d’erreurs.

Ma recommandation est donc de privilégier les moniteurs en première approche, et de passer aux locks lorsque leurs fonctionnalités avancées peuvent faire la différence.

Conclusion

Dans cet article, nous avons vu qu’autoriser l’accès concurrent à une ressource pouvait mener à la corruption de celle-ci.

Pour résoudre ce problème, Java propose deux mécanismes de verrouillage : les moniteurs (synchronized) et les locks (dans java.util.concurrent.locks.Lock). Nous avons vu le mode d’emploi ainsi que les avantages et inconvénients de chaque solution.

Quelques ressources pour aller plus loin :

Note

[1] Par exemple à l’aide des annotations proposées sur le site du livre Java Concurrency In Practice : http://jcip.net/

Partagez cet article.

A propos de l'auteur

5 commentaires

  1. Hello,

    Article intéressant. Merci

    Est ce que le sujet de confinement des données aurait pu être abordé ici?
    C’est une technique assez classique permettant de gérer l’accès concurrent sur des données dans un environnement multi threadé.

  2. Je me suis volontairement limité à une introduction aux principes essentiels de la programmation concurrente en Java. Les aspects plus évolués (performance, design patterns, techniques diverses) sont plus du ressort d’une formation ou d’un livre (voir les ressources en bas de l’article).

  3. Il me semble avoir déjà vu des synchronized(this) {} au lieu d’un synchronized (unAutreObjet) {}, est-ce qu’il y a des avantages/inconvénients par rapport à la méthode que tu utilises ?

  4. synchronized(this) revient presque au même que de déclarer la méthode “synchronized” : on utilise le même moniteur, mais synchronized(this) permet de ne verrouilller qu’une portion de code au lieu de toute la méthode.

    Quoi qu’il en soit, c’est déconseillé puisqu’on utilise un moniteur associé à une classe “utile” et surtout visible de toute l’application (risque que qu’une autre portion de code l’utilise également comme moniteur).

Ajouter un commentaire