Unités d’exécution (Threads)

images/bluebelt.png

Multitâche: processus et "fils d’exécution" (threads)

Sur la plupart des systèmes d’exploitation plusieurs tâches sont exécutées "simultanément".

En fait les processus sont gérés par un ordonnanceur d’ensemble qui met en place une stratégie d’utilisation du processeur. Sur les anciens processeurs des ordinateurs individuels il y avait une seule unité d’exécution dans le processeur et l’illusion de tâches s’exécutant en parallèle était due à la succession de très petites tranches de temps pendant lesquelles chaque processus est réellement prix en charge.

Actuellement les ordinateurs sont dotés de processeurs multi-coeurs qui permettent réellement d’avoir plusieurs tâches en parallèles au niveau physique … mais il reste qu’il y a plus de processus qui s’exécutent en parallèle que de coeurs et donc qu’un ordonnanceur est toujours nécessaire.

Le chargement/déchargement du contexte des processus est relativement lourd.

Historiquement le micro-noyau MACH a introduit une notion plus fine de micro-unités d’exécution qui se déroulent à l’intérieur d’un processus. Ces "fils d’exécution" (threads) peuvent partager les données d’un processus et leur activation/désactivation nécessite moins de changements de contexte. L’ordonnanceur peut donc arbitrer des exécutions unitaires à un niveau plus fin.

Le langage Java permet de manipuler ces unités fondamentales de l’exécution.

Le défi à relever est que Java se doit d'être portable alors que chaque système d’exploitation a une gestion des fils d’exécution qui lui est propre!

  • La stratégie des ordonnanceurs varient énormément entre les systèmes (avec des variantes complexes mixant time slicing -chaque unité se voit allouer un quantum de temps- et/ou préemption sur des appels système "lents" -comme les E/S-).
  • Les architectures multiprocesseur peuvent permettre à des "fils" différents de s’exécuter sur des processeurs différents … mais dans ce cas la communication entre unités d’exécution (et les accès mémoire) peuvent obérer les gains de performance attendus. Les architectures "multicoeur" ont, globalement, le même type de problèmes et on peut dire que seule une programmation adaptée permet d’obtenir des gains de performance significatifs!

Le problème est que le programmeur Java n’a pas d’action directe sur les stratégies de bas niveau des ordonnanceurs, du compilateur et de l’exécuteur de la machine virtuelle! Il doit mettre en place des comportements très généraux des unités d’exécutions et de leurs interactions (Il doit rester aussi dans une perspective de portabilité!). En fait les situations complexes de programmation parrallèle ne doivent être gérées que par des programmeurs experts.

Heureusement le package java.util.concurent nous fournit des outils de haut niveau capables de faire face à quelques situations courantes.

Création et exécution d’un Thread

La classe java.lang.Thread fournit un outil d’exécution (avec une pile d’exécution). Le code à exécuter est habituellement fourni par un objet Runnable.

public class VueOrdonnanceur implements Runnable {

        public static int num =  1 ;
   public final int max ;
        private final int id = num++ ;

   public VueOrdonnanceur( int max){
      this.max = max ;
   }

        public String toString() { return "# "+this.id ; }


   public void run () {
      for(int ix = 0 ; ix < max ; ix++) {
         try {
            File temp = File.createTempFile("cpt","") ;
            System.out.println(this+" ("  + ix + ") :"+temp);
            temp.delete() ;
         } catch (IOException exc){/* log */}
      }
   }// fin exécution
   // ... autres codes
}

Ici l’objet Runnable a une méthode run qui contient le code à exécuter dans un fil d’exécution distinct. (Dans la mesure où un fichier temporaire est créé à chaque itération de la boucle nous sommes pratiquement certain qu’un ordonnanceur qui attend des E/S décrochera le fil courant de l'état actif).

Un code qui créé un Thread et le démarre:

   Thread aFaire = new Thread( new VueOrdonnanceur(22)) ; // créé l'unité d'exécution
   aFaire.start() ; // l'exécute en tâche de fond

Une fois amorcé le Thread commence à exécuter la méthode run de l’objet Runnable associé: l’ordonnanceur décide à quel moment les instructions suivantes seront activées ou mises en attente d’exécution.

Quand l’exécution atteint la fin de la méthode run (l’accolade fermante) le Thread n’est plus vivant et devient un zombie (qui deviendra à terme un objet récupéré par le glaneur de mémoire). Il est impossible de faire revivre un zombie! (toute tentative provoquant un IllegalthreadStateException).

[Note]

Si l’objet Runnable fourni en paramètre est null le Thread commence à exécuter sa propre méthode run.

Cela signifie qu’une autre façon de créer un code exécutable est de sous-classer Thread et de spécialiser sa méthode run (en général uniquement si on veut modifier le comportement typique d’un Thread et spécialiser d’autres méthodes).

[Note]

La méthode run ne renvoie pas de résultat et ne propage pas d’exception controlée. Pour ce genre de besoins voir java.util.concurrent.Callable.

--exercice--

Interactions avec l’ordonnanceur

S’effacer devant d’autres Threads

Une exécution qui invoque Thread.yield() demande à l’ordonnanceur de la retirer de l'état actif et de la mettre en état d'éligibilité. Cela peut être utile si, par exemple, le code est longtemps dans une zone où les instructions n’opèrent aucun appel système (par exemple des calculs): sur certains systèmes avec peu ou pas de stratégie par quantum de temps ("time-slicing") l’exécution courante peut accaparer l’usage du processeur au détriment des autres tâches. Dans ce cas on permet explicitement aux autres unités d’exécution de prendre la main.

[Avertissement]Attention!

L’ordonnanceur est en droit de ne pas donner suite à votre demande et peut garder l’unité courante en activité (l’ordonnanceur reste maître des stratégies d’optimisation).

Mise en narcose

Un exécution qui invoque Thread.sleep(millisecondes) va être retirée de l'état actif et mise en "narcose" pendant au moins tant de millisecondes (ce n’est pas absolument précis). A l’expiration de ce délai le fil d’exécution redeviendra éligible (et donc pas immédiatement actif!).

public class AnimationImage // .....

   private Icon[] images ; // javax.swing.Icon
   private JLabel fond ; // javax.swing.JLabel
   public static final long DELAI = 300 ;

   //ATTENTION : CLASSE ANONYME!
   // normalement il faudrait avoir une bonne raison pour faire
  // ainsi une sous-classe de Thread
  // le vrai code serait plutôt: new Thread(new Runnable(...))
   Thread animation = new    Thread() {
      public void run() {
         int count = 0 ;
         int size = images.length ;
         while (true) {
            try{
               Thread.sleep (DELAI) ;
            } catch (InterruptedException exc) {
               break ;
            }
            fond.setIcon(images[count++ % size]) ;
         }
      }
      // autres codes pour arréter l'animation
   } ;
   // ....

Attente d’une fin d’exécution

   Thread chargeur = new ThreadChargeur() ; // une autre tâche
   chargeur.start() ; // maintenant on charge en tâche de fond
   // ....  d'autres codes
   // maintenant attente de la fin du chargement
   try {
      chargeur.join() ;
   } catch (InterruptedException exc) {
      //.....
   }
   // ....

Interruption

Quand une exécution est en cours il est de mauvais aloi de l’interrompre "brutalement": on peut être en train de faire quelque chose qui ne peut pas supporter de rester incomplet ou bloqué. Pour cette raison les méthodes qui permettaient une suspension de l’exécution depuis "l’extérieur" d’un fil d’exécution (méthodes suspend, resume, stop) ont été rendue obsolete (@Deprecated).

A "l’intérieur" du code du Thread on peut chercher à savoir s’il y a eu une requête de suspension de l’exécution en testant Thread.interrupted() (une exécution extérieure a lancé interrupt() sur le Thread courant).

On notera que si l’exécution courante est en narcose (du fait d’appels comme sleep ou join) alors elle est "réveillée" (l’appel se débloque et sort avec une exception controlée de type InterruptedException).

public class SoyezPatient {

   // un code qui indique à l'utilisateur sur la console
   // qu'on s'occupe de lui -bien que l'application lancée ne donne aucun signe de vie-

   public static void main (String[] args) {
      //ATTENTION : CLASSE ANONYME!
      // normalement il faudrait avoir une bonne raison pour faire
      // ainsi une sous-classe de Thread
      // le vrai code serait plutôt: new Thread(new Runnable(...))
      Thread soyezPatient = new Thread() {
         char[] tb = { '|' , '/' , '-' , '\\',  } ;
         int cnt = 0 ;
         public void run() {
            System.out.print(' ') ;
            while(!isInterrupted()) {
               System.out.print("\b" + tb[cnt++ % tb.length]) ;
               try{
                  Thread.sleep(300) ;
               } catch (InterruptedException exc) {  break; }
            }
         }
      };
      soyezPatient.start() ;

      // ON FAIT ICI QUELQUE CHOSE QUI PREND DU TEMPS

      soyezPatient.interrupt() ;
      try {
          soyezPatient.join() ;
      } catch (InterruptedException exc) {
         // rien
      }
      // ON FAIT AUTRE CHOSE
   }
}
[Avertissement]Attention!

Lire attentivement la documentation à propos du positionnement de l'état d’interruption (certains tests repositionnent l'état).

Noter également que certains appels d’E/S peuvent être interrompus.

Etats d’une exécution (version 1)

thread states 1

--exercice--

Le "modèle mémoire" de Java

images/blackbelt.png

Un point extrèmement important (et très difficile!):

public class Surprise {
   private int val = 0 ;
   private int test = 0 ;

   public void modifieur() {
      this.val = 22 ;
      // autres codes
      this.test = 1 ;
      // autres codes utilisant val
   }

   public void lecteur() {
      if(test > 0 ) {
         // on lit "val"
      }
   }
}

Si ce code est exécuté sur un seul fil la valeur de val lue par la méthode lecteur est 22 si la méthode modifieur est invoquée auparavant.

Ce n’est pas garanti si la même instance de Surprise est exécutée "simultanément" par plusieurs Threads!

Comment est-ce possible?

Et bien un compilateur peut optimiser le code binaire de la méthode modifieur pour un ordonnancement efficace des actions dans une chaîne d’instructions pour le processeur (pipeline). Il peut estimer qu’il n’y a pas d’interdépendance entre les modifications de test et val et peut regrouper les instructions élémentaires qui opèrent sur val. ("dans le tuyau" on peut donc avoir test qui "passe" à 1 avant que val passe à 66!).

On peut avoir d’autres réorganisations des actions élémentaires en fonction de l’architecture matérielle (multi-processeurs, multi-coeurs).

Pour décrire un comportement indépendamment des plate-formes la spécification s’appuie sur un modèle mémoire. Chaque unité d’exécution est décrite comme ayant potentiellement des copies partielles d’une "mémoire principale". Quand des fils d’exécution doivent partager des données ils doivent synchroniser leur copie privée de la mémoire avec la mémoire principale (synchronize)

Ceci est nécessaire pour assurer la cohérence selon différents points de vue:

  • antériorité ("happens before"): s’il y a des réorganisations d’instructions quelles sont les barrages ("barriers") qui garantissent un avant et un après?
  • visibilité : quand une information modifiée par un Thread est elle "visible" par un autre Thread ?
  • atomicité : quand plusieurs actions sont étroitement liées ensemble comment éviter qu’un autre Thread vienne s’interposer et conduire à des modifications incohérentes?
public class SansSurprise {
   private int val = 0 ;
   private volatile int test = 0 ; // mise en place d'un barrage

   public void modifieur() {
      this.val = 66 ;
      // autres codes AVANT
      this.test = 1 ;
      // autres codes utilisant "val"  APRES
   }

   public void lecteur() {
      if(test > 0) {
         // on lit val
      }
   }
}

Ici l’utilisation d’une variable volatile nous garantit que:

  • Il n’y a pas de réordonnancement d’instruction qui traverse le barrage constitué par les opérations de lecture/écriture de cette variable.
  • Chaque fois que cette valeur est lue/écrite la mémoire du Thread courant est resynchronisée avec la mémoire principale (chaque modification est visible des autres Threads). Détail: cette proprété est particulièrement intéressante avec les données scalaires "longues" comme long ou double en effet il n’est pas garanti en temps normal que les deux paires de 4 octets qui les composent soient écrites sans interférences entre Threads!

Mais l’atomicité de certaines actions comme test += n; n’est pas garantie!

[Note]ce n’est pas parce qu’un code fonctionne qu’il est correct!

En effet certains codes peuvent "tomber en marche": c’est à dire que, lors de tests, vous vous trouverez dans des circonstances particulières qui font que le code fonctionne alors qu’il n’est pas correct.

En voici encore un exemple trouvé sur le WEB:

// c'est de l'IHM  Swing
public class FenetreAnimation extends JFrame{
  ...
  private boolean animation = true;
  ...
  // un peu plus loin le thread de lancement fait une animation
  while(this.animation){
    ...
  }
  ...
  // un peu plus loin une gestion d'évènements
  // un bouton doit arréter l'animation
  class MonGestionnaireBouton implements ActionListener{
     public void actionPerformed(ActionEvent e) {
      animation = false;
      ...
     }
  }

Ce code est faux (même s’il fonctionne à l’occasion). Bien entendu le booléen animation doit être volatile! (il est modifié par le thread des événements de fenêtrage qui n’est pas le même que le thread d’animation!).

Synchronisations de mémoires

images/bluebelt.png

public class MauvaisGarage {
   private Vehicule[]  parking ;
   private int nbVcl ;
   public MauvaisGarage(int taille) {
      parking = new Vehicule[taille] ;
   }

   public void parquer(Vehicule vcl) throws ExceptionParkingPlein {
      if(nbVcl == parking.length ) {
         throw new ExceptionParkingPlein(nbVcl) ;
      }
      // danger ICI !!!
      parking[nbVcl++] = vcl ;
   }
}

Deux fils d’exécution qui utiliseraient la même instance pourraient provoquer des catastrophes ….

Supposons que nous en soyons à un point où il n’y a qu’une seule place libre dans le garage:

  • Le Thread A peut invoquer la méthode parquer jusqu’au point marqué par le commentaire "danger". A ce moment il est retiré de l'état actif par l’ordonnanceur.
  • Manque de chance: l’ordonnanceur active alors un Thread B qui éxecute complétement la méthode parquer: maintenant le garage est plein!
  • Puis le thread A reprend le cours de son exécution et rajoute un véhicule dans le parking. Le runtime échoue lamentablement!

Nous devons être sûrs que le test sur la taille du parking et l’action effective d’entrée du véhicule font partie d’un ensemble "atomique" d’instructions (c’est à dire qu’une fois qu’un Thread a commencé à l’exécuter aucun autre Thread ne peut rentrer dans cette partie de code et pourrir l'état courant des données).

(((synchronized))

public class Garage {
   private Vehicule[]  parking ;
   private int nbVcl ;
   public Garage(int taille) {
      parking = new Vehicule[taille] ;
   }

   public synchronized void parquer(Vehicule vcl) throws ExceptionParkingPlein {
      if(nbVcl == parking.length ) {
         throw new ExceptionParkingPlein() ;
      }
      parking[nbVcl++] = vcl ;
   }
}

Ici :

  • Le Thread A va acquérir un verrou sur l’instance courante (en Java tout objet est doté d’un Moniteur qui permet cela).
  • Quand l’exécution rentre dans le code de la méthode, la mémoire locale sera synchronisée avec la "mémoire centrale".
  • Quand le Thread B est activé il tente d’acquérir le verrou sur l’objet mais reste bloqué.
  • Quand le Thread A est reactivé il termine ce qu’il a à faire dans le bloc protégé. Les mémoires sont éventuellement resynchronisées et le verrou est relaché.
  • Maintenant le Thread B redevient éligible pour tenter de rentrer dans le bloc.

Blocs synchronized

(((synchronized))

Dans le code de Garage c’est la combinaison des données du tableau parking et de la variable nbVcl qui est "fragile': une des données doit obligatoirement être modifiée avec l’autre quand il y a une modification.

Donc chaque code qui touche à cette combinaison fragile doit être dans un bloc synchronized.

public class Garage {
   // code
   public synchronized void sortie(Vehicule vcl) {
      // code de sortie du véhicule
      // peut "retasser" le tableau
      // et changer la valeur de "nbVcl"
   }

   //....

Contention, blocs synchronized

L’entrée dans un bloc synchronized signifie qu’un Thread peut être bloqué parceque le moniteur a été acquis par un autre Thread. Il y a donc une dégradation potentielle des performances (risque de contention).

La zone synchronized doit être la plus petite possible: bien qu’il soit de bonne pratique d’avoir une méthode synchronized il peut être intéressant d’avoir un bloc synchronized plus étroit (quand c’est possible).

public class Garage {
   // code
   public  void sortie(Vehicule vcl) {
      // Il y a du code ici
      synchronized(this) {
         // code de sortie du véhicule
         // peut "retasser" le tableau
         // et changer la valeur de "nbVcl"
      }
      // d'autres codes de la méthode
   }

   //....

Moniteurs multiples

Il peut y avoir des combinaisons d'états fragiles qui sont indépendantes les unes des autres. Dans ce cas on devra utiliser plusieurs moniteurs différents pour superviser les actions.

public class Garage {
   private Object moniteurPaiement = new Object() ;

   // .....
   // parquer une voiture donne droit à un "Ticket"
   // on a besoin de ce Ticket pour sortir du garage (en payant!)
   // on pourrait avoir des exceptions (le code est simplifié)

   public  Vehicule sortie(Ticket ticket) {
      // d'abord on paye!
      synchronized(moniteurPaiement) {
         // opérations cohérentes de paiement
      }
      // du code
      synchronized(this) {
         // code de sortie du véhicule
      }
      // autres codes de la méthode
   }

   //....

--exercice--

Attente de condition

images/brownbelt.png

Une autre méthode inappropriée pour Garage pourrait être:

   public  Ticket parquerOuAttendre(Vehicule vcl) {
      while(nbVcl == parking.length ) {
         // ne fait rien
      }
      // .....
   }

Une boucle de ce type ("polling") est une très mauvaise idée! :

  • On fait "tourner" le processeur pour rien du tout!
  • Pour certaines stratégies d’ordonnancement on peut très bien acquérir le processeur et ne jamais le lacher! (absence de quantum de temps). La machine se bloque alors!
  • Dans la mesure où nous savons que nbVcl doit être accédé dans un bloc synchronized on risque de ne jamais permettre à aucun autre Thread d’acquérir le verrou.

Attente d’une condition. 

   public  Ticket parquerOuAttendre(Vehicule vcl) {
      // du code
      synchronized(moniteur) { // moniteur peut être "this"
         while(nbVcl == parking.length ) {
            try {
               moniteur.wait() ;
            } catch (InterruptedException exc) {
               // rapport et/ou faire qqch
            }
         }
         parking[nbVcl++] = vcl ;
      } // fin bloc
      // autre code
   }

Points très importants de ce code:

  • Le Thread qui exécute wait va relacher le verrou et rentrer dans une réserve ("wait pool").
  • Ce même Thread sera sorti de la réserve et redeviendra éligible quand un autre Thread invoquera notify() (ou notifyAll()) sur le même moniteur. Le Thread initial, qui était en attente, va tenter d’acquérir le moniteur pour revenir dans le code à l’endroit exact où il l’a quitté.
  • Au réveil qui suit un wait il n’est pas garanti que soit remplie la condition qui nous a initialement conduit à l’attente (une condition comme nbVcl < parking.length). Comment cela est-il possible? :

    • Une interruption parasite ("spurious wakeup") générée par le système (ou une simple interruption demandée par un code) peut sortir le Thread de l'état wait.
    • Une libération sur temporisation peut se produire suite à un appel du type wait(timeout)
    • Quand il cherche à re-acquérir le moniteur le Thread rentre en compétition avec d’autres fils d’exécution (qui peuvent lui "chiper" le verrou et donc modifier la condition!).

Donc la condition qui base une décision de wait doit toujours être re-testée (avec un while par exemple).

Notifications aux Threads "en réserve". 

   // on pourrait avoir des exceptions (le code est simplifié)

   public  Vehicule sortie(Ticket ticket) {
      // du code
      synchronized(moniteur) {
         // code qui "sort" le véhicule
         moniteur.notify() ; // ou notifyAll()
      }
      // code
   }

"Micro-Attente" optimisée

Nous avons vu qu’un boucle de "polling" était une mauvaise idée car elle bloquait le Thread. Il existe toutefois des processeurs qui offrent à un très bas niveau des options optimisées d’attente.

Il est possible de tirer partie des ces optimisations en déclarant le Thread en état onSpinWait. Ce n’est à réserver que pour des attentes d'événements avec des délais extrêmement courts.

public class Traitement {
  // sera modifié par un autre Thread
  volatile boolean événementReçu ;
   ....

  // dans un autre Thread
  while(! événementReçu) {
     Thread.onSpinWait() ;
  }
  traiterEvénement() ;

}

Etats d’une exécution (version 2)

htread states 2

--exercice--