Le multithreading
Introduction
La programmation multithread est un domaine passionnant mais qui peut devenir rapidement très complexe à mettre au point. Plusieurs exécutions parallèles au cœur de votre application devront partager des informations, s’attendre, échanger... Le succès d’une architecture de ce type repose avant tout sur une analyse solide. Ce chapitre n’a pas vocation de vous exposer toutes les possibilités de programmation multithread et leurs implémentations en Java mais de vous en présenter l’essentiel avec la philosophie POO.
Comprendre le multithreading
Un processus peut effectuer des traitements longs qui vont bloquer l’application durant leurs exécutions. Pour éviter cela, le développeur peut créer une sorte de chemin d’exécution parallèle qui va prendre en charge ce traitement et ainsi délester l’exécuteur principal. Dans ce cas, le système d’exploitation Windows de Microsoft partage très rapidement le temps machine entre les différents flux d’exécution (typiquement quelques ms par tranche de temps) donnant l’illusion d’une exécution simultanée. On parle de système d’exploitation préemptif. Le contenu d’une file d’exécution peut enchaîner tous les traitements qu’elle souhaite sans se soucier du temps que cela prendra globalement.
Le système d’exploitation viendra « l’interrompre » périodiquement pour donner du temps à la file d’exécution suivante et ainsi de suite jusqu’à revenir sur elle pour qu’elle reprenne son traitement là où il avait été interrompu.
Reprenons pour exemple cette carte d’acquisition d’entrées/sorties équipée d’une interface de programmation très sommaire. Le constructeur nous livre un jeu de fonctions permettant, entre autres...
Multithreading et Java
La partie encapsulation de processus est proposée au travers de la classe java.lang.Process. Comme la création d’un processus est étroitement liée au système d’exploitation dans lequel fonctionne la machine virtuelle, il faut passer par une classe très spécialisée qui s’appelle Runtime. Chaque application Java dispose de façon native d’une instance unique sur un objet de type Runtime et c’est grâce à elle que vous pourrez démarrer un nouveau processus.
L’extrait de code suivant (figurant dans le répertoire Chap8\demo_process du .zip accompagnant cet ouvrage) permet d’exécuter le programme Windows calc.exe depuis un programme Java.
import java.io.IOException;
//TIP To <b>Run</b> code, press <shortcut actionId="Run"/> or
// click the <icon src="AllIcons.Actions.Execute"/> icon in the gutter.
public class Main {
public static void main(String[] args) {
// Récupération d'une référence sur le "runtime"
Runtime runtime = Runtime.getRuntime();
try {
// Utilisation de sa méthode exec ...
Implémentation des threads en Java
Il existe deux façons principales de programmer des threads en Java : étendre la classe Thread ou implémenter l’interface Runnable.
1. Étendre la classe Thread
En étendant la classe Thread et en plaçant le code à dérouler dans la méthode run, reprise à votre compte, votre classe devient directement « threadable ». Après son instanciation, un simple appel à la méthode start permet de démarrer le thread et d’exécuter en parallèle le code contenu dans sa méthode run. Le thread s’arrête dès que le code de la méthode run a été entièrement déroulé ou qu’une exception non gérée a été levée. Pratique, non ?
Voici un exemple de code (figurant dans le répertoire Chap8\demo_thread du .zip accompagnant cet ouvrage) utilisant ce principe :
// MaClasseThread étend la classe java.lang.Thread
public class MaClasseThread extends java.lang.Thread {
//...
//... On imagine ici différentes méthodes et accesseurs
//...
// On place dans la méthode run le traitement "long"
// qui va être exécuté dans un thread instancié
// et démarré depuis le code appelant
// (le main dans cet exemple)
@Override
public void run(){
// Trace de début de traitement.
// On utilise la méthode de type static
// Thread.currentThread()
// pour afficher le nom attribué à ce thread
System.out.println("Début d'un traitement de 10 sec "
+ "dans le thread "
+ Thread.currentThread().getName());
// Ici on simule un travail de 10 secondes (10 x 1000 ms)
for(int i=0; i<10; i++) ...
Synchronisation entre threads
1. Nécessité de la synchronisation
La programmation de plusieurs chemins d’exécution ne pose aucun problème particulier jusqu’à ce qu’ils partagent les mêmes informations ou les mêmes ressources... En effet, étant donné que le système d’exploitation peut interrompre les traitements à n’importe quel moment, il risque d’y avoir des objets en cours de modification dans un thread préempté qui se retrouveront dans des états instables pour le thread suivant. Pour se prémunir de ces dysfonctionnements, il faut synchroniser les threads, c’est-à-dire protéger des zones délicates de traitements.
Cela ne va pas jouer sur le système de gestion qui continuera à activer les threads les uns après les autres ; simplement lorsqu’un thread A aura besoin d’accéder à une donnée commune protégée qu’un thread B n’aura pas terminé de mettre à jour, alors le thread A devra « attendre le prochain tour ». Et si au prochain tour le travail du thread B n’est toujours pas terminé alors il devra attendre le suivant, etc.
Même principe s’il s’agit d’un traitement commun que le thread B devra avoir terminé avant que le thread A ne puisse l’effectuer à son tour. C’est ce scénario que l’extrait de code suivant propose. En effet, le traitement permet d’afficher un comptage de un jusqu’à vingt effectué par dix threads. L’objectif à atteindre est l’affichage suivant :
1234567891011121314151617181920
1234567891011121314151617181920
1234567891011121314151617181920
1234567891011121314151617181920
1234567891011121314151617181920
1234567891011121314151617181920
1234567891011121314151617181920
1234567891011121314151617181920
1234567891011121314151617181920
1234567891011121314151617181920
Et voici une première version de code sans protection (figurant dans le répertoire Chap8\demo_thread_sans_synchro du .zip accompagnant cet ouvrage) :
public class TraitementSansProtection{
public void ExecuterLeTraitement() {
...
Communication inter-threads
1. La méthode join
Comme vu précédemment, la méthode join permet à un thread principal de « s’endormir » en attendant la fin de l’exécution d’un thread secondaire.
Exemple de code figurant dans le répertoire Chap8\demo_synchro_inter_ threads_avec_join du .zip accompagnant cet ouvrage
public class Main {
public static void main(String[] args)
throws InterruptedException {
System.out.println("Instanciation "
+ "du thread secondaire");
Thread monThreadSecondaire
= new Thread(new MaClasseRunnable());
System.out.println("Démarrage "
+ "du thread secondaire");
monThreadSecondaire.start();
// Simule un travail de 3 sec. sur le thread principal
Thread.sleep(3000);
System.out.println("Demande "
+ "l'arrêt du thread secondaire");
monThreadSecondaire.interrupt();
System.out.println("Attend "
+ "la fin du thread secondaire");
monThreadSecondaire.join();
System.out.println("Fin du main");
}
}
public class MaClasseRunnable implements Runnable { ...
Exercice
1. Énoncé
En partant de l’exemple précédent (avec Thread.sleep(500); dans la boucle de consommation), vous devez introduire la notion de gestion de flux entre producteur et consommateur. Le thread de production doit « s’endormir » quand un nombre maximum de trames en attente d’être traitées est atteint (dix par exemple). Le thread de consommation viendra lire ces trames puis, quand la file sera vide, le thread de production devra reprendre son travail.
Type de comportement souhaité :
debug:
Début Traitement global
Début ProducteurDeTrames
Appuyez sur Enter pour arrêter...
Début ConsommateurDeTrames
Consommateur attend
Trame reçue : 46
Trame consommée : 46
Trame reçue : 67
Trame reçue : 23
Trame reçue : 92
Trame reçue : 20
Trame reçue : 23
Trame consommée : 67
Trame reçue : 54
Trame reçue : 21
Trame reçue : 60
Trame reçue : 66
Trame consommée : 23
Trame reçue : 2
Trame reçue : 58
Trame reçue : 69
File saturée
Trame consommée : 92
Trame consommée : 20
Trame consommée : 23
Trame consommée : 54
Trame consommée : 21
Trame consommée : 60
Trame consommée : 66
Trame consommée : 2
Trame consommée : 58
Trame consommée : 69
File dé-saturée
Trame reçue : 97
Trame reçue : 76
Trame reçue : 41
Trame reçue : 89
Trame consommée : 97
Trame reçue : 12
Abandon demandé
InterruptedException dans ProducteurDeTrames
Fin ProducteurDeTrames
InterruptedException dans ConsommateurDeTrames
Fin ConsommateurDeTrames
Fin Traitement global
BUILD SUCCESSFUL (total time: 9 seconds)
Voici quelques informations pour vous aider :
Dans le code de départ, le développeur a fait jouer un double rôle au producteur : produire et synchroniser le consommateur pour qu’il s’endorme quand il n’y a rien à lire... Une solution possible à la nouvelle problématique serait...