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:
- l'état initial, comprenant tous les instants entre sa création (par
un constructeur) et l'invocation de sa méthode start().
- l'état prêt, immédiatement après que la méthode start
ait été appelée.
- l'état bloqué représentent les instants où le thread est
en attente, par exemple d'un verrou, d'un socket, et également quand
il est suspendu (par la méthode sleep(long) qui suspend l'exécution
du thread pendant un certain temps exprimé en nanosecondes).
- l'état terminaison, quand sa méthode run() se termine ou
quand on invoque la méthode stop() (à éviter si possible, d'ailleurs
cette méthode n'existe plus en JAVA 2).
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)