VII - Les threads Java

 

Lorsqu'un programme est exécuté sur un système multitâche, l'OS crée un processus dédié, disposant d'une certaine quantité de ressources (mémoire, ...). Un programme peut être amené à créer un ou plusieurs sous-processus afin de permettre l'exécution en parallèle de plusieurs opérations simultanées. Cependant, ces processus ne partagent pas le même espace mémoire si bien que leur création et leur commutation sont lourdes à gérer.

Un thread est une unité d'exécution plus "petite" qu'un processus. Les threads issus d'un même processus partagent le même espace mémoire, si bien qu'ils sont plus légers donc plus rapides, chacun étant doté d'une certaine priorité. De plus, un système multiprocesseurs peut exécuter les différents threads d'un même programme simultanément, un sur chaque processeur.

Le partage de mémoire entraîne cependant un certain nombre de difficultés car il est possible qu'un thread tente d'écrire dans une zone de mémoire en cours de lecture par un autre...On a alors introduit la notion de sémaphore permettant de protéger l'accès à une ressource. Nous verrons comment Java gère les sémaphores un peu plus loin.

On utilise souvent les threads pour permettre à des serveurs de traiter plusieurs clients simultanément ou pour améliorer la réactivité d'une interface graphique.

 

1. Etats d'un thread

Un thread est à tout moment dans un des cinq états suivants :

La figure suivante indique comment se font les transitions entre états.

  Nous allons voir maintenant comment créer, démarrer ou arrêter un thread en Java. Nous verrons également comment changer la priorité d'un thread et nous étudierons enfin les problèmes de partage de données en Java.

 

2. Manipulation des threads en Java

Comme beaucoup d'autres choses, un thread est considéré comment étant un objet en Java. Pour utiliser des threads dans un programme, il suffit d'hériter de la classe Thread et de redéfinir la méthode run(), mais il est également possible d'implémenter l'interface Runnable. C'est la méthode run() qui est automatiquement appelée au moment où le thread est démarré.

Nous allons voir comment utiliser les threads en Java à travers un exemple de threads qui comptent de 1 à 100. Commençons donc par créer une sous-classe de la classe Thread, puis créons une classe permettant de lancer les deux threads via la méthode main() :

// LanceCompteurs.java

//

class ThreadCompteur extends Thread {

int no_fin;

// Constructeur

ThreadCompteur (int fin) {

no_fin = fin;

}

// On redéfinit la méthode run()

public void run () {

for (int i=1; i<=no_fin ; i++) {

System.out.println(this.getName i);

}

}

}

// Classe lançant les threads

class LanceCompteurs {

public static void main (String args[]) {

// On instancie les threads

ThreadCompteur cp1 = new ThreadCompteur (100);

ThreadCompteur cp2 = new ThreadCompteur (100);

// On démarre les deux threads

cp1.start();

cp2.start();

// On attend qu'ils aient fini de compter

while (cp1.isAlive() || cp2.isAlive) {

// On bloque le thread 100 ms

try {

Thread.sleep(100);

} catch (InterruptedException e) { return; }

}

}

}

Une fois compilé et exécuté, ce programme affiche à l'écran :

Thread-1:1

Thread-2:1

Thread-2:2

Thread-2:3

Thread-1:2

Thread-1:3

Thread-1:4

Thread-1:5

Thread-1:6

Thread-2:4

(...)

Thread-2:95

Thread-2:96

Thread-2:97

Thread-2:98

Thread-2:99

Thread-2:100

Analysons le source de ce programme. Comme nous l'avons dit, nous définissons une sous-classe de la classe Thread. Cette classe comporte un constructeur, qui prend en argument le nombre d'itérations à effectuer et l'attribue à la donnée membre no_fin. Ensuite, on redéfinit la méthode run(), comme nous l'avons dit plus haut. Cette méthode comporte une simple boucle for qui compte de 1 jusqu'à no_fin.

On affiche dans cette boucle la valeur de l'indice, précédée du "nom" du Thread, afin de pouvoir les distinguer. Pour ce faire, nous utilisons la méthode getName() de la classe Thread, appliquée à l'objet courant (this). Nous avons reproduit ci-dessus l'affichage obtenu.

Maintenant, passons à la classe LanceCompteurs qui crée et lance deux threads, instances de la classe précédente.

Tout d'abord, nous créons les deux objets ThreadCompteur, comme n'importe quel autre objet Java, grâce à l'opérateur new, en passant en argument le nombre d'itérations:

ThreadCompteur cp1 = new ThreadCompteur (100);

ThreadCompteur cp2 = new ThreadCompteur (100);

A ce moment là, nous n'avons démarré encore aucun thread, nous avons juste réservé de la mémoire pour les deux objets-threads, nous sommes alors dans l'état "création" vu plus haut.

Ces threads sont démarrés grâce à l'appel de la méthode start(), héritée de la classe Thread. La Machine Virtuelle exécute alors la méthode run() que nous avons redéfinie dans le classe ThreadCompteur. Nous passons alors à l'état exécutable/exécuté (quand le thread est choisi par le gestionnaire de la JVM).

Ensuite, nous attendons la fin de l'exécution de ces threads, en testant le code de retour de la fonction isAlive(), appliquée aux deux threads, qui renvoie true tant qu'un thread est actif. Cette boucle contient un appel à la méthode sleep(), appliquée au thread courant (celui dans lequel le programme s'exécute). Cette méthode prend en argument un nombre de millisecondes pendant lesquelles elle met le thread à l'état bloqué afin que d'autres puisse s'exécuter. Notons que l'appel à sleep() est entouré d'un bloc try{} suivi d'un catch{}, il s'agit d'un gestionnaire d'exceptions (nous en parlerons dans un chapitre dédié).

Notez qu'il existe une autre méthode bien utile, qui permet d'arrêter un thread, mais que nous n'avons pas utilisée dans cet exemple. Cette fonction s'appelle stop().

Concernant l'affichage reproduit plus haut, il est important de noter que l'ordre d'affichage des compteurs respectifs des deux threads n'est pas déterministe. Ce comportement peut cependant être modifié via l'introduction de priorités dans nos threads.

 

3. Gestion des priorités

La classe Thread comprend deux fonctions, getPriority() et setPriority() qui permettent respectivement de lire ou modifier la priorité d'un thread. On y trouve également trois constantes, MIN_PRIORITY, NORM_PRIORITY et MAX_PRIORITY qui correspondent aux priorités minimale, normale et maximale qu'un thread peut avoir. Il est possible d'attribuer une valeur entière quelconque à un thread, à condition de rester dans l'intervalle MIN_PRIORITY-MAX_PRIORITY.

Reprenons notre exemple et ajoutons les deux lignes suivantes, juste avant l'appel de start() sur les threads :

cp1.setPriority(Thread.MAX_PRIORITY);

cp2.setPriority(Thread.MIN_PRIORITY);

Nous mettons ainsi la priorité maximale au thread 1, et la plus petite au 2ème thread. Si nous lançons le programme ainsi modifié, nous constatons, sous Windows 95, que les deux threads comptent l'un après l'autre, le thread 1 ayant terminé de compter quand le deuxième commence. C'est bien le comportement que nous voulions obtenir. Notez que la gestion des threads dans la JVM est dépendante de celle utilisée dans le système hôte, si bien que l'ordonnancement des threads (et donc le comportement du programme) peut être différent d'une machine à l'autre. C'est l'une des rares fonctionnalités de Java qui n'est pas portable.

Le précédent exemple montre également que lorsque deux threads de priorités différentes s'exécutent, c'est le thread de plus forte priorité qui est exécuté jusqu'à ce qu'il soit terminé, afin que le gestionnaire de thread puisse donner la main à un thread de plus faible priorité.

Il nous reste maintenant à voir comment gérer le partage de données par des threads en Java.

 

4. Partage de données

L'exemple le plus classique permettant d'illustrer le problème du partage des données entre deux threads concurrents, est le suivant.

On considère deux threads, qui additionnent 10000 fois la valeur 1 à une variable partagée. Puisqu'il y a deux threads en concurrence, on doit s'attendre à ce que le résultat soit égal à 20000. Voici le source de cet exemple :

// Compter.java

class Compteur {

int no=0;

int no_fin;

// Constructeur

Compteur (int fin) {

no_fin = fin;

}

// On compte

void comptons() {

for (int i=0; i<no_fin; i++) {

no++;

}

System.out.println("Total : " + Integer.toString(no));

}

}

class LanceCompte extends Thread {

Compteur cp;

// Constructeur

LanceCompte(Compteur cp) {

this.cp = cp;

}

// On redéfinit run()

public void run () {

cp.comptons();

}

}

// Classe Compter

class Compter {

public static void main (String arg[]) {

Compteur cp = new Compteur (10000);

LanceCompte lc1 = new LanceCompte (cp);

LanceCompte lc2 = new LanceCompte (cp);

// Démarrage des threads

lc1.start();

lc2.start();

try {

lc1.join();

lc2.join();

} catch (Exception e) { return ;}

}

}

Détaillons le fonctionnement de ce programme. Tout d'abord, on crée une classe Compteur qui a pour but de...compter. Ensuite, on définit une classe héritant de Thread, dans laquelle on appelle la méthode comptons() de la classe précédente, dans la méthode run() redéfinie.

La classe Compter quant à elle lance deux threads qui vont tout deux appeler la fonction comptons() sur le même objet. Notons que cette fois-ci, nous attendons que les deux threads aient terminé leur exécution en faisant appel à la méthode join() héritée de la classe Thread. Cette méthode rend la main quand l'objet thread sur lequel elle est appliquée a terminé son travail (fin de la méthode run() par exemple).

Si nous exécutons ce programme, nous obtenons :

Total : 10744

Total : 14394

Le premier thread compte jusqu'à 10000 et renvoie le résultat de ce calcul. Le deuxième fait de même. Nous devrions alors trouver 10000 puis 20000. Cependant, ce n'est pas le cas. En fait, les deux threads ont compté en même temps, comme nous le voulions, mais il s'est produit un conflit d'accès à la variable no. Pour être plus précis, il s'est produit un phénomène tel que le suivant :

En clair, la variable n'a finalement été incrémentée qu'une seule fois, et ce type de phénomène s'est produit plusieurs fois, si bien que nous n'arrivons pas à la valeur 20000 attendue.

Pour solutionner ce genre de problème, Java introduit un mot clé synchronized, à préciser en tête de fonction, qui indique que la fonction correspondante ne doit être exécutée que par un seul thread au même moment.

Si nous mettons ce mot clé devant la définition de la fonction comptons() :

synchronized void comptons() {

nous obtenons à l'écran :

Total : 10000

Total : 20000

ce qui est le résultat exact.

A noter qu'il est également possible de ne synchroniser qu'un bloc d'instructions, sur l'obtention d'un accès unique à un objet spécifié, comme suit :

synchronized (objet) {

// ...

}

Il existe d'autres fonctions particulièrement utiles dans la classe Thread (comme celles permettant à un ou plusieurs threads d'attendre que l'accès à un objet soit libéré), mais nous n'en dirons pas plus dans ce document. Passons maintenant à la programmation réseau en Java.