Previous Next Contents

Chapter 3    Threads Java

3.1  Introduction aux threads

Les ``threads'' ou ``processus légers'' sont des unités d'exécution autonomes qui peuvent effectuer des tâches, en parallèle avec d'autres threads: ils sont constitués d'un identificateur, d'un compteur de programme, d'une pile et d'un ensemble de registres. Le flot de contrôle d'un thread est donc purement séquentiel. Plusieurs threads peuvent être associés à un ``processus lourd'' (qui possède donc un flot de contrôle multiple, ou parallèle). Tous les threads associés à un processus lourd ont en commun un certain nombre de ressources, telles que: une partie du code à exécuter, une partie des données, des fichiers ouverts et des signaux.

En Java, le processus lourd sera la JVM (Java Parallel Machine) qui interprète le bytecode des différents processus légers. Les threads coopèrent entre eux en échangeant des valeurs par la mémoire commune (du processus lourd). On en verra plus en détail à la section 4.1 la sémantique détaillée.

L'intérêt d'un système ``multi-threadé'', même sur une machine monoprocesseur, est que l'ordinateur donne l'impression d'effectuer plusieurs tâches en parallèle. Les systèmes d'exploitation modernes (Unix, Windows NT etc.) sont tous multi-threadés donc il y a peu de chances que vous ayez connu l'horreur du mono-tâche. Le fait de pouvoir ouvrir en même temps netscape, emacs, et un shell par exemple, et de pouvoir passer d'une fenêtre à l'autre sans problème est la marque d'un tel système. L'application netscape même est multi-threadé. Une tâche essaie de se connecter au site choisi, pendant qu'une autre imprime à l'écran etc. Imaginez ce que ce serait si vous ne voyiez rien à l'écran tant que le site auquel vous vous connectez n'a pas fini de vous transmettre toutes les données! En fait, un système d'exploitation comme Unix comporte des processus (lourds) multiples, tels les démons systèmes (ou processus noyau) et souvent un grand nombre de processus (lourds) utilisateurs. Il n'est pas rare d'avoir quelques dizaines voire une centaine de processus lourds sur une machine à tout instant (faire ps al par exemple).

Sur une machine multiprocesseurs, des threads peuvent être exécutés sur plusieurs processeurs donc réelement en même temps, quand le système d'exploitation et le support pour les threads sont étudiés pour (c'est le cas pour Windows NT, Solaris 2, Linux etc.). Pour rentrer un peu plus dans les détails, les threads que nous programmerons sont des ``threads utilisateurs'' qui doivent communiquer avec le noyau de système d'exploitation de temps en temps, ne serait-ce que pour imprimer à l'écran, lire et écrire des fichiers etc. Tout thread utilisateur doit donc être lié d'une façon ou d'une autre à un thread ``noyau''. Selon les implémentations, chaque tâche utilisateur peut être liée à une tâche noyau, ou plusieurs tâches utilisateur à plusieurs tâches noyaux, ou encore plusieurs tâches utilisateur à une tâche noyau. La première version de Solaris (``green threads'') implémentait seulement la dernière possibilité qui est la seule qui ne permet pas de bénéficier de vrai parallélisme sur une architecture multiprocesseur. A partir de Java 1.1 et pour les versions plus récentes de Solaris et de Linux, on est dans le deuxième cas, qui offre le plus de flexibilité et de performances.

3.2  Les threads en JAVA

Les threads (ou processus légers) sont définis dans le langage JAVA, et ne sont pas comme en C ou C++, une extension que l'on peut trouver dans différentes bibliothèques.

3.2.1  Création

Pour créer un thread, on crée une instance de la classe Thread,

Thread Proc = new Thread();

Une fois créé, on peut configurer Proc, par exemple lui associer une priorité (on en parlera plus à la section 3.3.1). On pourra ensuite l'exécuter en invoquant sa méthode start. start va à son tour invoquer la méthode run du thread. Comme la méthode run de la classe Thread ne fait rien, il faut la redéfinir. C'est possible par exemple si on définit Proc comme une instance d'une sous-classe de Thread, dans laquelle on redéfinit la méthode run.

En général on a envie qu'un processus contienne des données locales, donc il est vraiment naturel de définir un processus comme une instance d'une sous-classe de Thread en général, voir par exemple la figure 3.1


class Compte extends Thread {
   int valeur;

   Compte(int val) {
      valeur = val;
   }

   public void run() {
      try {
         for (;;) {
            System.out.print(valeur + " ");
            sleep(100);
         }
      } catch (InterruptedException e) {
           return;
        }   
   }

   public static void main(String[] args) {
      new Compte(1).start();
      new Compte(2000).start();
   }
}
Figure 3.1:

La classe Compte gère uniquement ici un entier représentant une somme d'un compte courant d'une banque. Après avoir défini le constructeur, on a écrit une méthode run qui sera exécutée deux fois par deux instances différentes de la classe à partir du main, l'une avec une valeur de compte initiale égale à 1 et l'autre, à 2000. run se contente d'afficher la valeur courante du compte tous les dixièmes de seconde, jusqu'à une interruption clavier.

L'exécution du programme main donne quelque chose comme,

> java Compte
1 2000 2000 1 1 1 1
^C
>
Néanmoins, cette méthode n'est pas toujours acceptable. En effet dans certains cas, on veut étendre une classe existante et en faire une sous-classe de Thread également, pour pouvoir exécuter ses instances en parallèle. Le problème est qu'il n'y a pas d'héritage multiple en JAVA (c'est d'ailleurs plutôt un bien, car c'est très difficile à contrôler, voir C++). Donc on ne peut pas utiliser la méthode précédente. A ce moment là on utilise l'interface Runnable.

L'interface Runnable représente du code exécutable, et ne possède qu'une méthode,

public void run();
Par exemple la classe Thread implémente l'interface Runnable. Mieux encore, on peut construire une instance de Thread à partir d'un objet qui implémente l'interface Runnable par le constructeur:

public Thread(Runnable target);
On peut écrire à nouveau la classe Compte comme à la figure 3.2


class Compte implements Runnable {
   int valeur;

   Compte(int val) {
      valeur = val;
   }

   public void run() {
      try {
         for (;;) {
            System.out.print(valeur + " ");
            sleep(100);
         }
      } catch (InterruptedException e) {
           return;
        }   
   }

   public static void main(String[] args) {
      Runnable compte1 = new Compte(1);
      Runnable compte2 = new Compte(2000);
      new Thread(compte1).start();
      new Thread(compte2).start();
   }
}
Figure 3.2:

3.2.2  Partage des variables

Sur l'exemple de la section précédente il faut noter plusieurs choses. Les entiers valeur sont distincts dans les deux threads qui s'exécutent. C'est une variable locale au thread. Si on avait déclaré static int valeur, cette variable aurait été partagée par ces deux threads En fait, il aurait été même préférable de déclarer static volatile int valeur afin de ne pas avoir des optimisations génantes de la part de javac... Si on avait déclaré Integer valeur (``classe enveloppante'' de int), comme à la figure 3.3, cette classe, utilisée en lieu et place de int aurait pu être partagée par tous les threads.


public class UnEntier {
    int val;
    
    public UnEntier(int x) {
        val = x;
    }

    public int intValue() {
        return val;
    }

    public void setValue(int x) {
        val = x;
    }
}
Figure 3.3:

3.2.3  Quelques fonctions élémentaires sur les threads

Pour s'amuser (disons que c'est au moins utile pour débugger) on peut nommer les threads: void setName(String name) nomme le thread courant. String getName() renvoie le nom du thread.

Un peu plus utile, on peut recueillir un certain nombre d'informations sur les threads présents à l'exécution. Par exemple, static Thread currentThread() renvoie la référence au Thread courant c'est-à-dire celui qui exécute currentThread(). static int enumerate(Thread[] threadArray) place tous les threads existant (y compris le main() mais pas le thread ramasse-miettes) dans le tableau threadArray et renvoie leur nombre. static int activeCount() renvoie tout simplement le nombre de threads.

Voici un petit exemple d'utilisation (que l'on pourra également trouver, comme tous les autres exemples de ce cours, sur la page web du cours), à la figure 3.4


class Compte3 extends Thread {
   int valeur;

   Compte3(int val) {
      valeur = val;
   }

   public void run() {
      try {
         for (;;) {
            System.out.print(valeur + " ");
            sleep(100);
         }
      } catch (InterruptedException e) {
           return; } }

   public static void printThreads() {
      Thread[] ta = new Thread[Thread.activeCount()];
      int n = Thread.enumerate(ta);
      for (int i=0; i<n; i++) {
         System.out.println("Le thread "+ i + " est 
                            " + ta[i].getName());
      } } 

   public static void main(String[] args) {
      new Compte3(1).start();
      new Compte3(2000).start();
      printThreads(); } }
Figure 3.4:

Son exécution donne:

% java Compte3
1 2000 Le thread 0 est main
Le thread 1 est Thread-2
Le thread 2 est Thread-3
1 2000 1 2000 1 2000 1 2000 2000 
1 1 2000 2000 1 1 2000 2000 1 1 
2000 2000 1 1 2000 2000 1 1 2000 
2000 1 1 2000 2000 1 1 ^C
%

3.3  Eléments avancés

Pour commencer cette section, il faut d'abord expliquer quelles peuvent être les différents états des threads JAVA. Un thread peut être dans l'un des 4 cas suivants: On peut déterminer l'état d'un thread en appelant sa méthode isAlive(), qui renvoie un booléen indiquant si un thread est encore vivant, c'est à dire s'il est prêt ou bloqué

On peut aussi interrompre l'exécution d'un thread qui est prêt (passant ainsi dans l'état bloqué). void interrupt() envoie une interruption au thread spécifié; si l'interruption se produit dans une méthode sleep, wait ou join (que l'on va voir plus tard), elles lèvent une exception InterruptedException1 ; sinon le thread cible doit utiliser une des méthodes suivantes pour savoir si il a été interrompu: static boolean interrupted() renvoie un booléen disant si le thread courant a été ``interrompu'' par un autre ou pas et l'interrompt, boolean isInterrupted() renvoie un booléen disant si le thread spécifié a été interrompu ou pas (sans rien faire d'autre).

void join() (comme sous Unix) attend la terminaison d'un thread. Egalement: void join(long timeout) attend au maximum timeout millisecondes. Voir par exemple la figure 3.5


public class ... extends Thread {
   ...
   public void stop() {
      t.shouldRun=false;
      try {
         t.join();
      } catch (InterruptedException e) {}   } }
Figure 3.5:

3.3.1  Priorités

On peut affecter à chaque processus une priorité qui est un nombre entier, qui plus il est grand, plus le processus est prioritaire. void setPriority(int priority) assigne une priorité au thread donné et int getPriority() renvoie la priorité d'un thread donné.

L'idée est que plus un processus est prioritaire, plus l'ordonnanceur JAVA doit lui permettre de s'exécuter tôt et vite. La priorité peut être maximale: Thread.MAX_PRIORITY, normale (par défaut): Thread.NORM_PRIORITY (au minimum elle vaut 0). Pour bien comprendre cela, il nous faut décrire un peu en détail la façon dont sont ordonnancés les threads JAVA. C'est l'objet de la section suivante.

On peut également déclarer un processus comme étant un démon ou pas:

setDaemon(Boolean on);
boolean isDaemon();
Un processus démon est typiquement un processus ``support'' aux autres (par exemple, l'horloge, ou le ramasse-miettes). Il a la propriété d'être détruit quand il n'y a plus aucun processus utilisateur (non-démon) restant (en fait il y a un certain nombre de threads systèmes par JVM: ramasse-miettes, graphique).

3.3.2  Ordonnancement des tâches JAVA

Le choix du thread JAVA à exécuter (au moins partiellement) se fait parmi les threads qui sont prêts. Supposons que nous soyons dans le cas monoprocesseur. Il y aura donc à tout moment un seul processus actif à choisir. L'ordonnanceur JAVA est un ordonnanceur préemptif basé sur la priorité des processus. Essayons d'expliquer ce que cela veut dire. ``Basé sur la priorité'' veut dire qu'à tout moment, l'ordonnanceur essaie de rendre actif le (si on se place d'abord dans le cas simple où il n'y a pas 2 threads de même priorité) thread prêt de plus haute priorité. ``Préemptif'' veut dire que l'ordonnanceur use de son droit de préemption pour interrompre le thread courant de priorité moindre, qui reste néanmoins prêt. Il va de soit qu'un thread actif qui devient bloqué, ou qui termine rend la main à un autre thread, actif, même s'il est de priorité moindre.

Attention tout de même, un système de priorité n'est en aucun cas une garantie: c'est pourquoi on insiste sur le essaie dans la phrase ``l'ordonnanceur essaie de rendre actif le thread prêt de plus haute priorité''. On verra au chapitre 5 des algorithmes permettant d'implémenter l'exclusion mutuelle à partir de quelques hypothèses de bases, elles ne font aucunement appel aux priorités qui ne peut pas garantir de telles choses.

Il y a maintenant plusieurs façons d'ordonnancer des threads de même priorité. La spécification ne définit pas cela précisément.

L'ordonnancement ``round-robin'' (ou ``tourniquet'') dans lequel un compteur interne fait alterner l'un après l'autre (pendant des périodes de temps prédéfinies) les processus prêts de même priorité. Cela assure l'équité dans l'exécution de tous les processus, c'est à dire que tous vont progresser de conserve, et qu'aucun ne sera en famine (les processus ``plus rapides'' étant toujours ordonnancés).

Un ordonnancement plus classique (mais pas équitable en général) est celui où un thread d'une certaine priorité, actif, ne peut pas être prémpté par un thread prêt de même priorité. Il faut que celui-ci passe en mode bloqué pour qu'un autre puisse prendre la main. Cela peut se faire en interrompant régulièrement les processus par des sleep() mais ce n'est pas très efficace. Il vaut mieux utiliser static void yield(): le thread courant rend la main, ce qui permet à la machine virtuelle JAVA de rendre actif un autre thread de même priorité (on l'utilisera abondament au chapitre 5).

Tout n'est pas complètement aussi simple que cela. L'ordonnancement dépend en fait aussi bien de l'implémentation de la JVM que du système d'exploitation sous-jacent. Il y a deux modèles principaux concernant la JVM. Dans le modèle ``green thread'', c'est la JVM qui implémente l'ordonnancement des threads qui lui sont associés. Dans le modèle ``threads natifs'', c'est le système d'exploitation hôte de la JVM qui effectue l'ordonnancement des threads JAVA.

Le modèle green thread est le plus souvent utilisé dans les systèmes UNIX donc même sur une machine multi-processeur, seul un thread JAVA sera exécuté à la fois sur un tel système.

Par contre sur les plateformes Windows (Windows 95 ou Windows NT), les threads sont des threads natifs, et les threads JAVA correspondent bijectivement à des threads noyaux.

Sous Solaris, la situation est plus complexe, car il y a un niveau intermédiaire de processus connu de système d'exploitation (les ``light-weight processes''). On n'entrera pas ici dans les détails.

Pour ceux qui sont férus de système, disons que l'on peut contrôler pas mal de choses de ce côté là grâce à la Java Native Interface, voir par exemple
http://java.sun.com/docs/books/tutorial/native1.1/

3.3.3  Les groupes de processus

JAVA permet de définir des groupes de processus. Il existe une classe class ThreadGroup qui représente précisément ces groupes de processus. On peut définir un nouveau Thread dans un groupe donné par les constructeurs,
new Thread(ThreadGroup group, Runnable target);
new Thread(ThreadGroup group, String name);
new Thread(ThreadGroup group, Runnable target, String name);
Par défaut, un thread est créé dans le groupe courant (c'est à dire de celui qui l'a créé). Au démarrage, un thread est créé dans le groupe de nom main. On peut créer de nouveaux groupes et créer des threads appartenant à ces groupes par de nouveaux constructeurs. Par exemple,

ThreadGroup Groupe = new ThreadGroup("Mon groupe");
Thread Processus = new Thread(Groupe, "Un processus");
...
ThreadGroup MonGroupe = Processus.getThreadGroup();
On peut également faire des sous-groupes (en fait n'importe quelle arborescence est autorisée). On peut alors demander un certain nombre d'informations:

Groupe.getParent();
Groupe.getName();
Groupe.activeCount();
Groupe.activeGroupCount();
Groupe.enumerate(Thread[] list);
Groupe.enumerate(Thread[] list, boolean recurse);
Groupe.enumerate(ThreadGroup[] list);
Groupe.enumerate(ThreadGroup[] list, boolean recurse);
La première instruction permet de connaître le groupe parent, la deuxième le nom du groupe (une String), la troisième le nombre de thread du groupe Groupe, la quatrième, le nombre de groupe de thread, et les quatre suivantes, d'énumérer soit les threads d'un groupe, soit les groupes (cela remplit le tableau correspondant à chaque fois). Le booléen recurse indique si on désire avoir la liste récursivement, pour chaque sous-groupe etc.


1
C'est pourquoi il faut toujours encapsuler ces appels dans un bloc try ... catch(InterruptedException e)

Previous Next Contents