Les threads en Java


Ce document est aussi disponible en postscript.


1  Généralités

La machine Java fournit le support d’un noyau de gestion d’activités (ou processus légers, ou threads). Lorsqu’une machine Java est démarrée, elle crée une première activité applicative (qui appelle la procédure main de la classe spécifiée), puis des activités peuvent être créées et détruites dynamiquement. La méthode System.exit permet d’arrêter globalement la machine par un appel explicite. Sinon, l’exécution de la machine se poursuivra jusqu’à la terminaison de toutes les activités applicatives. Hormis ces activités applicatives, un certain nombre de activités dites démoniques gèrent des activités de supervision (ramasse-miettes par exemple).

Le cycle de vie d’une activité Java est similaire au cycle de vie standard d’une activité des Threads Posix, ou d’un processus :

Une activité est caractérisée par les attributs suivants :

2  Définition et création d’une activité

Une activité peut être créée de deux manières :

3  Opérations de la classe Thread

3.1  Les constructeurs

Une activité est créée en fournissant plus ou moins de paramètres explicites. Trois éléments interviennent : le groupe, l’objet de la classe Runnable fournissant le code à exécuter, un nom externe.

Par défaut, un nom externe de la forme "Thread-"+<entier> est attribué à l’activité.

        Thread(ThreadGroup, Runnable, String);
        Thread(ThreadGroup, Runnable);
        Thread(ThreadGroup, String);
        Thread(Runnable, String);
        Thread(Runnable);
        Thread(String);
        Thread();

3.2  Méthodes de classe

3.3  Vie de l’activité

3.4  Interruption

La classe Thread fournit un mécanisme minimal permettant d’interrompre une activité : la méthode interrupt (appliquée à une activité) provoque la levée de l’exception InterruptedException si l’activité est bloquée sur une opération de synchronisation (suite à un appel à Object.wait, Thread.join ou Thread.sleep). Sinon, un indicateur interrupted est positionné. Cet indicateur est testé par deux méthodes :

Noter que ce mécanisme ne permet pas d’interrompre une entrée-sortie bloquante en cours (comme une lecture en attente de donnée) : l’indicateur d’interruption est positionné, mais aucune exception n’est levée et l’activité reste bloquée. Ce point limite considérablement l’intérêt de ce mécanisme.

4  Problèmes et difficultés

4.1  Activités + Objets ≠ Acteurs

En dépit du mode de création, l’activité n’est pas associée à l’objet qui a servi à la créer. Considérons l’exemple suivant :

class X extends Thread {
  public void foo() { System.out.println(Thread.currentThread().getName()); }
  public void run() {
        this.foo();      // (1)
  }
}
class Y extends Thread {
  X unX;
  Y (X _x) { unX = _x; }
  public void run() {
         unX.foo();     // (2)
  }
}
public class ConfusionActeurs {
  public static void main (String[] unused) {
      X x = new X();
      x.setName("T1");
      x.start();
      Y y = new Y(x);
      y.setName ("T2");
      y.start();
      x.foo();          // (3)
  }
}

L’appel 1 produit T1, l’appel 2 produit T2 et l’appel 3 produit main, alors que tous s’appliquent au même objet. C’est pourquoi, il est souvent préférable d’utiliser la deuxième forme de création (implantation de Runnable), qui évite la confusion entre l’activité et l’objet.

4.2  Absence de suicide

La classe Thread ne prévoit qu’une seule cause de terminaison d’une activité : quand l’exécution du code associé (méthode run) est terminé (que ce soit en atteignant proprement la fin de la procédure, par un return placé dans run, ou à cause d’une exception non capturée, levée dans une méthode appelée depuis run, ce dernier cas entraînant l’arrêt de la machine virtuelle). Il est cependant possible de réaliser le suicide ainsi :

class ThreadSuicide extends Error {
  // Error est une forme de Throwable qu’il n’est pas nécessaire de
  // déclarer dans la clause throws des méthodes qui la lèvent.
  // Sauf cas très particulier (comme ici), elle ne doit jamais être capturée.
   public static void exit() {
     throw new ThreadSuicide();
   }
}

class X extends Thread {
   public void run () {
     try {
       … foo(); …
     } catch (ThreadSuicide e) {
     }
}

void foo() {  // dans n’importe quelle classe
  ⋮
  if (! bon)
     ThreadSuicide.exit();
}

4.3  Préemption et ordonnancement à court terme

La spécification de Java est très imprécise sur la préemption et l’ordonnancement des activités. La seule chose qu’exige la norme est la gestion des priorités telle que décrite en 1 : quand la machine Java est disponible et doit sélectionner une nouvelle activité, elle choisit une (au hasard) des activités ayant la priorité la plus élevée. La machine Java devient disponible quand une activité se bloque (appel à Object.wait, Thread.join ou Thread.sleep) ou accepte de céder le processeur (appel à Thread.yield).

Deux points sont donc problématiques : que se passe-t-il quand une activité se bloque sur une entrée-sortie ? Que se passe-t-il si une activité de calcul faiblement prioritaire ne relâche pas volontairement le processeur ?

En pratique, il existe (au moins !) deux implantations des Threads dans java :

5  Divers

5.1  Variables localisées

Outre les références globales (attributs des objets, visibles par toutes les activités) et les variables locales (visibles uniquement au sein de la fonction et par l’activité appelante), il existe des variables globales ayant une valeur distincte dans chaque activité. Le nom d’une activité peut être perçu comme une variable localisée1.

De telles variables sont des instances de ThreadLocal ou de InheritableThreadLocal qui fournissent l’interface suivante :

Remarquer qu’il n’est pas possible de consulter ou de modifier la valeur d’une variable localisée d’une autre activité.

L’exemple suivant crée des activités qui disposent chacune d’un numéro différent (déterminé à leur premier appel à numero.get()). Le numéro de l’activité courante est obtenu depuis n’importe quelle méthode en utilisant DemoThreadLocal.numero.get().

class NumeroThread extends ThreadLocal {
  static int numeroCourant = 0;
  protected Object initialValue() {
     numeroCourant++;
     return new Integer(numeroCourant);
  }
}
class Activite implements Runnable {
  public void run() {
     System.out.println("Mon numero est " + (Integer) DemoThreadLocal.numero.get());
  }
}
public class DemoThreadLocal {
  static NumeroThread numero = new NumeroThread();
  public static void main(String[] unused) {
     for (int i = 0; i < 5; i++) {
        Activite a = new Activite();
        new Thread(a).start();
     }
  }
}

5.2  Groupes d’activités

Les activités peuvent être structurées en groupes (classe ThreadGroup). Implicitement, toutes les activités appartiennent au groupe système (racine). D’autres groupes peuvent être créés. Une hiérarchie peut exister entre les groupes selon une structure d’arbre. Cette notion permet en particulier de déclencher une opération sur tous les membres d’un groupe (changement de la priorité des activités du groupe, énumération des activités du groupes…).

5.3  Exercice

Écrire un programme qui contient trois activités :

Proposer des solutions avec et sans join.

6  La synchronisation

Les activités interagissent lorsqu’elles entrent en concurrence pour l’accès à des objets communs ou quand elles coopèrent via des objets partagés. De façon classique, on trouve deux niveaux de synchronisation : d’une part, le problème de l’exclusion mutuelle d’accès à des données (objets) ou à du code (des méthodes), d’autre part, le problème de la synchronisation sur des événements. La combinaison des deux forme dans Java des moniteurs de Hoare dégénérés.

6.1  L’exclusion mutuelle

Pour traiter les problèmes d’exclusion mutuelle, Java propose la définition de sections critiques exprimées à l’aide du mot clé synchronized.

6.2  L’interblocage dû aux verrous

Avec l’utilisation de plusieurs verrous, le risque d’interblocage existe, dès qu’une activité peut posséder plusieurs verrous. Par exemple, le code suivant ne garantit pas l’absence d’interblocage :

synchronized (o1) { synchronized (o2) { … } }
           ||
synchronized (o2) { synchronized (o1) { … } }

La solution pour garantir l’absence d’interblocage par les verrous est la « stratégie par classes ordonnées » : on définit une relation d’ordre (d’importance) sur tous les verrous et on assure que les activités acquièrent les verrous exclusivement par ordre croissant d’importance. Dans l’exemple précédent, si o1 est moins important que o2, la deuxième ligne est erronée. En général, il est aisé de vérifier qu’un code proprement écrit respecte la contrainte d’ordre.

6.3  La synchronisation par objet

Pour synchroniser des activités sur des conditions logiques, on dispose du couple d’opérations permettant d’assurer le blocage et le déblocage des activités, en l’occurrence (wait, notify[All]). Ces opérations sont applicables à tout objet, pour lequel l’activité a obtenu au préalable l’accès exclusif. L’objet est alors utilisé comme une sorte de variable condition.

L’opération wait peut aussi se terminer par une interruption (3.4) ou après un délai de garde spécifié à l’appel.

Dans tous les cas, lorsqu’une activité est réveillée, elle est mise en attente de l’obtention de l’accès exclusif à l’objet.

6.4  Implantation des sémaphores

public class Semaphore {

    private int cpt = 0;
    Semaphore (int c) {
        cpt = c;
    }

    public void P() throws InterruptedException {
        synchronized (this) {
            while (cpt == 0) {
                this.wait ();
            }
            cpt--;
        }
    }

    public void V() {
        synchronized (this) {
            cpt++;
            this.notify ();
        }
    }
}

6.5  Difficultés

6.6  Gestion explicite des files d’attente

La solution la plus simple (mais pas la plus élégante) pour résoudre réellement un problème de synchronisation en Java consiste en la gestion explicite des requêtes bloquées. On définit ainsi une classe Requête, qui contient les paramètres de demande. Quand une requête ne peut pas être satisfaite, on crée un nouvel objet Requête, on le range dans une structure de données, et l’activité demandeuse se bloque sur l’objet Requête. Quand une activité modifie l’état de sorte qu’il est possible qu’une (ou plusieurs) requête soit satisfaite, elle parcourt les requêtes en attente pour débloquer celles qui peuvent l’être. La condition de satisfaction et la technique de parcours permet d’implanter précisément la stratégie souhaitée.

La première difficulté provient de la protection des variables partagées par toutes les activités (état du système et des files d’attente) tout en assurant un blocage indépendant ; cela conduit à l’apparition d’une « fenêtre », où une activité tente de débloquer une autre activité avant que celle-ci n’ait effectivement pu se bloquer. La deuxième difficulté réside dans l’absence d’ordonnancement lors des réveils, ce qui nécessite que la mise-à-jour de l’état soit faite dans l’activité qui réveille et non pas dans l’activité qui demande. On obtient alors la structure suivante (en italique, ce qui concerne spécifiquement le problème résolu : l’allocateur de ressources) :

class Allocateur {
    
    private class Requête {
        boolean estSatisfaite = false;
        int nbDemandé;         // paramètre d’une requête
        Requête (int nb) { nbDemandé = nb; }
    }

    // les requêtes en attente de satisfaction
    java.util.List lesRequêtes = new java.util.LinkedList();
    int nbDispo = …;        // le nombre de ressources disponibles

    void allouer (int nbDemandé) throws InterruptedException
    {
        Requête r = null;
        synchronized (this) {
            if (nbDemandé <= this.nbDispo) { // la requête est satisfaite immédiatement
                this.nbDispo -= nbDemandé;    // maj de l’état
            } else {            // la requête ne peut pas être satisfaite
                r = new Requête (nbDemandé);
                this.lesRequêtes.add (r);
            }
        }
        // fenêtre => nécessité de estSatisfaite (plus en excl. mutuelle donc une autre
        // activité a pu faire libérer, trouver cette requête et la satisfaire avant
        // qu’elle n’ait eu le  temps de se bloquer effectivement).
        if (r != null) {
            synchronized (r) {
                if (! r.estSatisfaite)
                  r.wait();
                // la mise à jour de l’état se fait dans le signaleur.
            }
        }
    } // allouer

    public void libérer (int nbLibéré)
    {
        synchronized (this) {
            this.nbDispo += nbLibere;
            // stratégie bourrin : on réveille tout ce qu’on peut.
            java.util.Iterator it = lesRequêtes.iterator();
            while (it.hasNext()) {
                Requête r = (Requête) it.next();
                synchronized (r) {
                    if (r.nbDemandé <= this.nbDispo) { // requête satisfaite !
                        it.remove();
                        this.nbDispo -= r.nbDemandé; // maj de l’état
                        r.estSatisfaite = true;
                        r.notify();
                    }
                }
            }
        }
    } // libérer
}

1
La norme Posix Threads utilise le terme de specific data.

Ce document a été traduit de LATEX par HEVEA