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.

Image non disponible

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.

 
Sélectionnez
public interface Colorable { 
    void setColor(byte r, byte g, byte b); 
}// end Colorable 

class Point2D {
    int x, y;
}// end Point2D 

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; 
    }
}// end ColoredPoint

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.

 
Sélectionnez
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 :

 
Sélectionnez
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. 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 :

 
Sélectionnez
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 :

 
Sélectionnez
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 vivante (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 :

 
Sélectionnez
//: exemplePartage.java
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'ait 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 apparaître 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 :

 
Sélectionnez
//: exempleConcurrent.java
public class exempleConcurrent extends Thread {

        private static int compte = 0;

    public void run() {
                int tmp = compte;
                try {
                        Thread.sleep(1); // ms
                } 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 synchronisé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 manipulent le verrou associé à un objet), mais ne doivent s'utiliser que dans des méthodes synchronized.

  1. wait() : le thread qui appelle cette méthode est bloquée 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.
  2. 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

 
Sélectionnez
//: Evenement.java
// Classe simulant la notion d'événement
//      S. Szulman 1998
public class Evenement
{
        private Boolean Etat; // etat de l'evenement 
        public Evenement() {
                Etat = Boolean.FALSE;
        }
        public synchronized void set() {
                Etat = Boolean.TRUE;
        // debloque les threads qui attendent cet evenement:
                notifyAll();
        }
        public synchronized void reset() {
                Etat = Boolean.FALSE;
        }
        public synchronized void attente() {
                if(Etat==Boolean.FALSE) {
                        try {
                                wait(); // bloque jusqu'a un notify()
                        }
                        catch(InterruptedException e) {};
                }
        }// fin attente
}// fin classe

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 entraîne 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 synchronisation 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). À 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

Image non disponible

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 pratiques, 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 soient arrivées).

III-A. Tubes entre processus UNIX

III-A-1. Enchaînement de commandes en shell

Il est très courant d'enchaîner plusieurs commandes unix lancées depuis le shell. Ainsi, lorsqu'on entre :

 
Sélectionnez
$ 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 quelconque 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 :

 
Sélectionnez
FILE *popen( char *command, char *mode );

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 :

 
Sélectionnez
PipedWriter out = new PipedWriter();
PipedReader in;
try {
    in = new PipedReader(out);
} catch (IOException e) { ... }
  1. 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.
  2. 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.

IV. Auteur

L'auteur Emmanuel Viennet est maître 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.

V. Bibliographie

  1. 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).
  2. 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.
  3. Concurrent Programming in JAVA, par Doug Lea, Addison Weysley. Ouvrage spécialisé sur les aspects multitâches de JAVA (synchronisation de threads).
  4. Java Sun : logiciels, documentations et exemples sur JAVA.
  5. 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.