Cours Thread et Communication en Java
Date de publication : 10/04/2005 ,
Date de mise a jour : 10/04/2005
Par
Emmanuel Viennet (http://viennet.developpez.com/)
Le cours thread et communication en Java permettra aux étudiants les bases de la gestion des processus dans
un système d'exploitation.
I. Rappel sur les processus
II. Threads
II-A. Introduction
II-B. Rappel : Notion d'interface en Java
II-C. Création d'un thread
II-C-1. Héritage de la classe Thread
II-C-2. L'interface Runnable
II-D. Méthodes de la classe Thread
II-E. Partage de la mémoire entre Threads
II-F. Synchronisation
II-F-1. Un problème d'accès concurrent
II-F-2. Mot clé synchronized
II-F-3. Synchronisation temporelle : wait et notify
II-F-4. Interblocages
III. Tubes de communication
III-A. Tubes entre processus UNIX
III-A-1. Enchainement de commandes en shell
III-A-2. Création d'un tube
III-A-3. Lancement d'une commande reliée par un tube
III-B. Tubes entre threads JAVA
Auteur
Bibliographie
I. Rappel sur les processus
De manière générale, un processus est un programme en cours d'exécution.
Le système alloue de la mémoire pour chaque processus : segment de code (instructions),
segment de données (allocation dynamique), segment de pile (variables locales et adresses
de retour des fonctions). Chaque processus dispose d'un ensemble de registres du processeur
(qui sont sauvegardés par le système lorsque le processus n'est pas en cours d'exécution).
Le système associe à chaque processus en ensemble d'informations de gestion : identifieur
(PID sous UNIX), descripteurs (fichiers ouverts, connexions réseau), priorités,
droits d'accès, etc.
Un processus peut être dans différents états. Voici une présentation simplifiée des états possibles :
- En exécution (running) : le processus s'exécute sur un processeur du système;
- Prêt : le processus est prêt à s'exécuter, mais n'a pas le processeur (occupé par un autre processus en exécution);
- Bloqué : il manque une ressource (en plus du processeur) au processus pour qu'il puisse s'exécuter
 Figure 1: Transitions entre états d'un processus.
Un processus a besoin de ressources pour s'exécuter : mémoire, processeur,
mais aussi entrées/sorties (fichiers, communications avec d'autres processus
s'exécutant ou non sur la même machine).
Une ressource peut être locale à un processus; dans ce cas, il est seul à l'utiliser et
il n'y a pas de problèmes de synchronisation. Mais dans la plupart des cas, les ressources sont
partagées. Le nombre de points d'accès d'une ressource est le nombre de processus qui peuvent
simultanément l'utiliser. Par exemple, une imprimante ou un processeur sont des ressources à
un seul point d'accès. Pour chaque ressource partagée, il faut mettre en place une politique
de synchronisation.
On dit que des processus sont en exclusion mutuelle s'ils utilisent la même ressource
(dite ressource critique). La section critique d'un programme est la suite d'instructions
pendant laquelle la ressource critique est utilisée.
Notion de démon. Un démon est un processus qui s'exécute en tâche de fond.
Un démon est en général lancé lors de l'initialisation du système, et s'exécute jusqu'à
l'arrêt de celui-ci. Il permet d'implémenter un service : service d'impression (prise en
compte des demandes d'impressions, gestion d'une file d'attente pour partager l'imprimante),
services Internet (ftp, mail, http), gestion de certaines tâches du système d'exploitation
etc.
II. Threads
II-A. Introduction
Un thread, parfois appelé "processus léger", est en quelque sorte un processus à l'intérieur
d'un processus. Nous utiliserons le mot thread en français, ou parfois la traduction approximative
tâche.
Les ressources allouées à un processus (temps processeur, mémoire) vont être partagées entre les
threads qui le composent. Un processus possède au moins un thread (qui exécute le programme principal,
habituellement la fonction main()).
Contrairement aux processus, les threads partagent la même zone mémoire (espace d'adressage),
ce qui rend très facile (et périlleux !) la communication entre threads.
Chaque thread possède son propre environnement d'exécution (valeurs des registres du processeur)
ainsi qu'une pile (variables locales).
L'utilisation des threads et le détail de leur comportement est différente dans chaque système
d'exploitation (Windows NT, UNIX). Certains langages, comme JAVA, définissent leur propre mécanisme
de threads, afin de permettre l'utilisation facile et portable des threads sur tous les systèmes.
Les threads font partie intégrante du langage JAVA. Elles sont gérées grâce à la classe Thread.
II-B. Rappel : Notion d'interface en Java
La programmation des threads en JAVA fait souvent appel à l'utilisation de la notion d'interface.
Nous rappelons dans cette partie les éléments essentiels sur les interfaces en JAVA.
Une interface permet de d'établir un ensemble de comportements abstraits définissant un contrat que va respecter une classe.
- spécification d'un protocole en termes de signatures de méthodes abstraites (sans corps);
- une interface ne contient pas de code;
- une interface ne peut avoir de variables membres mais peut avoir des constantes de classes (variables statiques et finales).
- une classe qui implémente une interface s'engage à remplir le contrat, à donner un corps à toutes les méthodes de l'interface.
Exemple : le code suivant définit une classe ColoredPoint qui est
une sous-classe de Points2D, et implémente l'interface Colorable.
La classe ColoredPoint doit donc implémenter une méthode setColor.
public interface Colorable {
void setColor(byte r, byte g, byte b);
class Point2D {
int x, y;
class ColoredPoint extends Point2D implements Colorable {
byte r, g, b;
public void setColor(byte rv, byte gv, byte bv) {
r = rv; g = gv; b = bv;
}
II-C. Création d'un thread
Nous détaillons dans cette section comment créer et lancer un thread en JAVA.
II-C-1. Héritage de la classe Thread
La première méthode pour créer un thread consiste à créer une classe qui hérite de la
class Thread. Cette classe doit surcharger la méthode run() de la classe Thread.
class MonThread extends Thread {
MonThread() {
... code du constructeur ...
}
public void run() {
... code a executer dans le thread ...
}
}
Une instance de Thread est lancée en appelant la méthode start() définie par la classe Thread :
MonThread p = new MonThread();
p.start();
L'appel de la méthode start() passe le thread à l'état "prêt", ce qui lui permet de démarrer
dès que possible (mais pas nécessairement immédiatement). C'est la machine virtuelle JAVA
qui décide du moment auquel le thread va s'exécuter. Lorsque le thread démarre, JAVA appelle
sa méthode run().
Un thread se termine lorsque sa méthode run() termine.
II-C-2. L'interface Runnable
L'autre moyen de créer un thread est de déclarer une classe qui implémente
l'interface Runnable. Cette interface déclare seulement une méthode : run().
La classe Thread a un créateur qui prend une instance implémentant
Runnable en argument.
La classe se déclare comme dans l'exemple précédent, mais on implémente Runnable au lieu
d'hériter de Thread :
class MonThread2 implements Runnable {
MonThread2() {
... code du constructeur ...
}
public void run() {
... code a executer dans le thread ...
}
}
Pour créer et lancer un thread, on crée d'abord une instance de notre classe MonThread2,
puis une instance de Thread sur laquelle on appelle la méthode start(), comme dans l'exemple suivant :
public static void main(String[] args) {
MonThread2 p = new MonThread2();
Thread t = new Thread(p);
t.start();
}
II-D. Méthodes de la classe Thread
La class Thread offre un certain nombre de méthodes pour contrôler le comportement des threads.
Nous ne mentionnons que quelques fonctionnalités dans ce cours, on consultera la documentation
du JDK pour plus d'informations.
- sleep( long ms ) : le thread qui appelle sleep() est bloqué pendant ms millisecondes (d'autres threads à l'état peuvent alors s'exécuter).
- isAlive() : retourne vrai si le thread auquel on applique la méthode est vivant (c'est à dire à été démarré par start() et que sa méthode run() n'est pas encore terminée. Le thread vivant est donc prêt, bloqué ou en cours d'exécution.
- getPriority() et setPriority( int prio ) permettent de manipuler la priorité du thread.
II-E. Partage de la mémoire entre Threads
Les threads d'un même processus partagent le même espace mémoire.
Chaque instance de la classe thread possède ses propres variables (attributs).
Pour partager une variable untre plusieurs threads, on a souvent recours à une
variable de classe (par définition partagée par toutes les instances de cette classe),
comme dans l'exemple ci-dessous :
public class exemplePartage extends Thread {
private static String chaineCommune = "";
private String nom;
exemplePartage ( String s ) {
nom = s;
}
public void run() {
chaineCommune = chaineCommune + nom;
}
public static void main(String args[]) {
Thread T1 = new exemplePartage( "T1" );
Thread T2 = new exemplePartage( "T2" );
T1.start();
T2.start();
System.out.println( chaineCommune );
}
}
Ce programme donne lieu à la création de trois threads : le thread principal (qui exécute la fonction main()), et deux threads T1 et T2 que l'on crée explicitement. Si on l'exécute, on pourra observer l'un des affichages suivants :
- Aucun affichage : le thread principal affiche chaineCommune avant que le système n'ai donné la main aux threads T1 et T2
- T1 : au moment de l'affichage, le thread T1 s'est exécuté, mais pas T2
- T2 : idem
- T1T2 ou T2T1 : les deux threads se sont exécutés, dans un ordre arbitraire
II-F. Synchronisation
Les résultats évoqués dans la section précédente font bien apparaitre le besoin de mécanisme permettant de synchroniser les traitements effectués par des threads. Il est toujours désagréable que le résultat d'un programme ne soit pas prédictible exactement...
Tout d'abord, il est très simple pour un thread d'attendre que tel autre thread soit terminé :
la méthode join() de la classe Thread bloque jusqu'à ce que le thread termine.
Ainsi, T1.join() permet d'attendre la fin de T1.
II-F-1. Un problème d'accès concurrent
Considérons le problème classique suivant :
public class exempleConcurrent extends Thread {
private static int compte = 0;
public void run() {
int tmp = compte;
try {
Thread.sleep(1);
} catch (InterruptedException e) {
System.out.println("ouch!\n");
return;
}
tmp = tmp + 1;
compte = tmp;
}
public static void main(String args[]) throws InterruptedException {
Thread T1 = new exempleConcurrent();
Thread T2 = new exempleConcurrent();
T1.start();
T2.start();
T1.join();
T2.join();
System.out.println( "compteur=" + compte );
}
}
Les deux threads T1 et T2 accèdent à une même variable partagée compte, travaillent sur une
copie locale tmp qui est incrémentée avant d'être réécrite dans compte. Nous avons intercalé
un appel à sleep pour simuler un traitement plus long et augmenter la probabilité que le
système bascule d'une tâche à l'autre durant l'exécution de run.
II-F-2. Mot clé synchronized
Les problèmes d'accès concurrents se règlent en JAVA à l'aide du mot clé synchronized,
qui permet de déclarer qu'une méthode ou un bloc d'instructions est critique : un seul
thread á la fois peut se trouver dans une partie synchronisée sur un objet.
Ce mécanisme est implémenté par la machine virtuelle JAVA à l'aide d'un verrou (lock,
en fait un sémaphore). Chaque objet JAVA possède un verrou. Pour exécuter une section
de code synchronisée (bloc ou méthode), il faut posséder le verrou. Si un thread commence
à exécuter une section synchronisée, aucun autre thread ne pourra entrer dans une section
synchonisée du même objet (même par une autre méthode) tant que le verrou n'aura pas été
libéré (en quittant la partie synchronisée ou en appelant la méthode wait() que nous
étudions plus loin).
Attention, si l'on veut synchroniser une méthode pour tous les objets de cette classe (accès à des variables de classes partagées par plusieurs threads), il faut que la méthode synchronisée soit une méthode de classe (static).
II-F-3. Synchronisation temporelle : wait et notify
Les méthodes wait(), notify() et notifyAll() permettent de
synchroniser différents threads. Ces méthodes sont définies dans la classe Object
(car elles manipule le verrou associé à un objet), mais ne doivent s'utiliser que dans
des méthodes synchronized.
- wait() : le thread qui appelle cette méthode est bloqué jusqu'à
ce qu'un autre thread appelle notify() ou notifyAll(). Notons que wait() libère
le verrou, ce qui permet à d'autres threads d'exécuter des méthodes synchonisées
du même objet.
- notify() et notifyAll() permettent de débloquer une tâche
bloqué par wait(). Attention, si une tâche T1 appelle wait dans une méthode de
l'objet O, seule une autre méthode du même objet pourra la débloquer; cette méthode
devra être synchronisée et exécutée par une autre tâche T2.
Exemple : implémentation d'une classe Evenement
public class Evenement
{
private Boolean Etat;
public Evenement() {
Etat = Boolean.FALSE;
}
public synchronized void set() {
Etat = Boolean.TRUE;
notifyAll();
}
public synchronized void reset() {
Etat = Boolean.FALSE;
}
public synchronized void attente() {
if(Etat==Boolean.FALSE) {
try {
wait();
}
catch(InterruptedException e) {};
}
II-F-4. Interblocages
La synchronisation entre tâches est un art difficile : si l'on ne synchronise pas assez,
des résultats erronés peuvent se produire (qui plus est de façon pas toujours reproductible);
mais si l'on synchronise mal les traitements, on risque de pénaliser les performances :
l'entrée dans une méthode synchonized est coûteuse (prise du verrou), et on perd les
avantages des traitements en parallèle. D'autre part, un mécanisme de synchronisation
mal conçu entraine facilement l'apparition d'interblocages (deadlocks) : plusieurs
tâches peuvent s'attendre mutuellement, et plus rien ne se passe.
L'ancienne API de JAVA possédait plusieurs autres méthodes de synchonisation qui ont été
supprimées (en fait elles sont deprecated, c'est à dire qu'elles existent toujours pour
que les anciens programmes fonctionnent, mais que leur emploi est fortement déconseillé
et déclenche un message du compilateur). A l'usage, les développeurs se sont en effet
rendu compte que l'usage de ces méthodes (stop, suspend, resume, destroy) entraînait
souvent l'apparition d'interblocages.
III. Tubes de communication
 Figure 2: Tube de communication.
Un tube est un mécanisme de communication unidirectionnel entre deux processus ou deux threads. Les tubes sont très pratique car ils permettent de gérer à la fois les problèmes de communication (à chaque tube est associée un mémoire tampon à laquelle les deux processus vont accéder, l'un en écriture, l'autre en lecture), et les problèmes de synchronisation (la lecture dans un tube est bloquante jusqu'à ce que les données soit arrivées).
III-A. Tubes entre processus UNIX
III-A-1. Enchainement de commandes en shell
Il est très courant d'enchainer plusieurs commandes unix lancées depuis le shell. Ainsi, lorsqu'on entre :
$ ps -au | grep napster
le système lance deux processus, le premier exécutant la commande ps et le second la commande grep.
La sortie standard de ps est redirigée dans un tube, qui la relie à l'entrée standard de grep.
III-A-2. Création d'un tube
Les tubes unix permettent de relier deux processus issus d'un même ancètre, qui doit créer
le tube à l'aide de l'appel système pipe(). L'appel pipe() retourne deux descripteurs
d'entrées/sorties, l'un pour écrire (l'entrée du tube), l'autre pour lire (la sortie du tube).
On peut ensuite créer un ou plusieurs processus fils à l'aide l'appel système fork().
Les processus fils héritent alors des descripteurs et peuvent donc utiliser le tube.
Notons que le système UNIX propose un autre type de tube, les tubes nommés (ou FIFO),
qui correspondent à un fichier dans le système de fichiers, et permettent l'établissement d'une
connexion entre deux processus quelconquent s'exécutant sur la même machine. Ce type de tube
est relativement peu utilisé (on préfère souvent utiliser des sockets) et nous ne les étudierons
pas dans ce cours.
III-A-3. Lancement d'une commande reliée par un tube
La fonction C popen() permet de lancer une commande UNIX reliée à son processus père par un
tube. Cette fonction est pratique car elle se charge de la création du tube et du processus
fils (popen() appelle donc pipe() et fork()). Le prototype de popen est :
FILE *popen( char *command, char *mode );
où command spécifie la commande à lancer (avec éventuellement ses arguments)
et mode le sens de la communication : "w" si l'on écrit dans le tube (c'est à dire
que l'on envoie des données vers la commande lancée), "r" si l'on lit depuis le tube.
Notez la similitude entre popen() et la fonction fopen() qui ouvre un fichier :
ces deux commandes retourne en pointeur sur une structure FILE, que l'on peut
employer avec les fonctions d'entrées/sorties habituelles comme fread(), fwrite(),
fgets(), fprintf(), etc.
Un tube ouvert par popen() doit être fermé en appelant la fonction pclose() (à la place de fclose() pour un fichier).
III-B. Tubes entre threads JAVA
JAVA fournit des classes permettant de créer très simplement des tubes de communication entre threads.
On manipule un tube à l'aide de deux classes : PipedWriter pour l'entrée du tube
(où l'on écrira des données), et PipedReader pour la sortie du tube.
La création du tube complet se fait comme dans cet exemple :
PipedWriter out = new PipedWriter();
PipedReader in;
try {
in = new PipedReader(out);
} catch (IOException e) { ... }
- PipedWriter est une sous-classe de Writer, et possède donc une méthode write() que l'on appelle pour écrire des données dans le tube.
- PipedReader, sous-classe de Reader, a diverses méthodes read() permettant de lire un ou plusieurs caractères depuis le tube.
En général, le programme crée ensuite des threads en leur donnant l'extrémité du tube (l'objet in ou out) dont elles ont besoin.
Auteur
L'auteur Emmanuel Viennet est maitre de conférences à l'IUT de Villetaneuse, et membre de l'équipe Apprentissage, Diagnostic et Agents (ADAge) du laboratoire d'informatique (LIPN). Pour en savoir plus, rendez-vous sur son site : Laboratoire d'Informatique de l'université Paris Nord.
Bibliographie
- Thinking in JAVA (
traduit en français), par Bruce Eckel, Prentice Hall (le texte du livre et les exemples en JAVA sont disponibles gratuitement sur http://www.bruceeckel.com). Ouvrage très complet et clair sur le langage, couvrant aussi les aspects multitâches (threads) et réseaux (sockets).
- JAVA La synthèse, par Gilles Clavel et al., InterEditions. Ouvrage d'introduction à JAVA en français. Un court chapitre sur les threads dans le cadre des applets.
- Concurrent Programming in JAVA, par Doug Lea, Addison Weysley. Ouvrage spécialisé sur les aspects multitâches de JAVA (synchronisation de threads).
Java Sun : logiciels, documentations et exemples sur JAVA.
- Concernant la programmation système en C et le détail des appels systèmes d'UNIX, nous conseillons les ouvrages en français de J.M. Rifflet, La programmation sous UNIX publié par Ediscience, et de R. Card Programmation Linux 2.0, publié par Eyrolles.
|