Blog Arolla

De Runnable et synchronized à parallel() et atomically() à Devoxx

Le jeudi 18 avril, c’est le premier jour de la première édition de Devoxx France qui démarre (Devoxx sous la tour Eiffel) ! C'est historique pour la communauté Java de France. Et j'y suis avec d'autres consultants Arolla. Le fait d'avoir plusieurs talks intéressants sur le même créneau horaire rend le choix parfois difficile.

Je commence par une université (session de 3 heures) de José Paumard, maître de conférence à Paris 13, indépendant et vieux routard de la programmation. Le sujet de la conférence est la programmation concurrente sur la JVM. C’est très dense et je vais essayer d'en retracer ici les grandes lignes.

Comment tirer parti de la puissance des processeurs ? Quels sont les impacts des processeurs multi-cœurs sur notre façon d'écrire du code ? Quels challenges nous pose le développement d'applications parallèles ? Voici quelques questions auxquelles le talk José a apporté des éléments de réponse.

La concurrence sur la JVM aux premières heures de Java

A ses débuts en 1995 (JDK 1), l'API proposée par Java pour faire de la concurrence était assez peu élaborée. Elle comportait les éléments suivants entre autres : l'interface Runnable, les mots-clés synchronize et volatile et la classe Thread. Chaque objet disposait d'un moniteur. Les problématiques de multithreading qui prévalaient à cette époque sont différentes de celles de nos jours. Les processeurs étaient pour la plupart mono-coeurs et le multithreading consistait au découpage en slots du temps de calcul du processeur. Ce qui signifie qu'à un moment donné, seul un thread s'exécute puis il est mis en attente, un autre s'exécute ainsi de suite. Les causes pour lesquelles un thread peut être mis en attente sont diverses. Par exemple il est en attente de la disponibilité d’un autre thread, de la fin d’un traitement effectué par un autre ou afin de permettre à un autre d’utiliser le temps de la CPU. Que se passait-t-il quand les « performances » des applications n’étaient pas à la hauteur ? La réponse était... “acheter une machine avec un processeur plus puissant”. L'augmentation continue de la fréquence des processeurs rendait cela possible (cf. la loi de Moore). Cette réalité serait la cause du peu de changement de l'API de concurrence en Java jusqu'en 2004.

La stagnation de l’augmentation des fréquences et l'arrivée des processeurs multi-coeurs

Mais... les choses ont commencé à "se gâter" avec la stagnation de la fréquence des microprocesseurs. Ce fût la fin de «la soupe gratuite ». Désormais, l’augmentation de la puissance de calcul passe par la multiplication du nombre de coeurs ou l’usage du processeur graphique (GPGPU). Aujourd’hui, il existe des processeurs grand public possédant jusqu’à 8 coeurs tandis que certains processeurs professionnels ont jusqu’à 128 coeurs. Cette nouvelle donne change la façon dont nous écrivons des programmes. Java 5 met à disposition du développeur des API de concurrence plus avancées, et groupées dans le package java.util.concurrent. Mais, avant d’explorer en détails ces API, José nous parle de certains problèmes liés au multi-threading.

Quelques problèmes typiques du multi-threading

Les problèmes liés au multi-threading abordés lors de la présentation de José :

  • Les data races : Une “data race” arrive lorsque deux processus qui s’exécutent en même temps produisent un résultat différent selon leur entrelacement. Dans ce genre de situation, si on ne fait pas attention, les choses peuvent très mal se passer. L’exemple du Singleton a été utilisé pour illustrer le propos. On peut par exemple se retrouver avec deux instances d’une classe qui est censée n'en avoir qu’une seule. Le présentateur a écrit un post très détaillé sur le sujet sur son blog. En résumé les solutions aux “data races” sont la synchronisation (mot-clé synchronize) ou l’immutabilité des variables.

  • La visibilité des modifications : Les processeurs utilisent des caches (registres) pour stocker les données sur lesquelles ils sont en train d’effectuer des opérations, améliorant ainsi les performances. Mais cela peut avoir des conséquences dans un programme en multi-thread. Prenons par exemple le cas d’une donnée partagée par deux threads qui s’exécutent sur deux coeurs. Chaque coeur peut copier la donnée dans son cache local. Ainsi, les modifications effectuées par chacun sur sa version locale de la donnée peuvent ne pas être visibles immédiatement de l’autre. Les mots-clés volatile et synchronize apportent la solution à ce problème. En effet, une variable annotée avec volatile n’est pas cachée par le processeur et toute lecture de la variable renvoie à la dernière valeur écrite dans la variable. De même, les modifications effectuées dans un bloc synchronize sont visibles à la sortie de ce bloc.

  • Le deadlock : Un autre problème pouvant survenir en programmation concurrente est le deadlock ou l'“étreinte fatale” pour les plus poètes d’entre nous. Cela arrive lorsque deux threads s’attendent mutuellement. La conséquence est que chacun se retrouve bloqué. Le sujet du deadlock est assez compliqué. Si vous souhaitez en savoir davantage, visualisez la conférence (intitulée "Deadlock Victim") d’Olivier Croisier et de Heinz Kabutz sur parleys.com.

A partir de tout ça, comment crée-t-on un thread ?

Création et démarrage d’un thread

La première chose à faire est de définir la tâche en implémentant l’interface Runnable.

public class Task implements Runnable {
  public void run() {
    //Do something here.
  }
}

Ensuite, on crée un objet de type Thread en lui passant une instance de la classe Task puis, on le lance en invoquant la méthode Thread#start().

Thread thread = new Thread(new Task());
thread.start();

Il y a mieux que la méthode Thread#start() pour lancer un thread : java.util.concurrent.ExecutorService.
L’ExecutorService s’adapte à la machine et s’occupe de la création des threads contrairement à la gestion manuelle par la méthode Thread#start(). Cela donne :

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
....
ExecutorService executorService = Executors.newCachedThreadPool();
Task task = new Task();
executorService.submit(task);

java.util.concurrent.Executors est une classe Factory qui met à disposition plusieurs méthodes pour créer des ExecutorService en fonction de votre besoin.
Jusque-là, nous avons défini les tâches avec l'interface Runnable qui dispose d’une unique méthode qui ne renvoie aucune valeur. Si vous souhaitez ne créer qu'une tâche qui renvoie une valeur, il existe une interface pour cela : java.util.concurrent.Callable.
Voici la définition de la tâche :

import java.util.concurrent.Callable;

public class CallableTask implements Callable<Integer> {
  public Integer call() throws Exception {
    int result = 0;
    // Do something hard and store the value in result variable.
    return result;
  }
}

Pour la lancer, on se sert comme précédemment de l’ExecutorService :

import java.util.concurrent.Future;
...
Future<Integer> resultFuture = executorService.submit(new CallableTask());

Lorsque l’on soumet à l’ExecutorService une tâche de type Callable, nous obtenons en retour un objet de type Future. C’est un objet qui porte bien son nom car il contiendra le futur résultat qui sera obtenu à l’issue de l’exécution de la tâche. La méthode bloquante Future#get permet de récupérer le résultat :

int result = resultFuture.get();

Vous savez désormais créer et lancer un thread ! Il est temps d’aborder les autres APIs que José a présentées.

Quelques APIs de concurrence de Java

Voyons quelques classes de concurrence disponibles en Java depuis la version 5 :

  • Lock : Un lock permet de contrôler l’accès à une ressource partagée par plusieurs threads. L’interface commune aux différentes implémentations est  Lock. Ce type de verrou permet plus de contrôle que le verrou intrinsèque disponible sur tout objet Java.
  • Semaphore : Un sémaphore est un objet qui permet de contrôler l’accès à un nombre N de ressources.

  • Barrier : Encore une autre classe intéressante et pratique dans certaines situations : CyclicBarrier. Un objet de ce type permet à un ensemble de threads de se rencontrer à un point donné comme par exemple à la fin de plusieurs traitements. Il est possible d’implémenter la même fonctionnalité avec un CountDownLatch.

  • Atomic(Long, Float, …) : Les classes groupées dans le package java.util.concurrent.atomic permettent de faire des opérations atomiques basées sur l’instruction “compare and swap”. Je vous invite à explorer ce package pour en savoir davantage.

  • BlockingQueue :  L’interface BlockingQueue simplifie grandement l’application du pattern consommateur/producteur (cf. la javadoc pour un exemple simple).

  • CopyOnWriteArrayList : Une autre structure de données astucieuse introduite en Java 5. L’idée est la suivante : les lectures ne sont pas synchronisées contrairement aux opérations qui modifient la collection. Pour ces dernières, une copie du tableau sous-jacent est faite. Ce qui a pour conséquence de rendre l’usage d’une CopyOnWriteArrayList coûteux dans les cas où les modifications sont nombreuses.

Les alternatives à la synchronisation

La deuxième partie de la conférence était consacrée aux alternatives à la concurrence basée sur la synchronisation (synchronize).  Commençons par la mémoire transactionnelle logicielle plus connue sous le doux nom de STM (Software Transactional Memory).

  • La STM : La STM se base sur la notion de transaction comme on en a avec les bases de données pour gérer l’accès concurrent à des ressources partagées. La donnée partagée est encapsulée dans une référence. Les références sont modifiées dans une transaction. Deux scénarios possibles :

  1. Si la transaction échoue (à cause d’une collision par exemple), elle est relancée.

  2. Si la modification est réussie, la transaction est validée.

Parmi les points séduisants de la STM, on peut citer la simplicité de la syntaxe et la facilité de raisonner sur le code ainsi que la composabilité des opérations (ce qui fait défaut aux verrous). D'après le présentateur, le bon cas applicatif de la STM est quand le niveau de concurrence n’est pas trop élevé. En effet, s’il y a trop de concurrence, les collisions sont nombreuses et dégradent ainsi les performances. Le projet Multiverse permet de faire de la STM en Java et pour ceux qui font du Scala, il y a Scala STM.

  • Les acteurs : Le modèle des acteurs est une autre alternative aux verrous pour écrire des programmes concurrents. L’article fondateur du modèle date de 1973. Un acteur est un objet qui dispose d’une boîte de réception, y reçoit des messages (immutables), réalise un traitement et peut potentiellement transmettre un message en réponse au monde extérieur. Un aspect important du modèle des acteurs est qu’il ne partage pas son état avec le monde extérieur. Si vous souhaitez en savoir davantage sur les acteurs, vous pouvez aller du côté d’Akka qui est un excellent outil. Pour info, l'API d'Akka existe à la fois en Scala et en Java.

Le reste en vrac

Dans la dernière partie de la conférence, José a parlé de la parallélisation des calculs, de Fork/Join et de l’importance de l’algorithmique.

Conclusion

Cette présentation était très dense. Le sujet est à la fois riche et complexe. Le présentateur, bon orateur, avait une bonne maîtrise du sujet. Pensez à faire un tour sur parleys.com afin de visualiser la vidéo. Cela vaut vraiment le coup !

Plus de publications

1 comment for “De Runnable et synchronized à parallel() et atomically() à Devoxx

  1. Pingback: Discussion avec Nouhoum Traoré | Arolla