Il y a 15 ans -
Temps de lecture 7 minutes
Programmation concurrente : notions fondamentales
Jouer avec les Threads n’est pas trivial. En informatique de gestion, cette difficulté est heureusement masquée par les serveurs d’application et les API spécifiques. La plupart du temps, ils permettent aux développeurs de s’abstraire de ces contraintes et de se concentrer sur le code métier, moins technique. Il arrive pourtant qu’il faille se relever les manches. Certains besoins vous ont certainement déjà poussé à faire communiquer 2 Threads.
Si le développement n’est pas facile, le debug peut devenir une calamité ! La programmation concurrente ne soulève pourtant que 3 types majeurs de problèmes. Faites le sondage autour de vous, les développeurs associent trop rapidement le mot clé synchronize
au multithreading sans en comprendre véritablement le fonctionnement. Demandez-leur ensuite de vous décrire l’utilité du mot clé volatile
…
Cet article revient sur les grands principes de la programmation concurrente :
- Atomicité, indivisibilité des actions
- Visibilité, rendre les effets d’un thread visibles dans un autre
- Ordonnancement, ordre d’exécution du code
Atomicité
L’atomicité est la notion la plus connue de la programmation concurrente, pourtant elle reste l’une des principales causes de bugs des applications multithreadées. L’atomicité d’une opération désigne une action ou un ensemble d’actions que l’on exécute d’un seul coup. Il ne faut donc pas s’y tromper, une seule ligne de code peut ne pas garantir l’atomicité de l’action désirée, comme nous le verrons par la suite. Afin de garantir l’atomicité d’une partie du code, l’utilisation de locks est souvent nécessaire. Ils permettent de garantir l’exclusion mutuelle assurant ainsi le bon comportement du code.
Dans le code suivant, bien que les accès en lecture et en écriture de la variable d’instance solde
soient contrôlés par des locks, le code n’est pas bien synchronisé pour autant.
public class GestionDeCompteBugguee { // tous les accès sont synchronisés : getter & setter private int solde; // getter synchronisé public synchronized int getSolde() { return solde; } // setter synchronisé public synchronized void setSolde(int valeur) { solde = valeur; } // montant déposé à la banque à ajouter sur le compte void depotSurLeCompte(int montant) { int b = getSolde(); setSolde(b + montant); } // montant à retirer du compte void retraitDuCompte(int montant) { int b = getSolde(); setSolde(b - montant); } }
Deux threads sont chargés de la mise à jour du solde
, l’un effectuant des dépôts et l’autre des retraits. Si l’on effectue un dépôt équivalent à un retrait, nous attendons que le solde final soit identique au solde initial. En utilisant l’exemple précédant, ce n’est pas toujours le cas.
Imaginons par exemple que nous débutions une série d’opérations avec un solde de 100 :
- le thread 1, pour faire son dépôt 50, commence par récupérer la valeur du solde : 100
- le thread 2, pour effectuer son retrait de 25, commence également par récupérer la valeur courante du solde : 100 ; puis, il met à jour le solde en conséquence. À la fin de l’opération, le solde ne vaut donc plus que 75.
- le thread 1 effectue ensuite la mise à jour liée au dépôt, en utilisant alors la mauvaise valeur pour le solde. À la fin de l’opération la valeur du solde sera de 150 au lieu de 125.
Les locks ont été positionnés sans tenir compte de l’atomicité des opérations. La mise à jour du solde est un échec, un appel au retrait ayant été ‘perdu’. Ce n’est pas parce qu’un code est synchronisé qu’il fonctionne comme il est appelé à fonctionner.
Dans notre exemple, il suffit de synchroniser les méthodes depotSurLeCompte
et retraitDuCompte
pour les rendre atomiques.
Dans des cas plus complexes, la solution n’est pas forcément aussi triviale.
De la même manière, une action aussi simple que l’incrément d’un entier n’est pas atomique x += 1
. Bien que cela ne soit pas aussi flagrant que dans notre exemple, il s’agit en fait exactement du même problème. Pour effectuer ce genre d’opération en contexte multihreadé, le jdk nous propose d’utiliser des objets assurant out-of-the-box l’atomicité des ces opérations. Par exemple, AtomicInteger
nous permet d’effectuer des incréments et des décréments en toute sécurité.
Visibilité
La visibilité décrit le mécanisme par lequel le résultat d’une action effectuée par un thread peut être ‘surveillée’ par un autre. Cette ‘observation’ implique une autre forme de synchronisation.
Nous ne parlons pas nécessairement du mot clé synchronize
(qui implique la notion de lock), mais plutôt des mots clés permettant d’assurer la visibilité et l’ordonnancement : final
et volatile
.
Pour mieux comprendre le problème, utilisons l’exemple de code ci-dessous :
public class BouclePotentiellementInfinie { private boolean termine = false; // appelé par un thread 1 public void work() { while (!termine) { // do stuff } } // appelé par un thread 2 public void stopWork() { termine = true; } }
Imaginons que ce code soit utilisé par deux threads différents. Le premier appelle la méthode work()
effectuant une boucle. La sortie de cette boucle est conditionnée par l’appel de la méthode stopWorking()
réalisée par un second thread.
Cet exemple fonctionne-t-il comme attendu? Pas toujours !
En effet, comme il n’y a aucun mécanisme de synchronisation entre les deux threads, nous ne sommes pas assurés que le premier thread soit ‘averti’ des mises à jour de la variable termine
. La JVM effectue de manière transparente pour les développeurs de petites optimisations sur le code pour le rendre plus efficace.
Comme la valeur de la variable termine
n’est pas modifiée à l’intérieur de la boucle, le compilateur peut mettre sa valeur en cache pour éviter d’aller la charger inutilement à chaque itération. Cette boucle se transforme en boucle infinie.
Pour parer à ce problème, il faut ajouter au code précédent un mécanisme de synchronisation. Par exemple en déclarant la variable termine
volatile
. Ce modificateur force la JVM à relire les valeurs des variables au sein de la mémoire partagée à chaque fois que quelqu’un y accède.
Ordonnancement
L’ordonnancement du code est la troisième grande cause des problèmes observés en programmation concurrente. Cela peut paraitre étrange au premier abord, mais l’ordre d’exécution d’un programme Java ne correspond pas forcément à l’ordre strict des ses lignes de code.
public class OrdreExecutionNonAssure { private boolean a = false; private boolean b = false; // appelé par un thread 1 public void threadOne() { a = true; b = true; } // appelé par un thread 2 public boolean threadTwo() { boolean lecture1 = b; // true boolean lecture2 = a; // false !! boolean lecture3 = a; // true } }
L’ordre d’exécution n’est garanti qu’en contexte synchronisé. Dans l’exemple précédent, vous pouvez observer le comportement étrange suivant : le deuxième thread voit false
à la variable a
alors qu’il a vu précédemment true
pour la variable b
. Ici encore, en absence de synchronisation, le compilateur s’autorise à réorganiser le code comme bon lui semble (dans la limite du possible). Pour corriger le problème vous devez déclarer les deux méthodes synchronisées et déclarer les deux variables volatile
.
Conclusion
Nous arrivons à la fin de cette introduction à la programmation concurrente Java. Celle-ci sera certainement le point de départ d’une série dans laquelle nous verrons des cas réels d’utilisation.
Vous avez peut-être remarqué que j’ai essayé de parler le moins possible du mot-clé synchronized
au profit de termes plus génériques. Depuis l’arrivée du jdk5, celui-ci est de moins en moins utilisé, voire laissé à l’abandon. Là où les blocs synchronisés contraignaient à un accès séquentiel de certaines portions de code, java.util.concurrent
nous propose un mécanisme différent qui augmente le taux de parallélisme tout en maximisant le débit. D’une manière générale, il est préférable d’utiliser ce genre de lock pour réduire les contentions.
Pour résumer les points clefs abordés dans ce billet :
- La programmation concurrente, c’est compliqué
- Le mot clé
volatile
assure la visibilité d’une variable, le locking est plus vaste : il englobe la visibilité, l’atomicité et l’ordonnancement.
Commentaire
8 réponses pour " Programmation concurrente : notions fondamentales "
Published by Maître Cappello , Il y a 15 ans
Intéressant article, qui mériterait d’être intitulé ‘Programmation concurrente’.
http://fr.wikipedia.org/wiki/Programmation_concurrente
D’après mon interprétation du Petit Robert, ‘concurrentiel’ tend à signifier ‘compétitif’, avec une notion économique ?
Published by Erwan Alliaume , Il y a 15 ans
Si même ce vieux Robert est contre moi …
D’après mon grimoire, l’adjectif « concurrent » désigne un objet qui rentre en concurrence avec une autre alors que « concurrentiel » désigne quelque chose de relatif à la concurrence. Il me semblait donc correct d’utiliser « programmation concurrentielle » comme terme générique pour décrire les problèmes d’interactions entre « processus concurrents » puisque ce ne n’est pas programmation qui est concurrente, mais les processus qui la composent. Quoi ? De la mauvaise foi ? Non, jamais :) Du coup j’ai corrigé l’article, merci.
Published by Sanlaville , Il y a 15 ans
Article très intéressant effectivement. Merci.
Juste pour signaler une petite typo dans la section Visibilité
[code] public void work()
[texte] Le premier appelle la méthode doItNow()
Published by Erwan Alliaume , Il y a 15 ans
Décidément !
La coquille est corrigée, voila ce qu’il arrive lorsqu’on refactor sans faire de tests de non-régression :)
Merci.
Published by Cyril Gambis , Il y a 15 ans
Intéressant!
C’est une bonne base; les mécanismes d’accès concurrents aux données et de synchronisation de process sont trop méconnus de la plupart des programmeurs.
volatile est un mot clé important, mais méconnu. Cela provient en partie du fait que son comportement était buggé dans certaines JVM (ou carrément pas implémenté), et que le modèle mémoire pré-JDK 1.5 était défaillant. Depuis Doug Lea et la ré-écriture de cette partie pour Java 5, tout est rentré dans l’ordre.
Autour de « volatile », il y a aussi cet article: http://pitfalls.wordpress.com/2008/05/25/javavolatile/
Published by Amel , Il y a 10 ans
Dans la classe GestionDeCompteBugguee, même en synchronisant les 2 méthodes « depotSurLeCompte » et « retraitDuCompte » l’atomicité ne sera toujours pas garantie, vue que les deux threads exécutent 2 méthodes différentes au même instant T0. merci de me dire si je me trompe ou pas.