Java et les threads


Généralités

Le concept de thread permet de séparer l'exécution d'un programme en plusieurs processus légers. Chacun s'exécute en parallèle dans le même espace d'adressage de façon concurrente.

Ce concept s'applique parfaitement dans le domaine des applications réseau où beaucoup de problèmes se résolvent naturellement de façon concurrente. Par exemple, une thread s'occupe de l'accès réseau pendant qu'une autre s'occupe des interactions avec l'utilisateur ; une thread attend les connexions pendant qu'une autre traite une connexion déjà établie...

En plus de faciliter, dans certains cas, la résolution d'un problème, ce concept peut également améliorer les performances en optimisant l'utilisation des ressources.

Toutefois, l'utilisation des threads n'a pas que des avantages, le code ainsi produit est souvent difficile à comprendre, peu réutilisable et difficile à débuguer.

Java propose dans le langage un mécanisme pour créer des threads et pour les faire communiquer.


Les mécanismes associés

Chaque thread s'exécutant dans le même espace d'adressage, il est nécessaire d'avoir un mécanisme d'exclusion mutuelle entre threads lors des accès à la mémoire. Java propose dans le langage un mécanisme de moniteur qui assure que quand une thread rentre dans un moniteur aucune autre thread ne peut y pénétrer.

Chaque thread s'exécute de façon concurrente avec une certaine priorité allant de 1 à 10. Les threads de plus forte priorité sont exécutées "plus souvent" que celles de plus faibles priorités.

La JVM exécute les threads jusqu'à ce que :

Un mécanisme de synchronisation entre les threads est également disponible.


La classe java.lang.Thread

Un objet de la classe Thread permet de contrôler un processus léger qui peut être actif, suspendu ou arrêté.

Au lancement d'un programme une seule thread s'exécute, c'est la thread initiale. On y accède à son objet de contrôle via la méthode de classe :

public static Thread currentThread();

Il est alors possible de modifier les attributs de la thread en manipulant cet objet via une des méthodes d'instance.

Par exemple :

class MaPremiereThread
{
 public static void main(String[] args) throws Exception
 {
 Thread threadInitiale = Thread.currentThread();
 threadInitiale.setName("Ma thread"); // donner un nom a la thread
 System.out.println(threadInitiale); // afficher le nom de la thread
 Thread.sleep(1000); // faire dormir la thread courante 1 sec
 System.out.println("fin");
 threadInitiale.setDaemon(true); // essaye de passer la thread en daemon
 }
}

Ce code affiche :

Thread[Ma thread,5,main]
fin
java.lang.IllegalThreadStateException
 at java.lang.Thread.setDaemon(Thread.java)
 at MaPremiereThread.main(Compiled Code)

Car pour appliquer la méthode setDaemon() la thread ne doit pas être active.


La création d'une nouvelle thread

Nous ne disposons au démarrage que d'une seule thread. Pour en créer une nouvelle, il faut créer un nouvel objet de la classe Thread et ensuite le démarrer (lui demander d'exécuter une portion de code).

Pour cela, il existe deux méthodes. Soit le code à exécuter est spécifié dans une classe qui hérite de la classe Thread, soit il est spécifié dans une classe qui implante l'interface Runnable, dont un objet sera passé en paramètre lors de la création de d'un nouvel objet de la classe Thread.

La première solution a l'avantage de ne manipuler qu'un objet mais a l'inconvénient d'interdire tout nouvel héritage.

Pour créer la nouvelle thread il faut utiliser l'un des 7 constructeurs de la classe Thread. Les plus courants sont les suivants :

Les autres prennent en paramètre supplémentaire un java.lang.ThreadGroup. Par défaut la thread créée appartient au même groupe que celle qui appelle le constructeur.

Le paramètre java.lang.ThreadGroup permet de regrouper un ensemble de thread est de leur donner des propriétés communes ou de leur appliquer une méthode à toutes.

Une fois l'objet thread créé au moyen d'un des constructeurs précédent, il faut l'activer (le démarrer) au moyen de la méthode public native synchronized void start() qui appelle la méthode public void run() de l'objet contenant le code à exécuter. Si la méthode run() est appelée directement, le code de cette méthode est exécuté dans la thread courante.

Exemple avec la première méthode :

class MaThread extends Thread
{
 public void run ()
 {
 int i;
 for(i=0;i<5;i++)
 {
 System.out.println("Ma thread "+i);
 try
 {
 Thread.sleep(500);
 }
 catch(java.lang.InterruptedException e)
 {
 System.out.println("Interrupted !");
 }
 }

 System.out.println("Ma thread se termine");

 }
}

class MonProgramme1
{
 public static void main(String[] args) throws Exception
 {
 Thread maThread = new MaThread();
 maThread.start();

 int i;
 for(i=0;i<5;i++)
 {
 System.out.println("La thread initiale " + i);
 Thread.sleep(300);
 }
 System.out.println("La thread initiale se termine");
 }
}

Exemple avec la deuxième méthode :

class MonRunnable implements Runnable
{
 public void run ()
 {
 int i;
 for(i=0;i<5;i++)
 {
 System.out.println("Ma thread "+i);
 try
 {
 Thread.sleep(500);
 }
 catch(java.lang.InterruptedException e)
 {
 System.out.println("Interrupted !");
 }
 }

 System.out.println("Ma thread se termine");
 }
}

class MonProgramme2
{
 public static void main(String[] args) throws Exception
 {
 Thread maThread = new Thread(new MonRunnable());
 maThread.start();

 int i;
 for(i=0;i<5;i++)
 {
 System.out.println("La thread ini " + i);
 Thread.sleep(300);
 }
 System.out.println("La thread initiale se termine");
 }
}

Ces programmes affiche pour une exécution particulière :

prompt java MonProgramme
La thread initiale 0
Ma thread 0
La thread initiale 1
Ma thread 1
La thread initiale 2
La thread initiale 3
Ma thread 2
La thread initiale 4
Ma thread 3
La thread initiale se termine
Ma thread 4
Ma thread se termine

Attention l'accès au processeur de façon équitable entre les threads n'est pas assuré sur tous les systèmes !


L'arrêt d'une thread

Une thread se termine lorsque la méthode run() se termine ou lors de l'appel à la méthode public final void stop() sur une référence à la thread. Si la thread1 qui appelle la méthode stop() sur thread2 a le droit d'arrêter la thread2 (elle fait partie du même ThreadGroup), alors la thread2 reçoit une exception ThreadDeath (qui est rarement rattrapée). La méthode public final synchronized void stop(Throwable ex) permet d'envoyer à la thread à arrêter n'importe quelle exception à la place de ThreadDeath.

Lorsqu'une thread est terminée l'objet de type Thread est toujours accessible (appel à la méthode isAlive() par exemple), mais il n'est plus possible de reprendre son exécution. Comme tous les autres objets, l'objet ne sera détruit que lorsqu'il n'existera plus de référence permettant d'y accéder et que le ramasse-miettes l'aura détruit.

Il est possible de suspendre momentanément l'exécution d'une thread au moyen de la méthode public final void suspend() et de reprendre l'exécution là où elle avait été interrompue au moyen de la méthode public final void resume().

public class MonProgrammeStop extends Thread
{
 public MonProgrammeStop(String name)
 {
 super(name);
 }


 public static void main(String[] args)
 {
 Thread thread = new MonProgrammeStop("Ma thread");
 thread.setDaemon(true);
 thread.start();

 Thread.currentThread().yield();
 System.out.println(thread + " isAlive retourne " + thread.isAlive());

 thread.resume();
 Thread.currentThread().yield();

 thread.stop();
 Thread.currentThread().yield();

 System.out.println(thread + " isAlive retourne " + thread.isAlive());
 }
 public void run()
 {
 try
 {
 System.out.println(Thread.currentThread() + " debut run");
 Thread.currentThread().suspend();
 System.out.println(Thread.currentThread() + " apres suspend");
 Thread.currentThread().yield();
 for(;;);
 }
 catch(ThreadDeath e)
 {
 System.out.println(Thread.currentThread() + " reçoit " + e);
 }
 }
}

La méthode public void destroy() permet de détruire une thread sans aucune possibilité de réaction et en ne libérant aucun moniteur.

La méthode public static native void sleep(long millis) throws InterruptedException permet de suspendre l'activité d'une thread pendant un certain nombre de millisecondes.

Toutes ces opérations ne sont possibles que si la thread appelant ces méthodes a le droit d'accéder à la thread dont on veut modifier l'état. Ceci n'est vrai que si elles appartiennent au même ThreadGroup.


La notion de priorité

Il est possible de changer la priorité d'une thread afin qu'elle est une priorité particulière pour accéder au processeur. La thread de plus forte priorité accède plus souvent au processeur. Par défaut, une thread a la priorité de sa mère. Pour changer la priorité d'une thread la méthode suivante est disponible :

Pour visualiser la priorité :

Il existe des constantes prédéfinis pour les valeurs des priorités :

Il n'est pas possible de sortir de ces bornes sous peine de recevoir une exception IllegalArgumentException. Il est également possible de donner une priorité maximale à un ThreadGroup particulier. Ceci peut être utile pour éviter que certaines threads (des applettes par exemple) empêche les autres d'accéder au processeur. Exemple :

import java.io.*;

class La_thread implements Runnable
{
 int time;
 int number;

 public La_thread(int nb,int t)
 {
 number =nb;
 time = t;
 }
 public void run()
 {
 int i;
 for(i=0;i<50;i++)
 {
 System.out.println("Thread :" + number);
 try
 {
 Thread.sleep(time);
 }
 catch(Exception e)
 {
 System.out.println("Interrupted !");
 }
 }
 System.out.println(number + " Fini, interrupted : "+ Thread.interrupted());
 }
}

class Thread_ini
{
 public static void main(String[] args) throws Exception
 {
 Thread t1 = new Thread (new La_thread(1,10));
 Thread t2 = new Thread (new La_thread(2,0));

 t1.setPriority(Thread.MAX_PRIORITY);
 t2.setPriority(Thread.MIN_PRIORITY);
 t1.start();
 t2.start();
 t1.join();
 t2.join();
 System.out.println("Ini Fini !");
 }
}

Il est possible de libérer le processeur pour les autres threads au moyen de la méthode de classe :


L'exclusion mutuelle entre thread

Java garantit atomicité de l'accès et de l'affectation des types primitifs, sauf les long et les double. Ainsi deux threads qui modifient de façon concurrente une variable de type double peuvent entraîner des résultats incohérents. De même pour des objets.

Exemple :

public class Point implements Runnable
{
 protected int x;
 protected int y;
 void moveTo(int x, int y)
 {
 this.x = x;
 this.y = y;
 }
 void toString()
 {
 return "(" + x + "," + y + ")";
 }
 public void main(String[] args)
 {
 Point p = new Point();
 Thread thread = new Thread(p);
 thread.start();
 for(;;)
 {
 p.moveTo(1,1);
 System.out.println(p);
 }
 }
 public void run()
 {
 for(;;)
 {
 moveTo(0,0);
 System.out.println(this);
 }
 }
}

Peut entraîner une valeur de pour Point x=0 et y=1.

Pour éviter ces problèmes d'incohérences Java propose un mécanisme d'exclusion mutuelle entre deux threads.

Pour cela Java utilise le mot clef synchronized qui s'applique à une portion de code relativement à un objet particulier. Pendant l'exécution d'un portion de code synchronisée par une thread A , toute autre thread essayant d'exécuter une portion de code synchronisée sur le même objet est suspendue. Une fois que l'exécution de la portion de code synchronisée est terminée par la thread A , une thread en attente et une seule est activée pour exécuter sa portion de code synchronisée. Ce mécanisme est un mécanisme de moniteur. Il peut y avoir un moniteur associé à chaque objet.

Deux constructions sont disponibles:

Exemple :

import java.util.NoSuchElementException;

public class TableauDynamique
{
 private Object[] tableau; // le conteneur
 private int nb; // la place utilisée

 public TableauDynamique (int taille)
 {
 tableau = new Object[taille];
 nb = 0;
 }

 public synchronized int size()
 {
 return nb;
 }

 public synchronized Object elementAt(int i) throws NoSuchElementException
 {
 if (i < 0 || i = nb)
 throw new NoSuchElementException();
 else
 return tableau[i];
 }

 public synchronized void append(Object x)
 {
 if (nb = tableau.length)
 { // allouer un tableau plus grand
 Object[] tmp = tableau;
 tableau = new Object[3*(nb + 1)/2];
 for (int i = 0; i < nb; ++i)
 tableau[i] = tmp[i];
 }
 tableau[nb] = x;
 nb++;
 }

 public synchronized void removeLast() throws NoSuchElementException
 {
 if (nb == 0)
 throw new NoSuchElementException();
 else
 {
 nb--;
 tableau[nb] = null;
 }
 }
}

Exemple :

public class List
{
 protected double valeur;
 protected List suivant;

 public List(double valeur, List suiv)
 {
 this.valeur = valeur;
 this.suivant = suivant;
 }

 public synchronized void setValeur(double valeur)
 {
 valeur = valeur;
 }

 public synchronized double valeur()
 {
 return valeur;
 }


 public List suivant()
 {
 return suivant;
 }

 public double somme()
 {
 double somme = valeur(); // acces synchronisé
 if (suivant() != null)
 somme += suivant().somme();
 return somme;
 }

 public boolean inclus(double x)
 {
 synchronized(this)
 {
 if (valeur == x) return true;
 }
 if (suivant() == null)
 return false;
 else
 return suivant().inclus(x);
 }
}

Une méthode synchronisée peut appeler une autre méthode synchronisée sur le même objet sans être suspendue.

Les méthodes de classe peuvent aussi être synchronisées mais la synchronisation se fait sur l'objet de type Class, si une méthode non statique veut synchroniser son accès avec une méthode statique elle doit se synchroniser relativement au même objet. Elle doit donc contenir une construction de la forme : synchronized(this.getClass()){...}.

La synchronisation d'une méthode peut être éliminée par masquage.


Les problème de l'exclusion mutuelle

L'exclusion mutuelle est introduite afin de régler les problèmes de sureté (safety) du code. Rien de faux n'arrive.

Malheureusement elle peut introduire des problèmes de liveness. Rien n'arrive du tout.

Arriver à trouver le bon équilibre entre les deux extrêmes est une vraie difficulté de la programmation concurrente.

Les problèmes de liveness peuvent être de quatre types :

Le problème de liveness le plus courant est le problème de deadlock. Une thread1 attend une "chose" que doit libérer une thread2 qui attend elle-même une chose que doit libérer la thread1. Une stratégie simple (malheureusement pas toujours applicable) pour éviter les deadlocks consiste à numéroter les "choses" à attendre et à toujours les prendre dans le même ordre.

Une autre approche qui peut souvent porter ces fruits pour éliminer les problèmes de liveness est la stratégie de diviser pour régner. Plutôt que d'avoir un unique moniteur, celui-ci est subdiviser en plusieurs sous-moniteurs contrôlant chacun l'accès à une ressource particulière.

Pour avoir une bonne propriété de liveness il est parfois nécessaire d'assouplir un peu la sûreté du code. Ceci est parfois possible dans un contexte particulier. En revanche, une telle option peut interdire la réutilisabilité du code pour un autre contexte où les contraintes de sécurité du code ne seront pas les mêmes.

Avant de supprimer l'exclusion mutuelle d'une partie de code il est très important de penser aux deux règles suivantes :


Le mot clef volatile

Le mot clef volatile est à utiliser pour éviter que le compilateur ne fasse certaines optimisations. En particulier dans le cas :

Exemple :

boolean a=true;
while(a){...}

Le compilateur optimise le code et ne voit pas que la variable est modifiable par une autre thread. Il faut donc écrire :

volatile a = true;
while(a){...}

La synchronisation entre threads

Le mécanisme de moniteur permet d'exclure mutuellement deux threads. Pour les applications concurrentes il est également très important de pouvoir les synchroniser. Ceci particulièrement utile pour s'assurer qu'une autre thread est bien dans un état particulier (terminée en particulier).

Java propose un mécanisme d'attente/notification.

Les primitives suivantes sont des méthodes de la classe java.lang.Object qui sont utilisées pour la synchronisation. Elles doivent être appelées sur un objet associé à un moniteur détenu au moment de l'appel par la thread courante :

public interface Compteur
{
 public static final long MIN = 0; // minimum
 public static final long MAX = 5; // maximum

 public long value(); // valeur entre MIN et MAX
 public void inc(); // incremente si value() < MAX
 public void dec(); // decremente si value() MIN
}

public class CompteurConcurrent implements Compteur
{
 protected long count = MIN;
 public synchronized long value()
 {
 return count;
 }

 public synchronized void inc()
 {
 attendIncrementable();
 setCount(count + 1);
 }

 public synchronized void dec()
 {
 attendDecrementable();
 setCount(count - 1);
 }

 protected synchronized void setCount(long newValue)
 {
 count = newValue;
 notifyAll();
 }
 protected synchronized void attendIncrementable()
 {
 while (count >= MAX)
 try { wait(); } catch(InterruptedException ex) {};
 }

 protected synchronized void attendDecrementable() {
 while (count <= MIN)
 try { wait(); } catch(InterruptedException ex) {};
 }
}

Il est également possible de se synchroniser sur la terminaison d'une thread grâce à la méthode :

class Picture {/* ... */ }

public interface PictureRenderer
{
 public Picture render(byte[] rawPicture);
}
class RenderWaiter implements Runnable
{
 private PictureRenderer r; // objet de service
 private byte [] arg; // arguments a lui passer
 private Picture result = null; // resultats qu'il a calculé

 RenderWaiter(PictureRenderer r, byte[] raw)
 {
 this.r = r;
 arg = raw;
 }

 synchronized Picture result()
 {
 return result;
 }

 public void run()
 {
 result = r.render(arg);
 }
}

import java.awt.*;

public class PictureDisplayV1
{
 protected PictureRenderer myRenderer;
 TextArea canvas;

 public PictureDisplayV1(TextArea canvas, PictureRenderer r)
 {
 this.canvas = canvas;
 myRenderer = r;
 }

 public void show(byte[] rawPicture)
 {
 RenderWaiter w = new RenderWaiter(myRenderer,rawPicture);
 Thread t = new Thread(w);
 t.start();
 // fait autre chose
 displayBorders();
 displayCaption();

 try
 { // Attend le Renderer
 t.join();
 }
 catch(InterruptedException ex)
 {
 cleanUp();
 return;
 }

 Picture img = w.result(); // récupère le résultat
 displayPicture(img);
 }

 protected void displayBorders()
 {
 canvas.appendText("|--------|\n");
 }

 protected void displayCaption()
 {
 canvas.appendText("... Belle Image ...\n");
 }

 protected void displayPicture(Picture img)
 {
 canvas.appendText(img.image());
 }

 protected void cleanUp()
 {
 canvas.appendText("Arg...\n");
 }
}

Toutes ces méthodes bloquantes ainsi que la méthode sleep() peuvent être interrompues au moyen de la méthode interrupt() de la classe Thread. Cette interruption lève un exception InterruptedException dans la thread qui était bloquée.  C'est la raison pour laquelle il faut toutes les inclure dans un bloc try/catch.  Cette construction est utile dans le cas ou l'on sait que la condition attendue ne pourra jamais être atteinte.

Les constructions suspend/resume peuvent être utilisées à la place de wait/notify dans les cas où l'attente a lieu dans un bloc non synchronisé (ou si la thread peut garder le moniteur) et surtout quand les deux threads se connaissent. En effet, dans le cas de wait/notify les deux threads en jeu ne se connaissent qu'a travers un moniteur.


Où sont les sémaphores ?

Il n'y pas de sémaphores en Java mais il est possible de les simuler en utilisant les constructions suivantes :

public class Lock
{
 private int valeur =1;

 public synchronized void P()
 {
 while(valeur == 0)
 {
 try{wait();}
 catch(InterruptedException e){}
 }
 valeur=0;
 }
 public synchronized void V()
 {
 valeur=1;
 notifyAll();
 }
}