• 6 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 30/11/2016

Avant toutes choses

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Dans ce chapitre, nous allons faire quelques rappels sur ce qu'est le multithreading (ou multitâches). Nous verrons ainsi ce qui différencie un thread d'un processus, quels sont les risques de ce type de programmation etc.
Nous allons commencer doucement en reposant les bases afin de pouvoir aborder les concepts des prochains chapitres plus sereinement. Donc, même si vous pensez être dispensé de rappel, je vous invite tout de même à lire ce chapitre, ça ne vous fera mal. :-°

Bon, si nous commencions !

Le multithreading, kesako ?

Vous ne devriez pas être étranger au terme multitâches qui signifie qu'un programme ou un système peut effectuer plusieurs tâches en même temps. Par exemple, dans un système multitâches, vous pouvez très bien enregistrer un document ou faire une recherche sur Internet via un navigateur tout en écoutant de la musique.
Sauf si vous possédez une machine disposant de plusieurs processeurs (core), le système d'exploitation de la machine doit partager son seul processeur pour toutes ces tâches pour vous donner l’impression que toutes ces tâches sont traitées en parallèle.

Dans le monde des systèmes d'exploitation, il existe deux sortes de multitâches :

  • le multitâche préemptif, qui interrompt les programmes sans demander leurs avis ;

  • le multitâche non préemptif (ou coopératif), où chaque programme peut être interrompu s'il en demande explicitement l'autorisation.

Très bien, mais quelle est la différence, concrètement ?

La différence est de taille, un programme multithreads mal codé tournant sur un système non préemptif peut bloquer tout le système indéfiniment car il ne sera pas possible d'interrompre la (ou les) tâche(s) puisque le programme ne l'a pas demandé tandis que sur un système préemptif on ne laisse pas le choix au programme, on tue la tâche posant problème.

Maintenant parlons un peu vocabulaire. Vous avez compris que thread se traduit par tâche, mais quelle est la différence entre un thread et un processus ?

Ce n'est pas la même chose ?

Et non ! Il existe une différence capitale entre ces deux notions : la possibilité d'accéder ou non aux données (variables, objets, etc).
Des tâches sont lancées depuis un processus. Les données du programme (du processus qui lance les tâches) sont accessibles et utilisables par les tâches lancées en parallèle.
Pour vous donner un exemple simple, voici un code source :

class Test implements Runnable {
   public void run() {
      for (int i = 0; i < 10; i++) {
         System.out.println(Thread.currentThread().getName() + " - " + ++(TestProcessusThread.entier));         
         try {
            Thread.sleep(2000);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
      }
   }
}
public class TestProcessusThread {
   public static Integer entier = 0;
   public static void main(String[] args) {  
      
      
      Thread t1 = new Thread(new Test());
      Thread t2 = new Thread(new Test());
      Thread t3 = new Thread(new Test());
      Thread t4 = new Thread(new Test());
      
      t1.start();
      t2.start();
      t3.start();
      t4.start();
   }   
}

Vous pouvez voir que tous les threads peuvent utiliser et manipuler la variable présente dans la classe TestProcessusThread. Par contre, il n'y a qu'un seul processus (javaw.exe) de lancé lorsque ce code est exécuté :

Processus lancés
Processus lancés

Les différents processus listés dans cette image n'ont pas accès aux données des autres, et heureusement ! Vous imaginez la galère si c'était le cas ?

Mais alors, qu'est-ce qu'un thread ?

Déjà, vous devez savoir qu'un programme Java en cours d'exécution lance trois threads :

Les trois threads principaux
Les trois threads principaux

Chaque thread correspond à une pile d'invocations de méthode. Concrètement, lorsque votre programme Java lance son thread principal, il empile la méthode main() et toutes les invocations présentes en son sein. Le thread reste alors vivant tant qu'il y a encore au moins une méthode d'empilée. Il prend donc fin lorsque la première méthode mise sur la pile prend fin, par exemple dans le cas du thread principal, lorsque la méthode main() est dépilée.
Voici un petit schéma qui résume tout ceci :

Empilement de méthodes
Empilement de méthodes

Un thread est donc une pile d'invocations et créer un nouveau thread revient à créer une nouvelle pile d'invocations de méthode dans un processus.

Vous voyez maintenant ce qu'est un thread mais vous ne vous rendez peut-être pas encore compte des problèmes que cela peut engendrer lorsque vous les utilisez. Par exemple, le code que je vous ai fourni plus haut devrait, en toute logique, se terminer avec la valeur 40 dans notre entier. Cependant, vous aurez peut-être constaté qu'après quelques exécutions, la valeur finale n'est pas toujours 40 et change d'une exécution à une autre…

Eh ! Mais c'est vrai ! Certaines exécutions se terminent avec 38, 37…

Pour bien comprendre ce qu’il se passe, vous allez avoir besoin de comprendre comment votre système gère les threads. C’est ce que nous allons voir ensemble maintenant !

Les threads dans tous leurs états

Vous savez maintenant qu'un thread est une pile d'invocations rattachée à un processus s'exécutant sur un système. Ce système gère le multitâche en jonglant avec toutes les tâches à faire en en exécutant des parties à tour de rôle dans un ordre qu'il définit : on parle d'ordonnancement. Mais que devient un thread que le système ne traite plus ? Comment sait-il s'il faut traiter tel ou tel thread ?
Je vais tenter de vous apporter des éléments de réponses à toutes ces questions.

Pour bien comprendre les problèmes qu'apporte un environnement multithread, il faut avant tout savoir qu'un thread peut avoir plusieurs états et, selon l'état d'un thread, il sera ou non traité par le système. Voici la liste de ces différents états ainsi que leurs codes utilisés dans le langage Java (que vous pouvez trouver avec la méthode getState()) :

État

Description

Nouveau
(NEW)

C'est le statut que prend un thread lorsque vous le créez, en faisant new Thread(…). À ce moment, le thread n'est pas encore en cours d'exécution, il faudra invoquer la méthode start() pour que celui-ci passe dans l'état exécutable.

Exécutable
(RUNNABLE)

Lorsque vous invoquez la méthode start(), votre thread passe dans l'état exécutable. À compter de ce moment, votre thread est prêt à travailler. Comme vous l'avez sûrement deviné, les threads ne restent pas indéfiniment dans cet état car il faut que le système traite tous les autres threads en attente de traitement. La majeure partie des systèmes préemptifs utilise ce qu'on appelle un système de planification préemptif qui alloue une plage de temps à chaque thread pour son exécution. Lorsque ce temps est écoulé, le système passe à l'exécution d'un autre thread en utilisant un système de priorité (nous reviendrons sur cette notion de priorité plus tard).

Toutefois, pour certains systèmes hébergés sur de petits appareils (comme des téléphones ou d'autres appareils mobiles), les threads ne perdent leurs statut exécutable que lorsque le programme les met volontairement en pause.

Bloqué
(BLOCKED,TIMED_WAITING ou WAITING)

Vous pouvez voir qu'un thread peu être considéré comme bloqué (on dit aussi en attente) pour plusieurs raisons.

  • WAITING : lorsqu'un thread attend indéfiniment un résultat ;

  • TIME_WAITING : lorsqu'un thread est mis en pause, via la méthode sleep() par exemple ;

  • BLOCKED : lorsqu'un thread tente d'accéder à une ressource (variable, fichier ou autre) verrouillée par un autre thread.

Terminé
(TERMINATED)

Un thread prend l'état terminé lorsqu'il a effectué toutes ses tâches. On dit aussi qu'il est « mort ». Vous ne pouvez alors plus le relancer par la méthode start().

Voici un petit schéma qui représente les différents états d'un thread au cours de sa vie :

Cycle de vie d'un thread
Cycle de vie d'un thread

Maintenant que vous savez ceci, voyons pourquoi nous n'avons pas toujours le même résultat dans le code vu précédemment.
Dans ce dernier, nos threads ne faisaient que deux choses simples : incrémenter une variable et afficher un message. Nous le mettions ensuite en sommeil avec l'instruction sleep(). Mais rappelez-vous que le système peut aussi mettre en sommeil un thread de son propre chef, et ceci en plein milieu d'une instruction.

D'accord mais comment se fait-il que l'instruction ++(TestProcessusThread.entier) soit coupée ?

Avec vos yeux de développeurs Java vous ne voyez qu'une seule instruction mais, pour le processeur, cette dernière représente au minimum trois instructions différentes !

Pardon ? o_O

Vous avez bien entendu. Incrémenter une variable n'est pas une seule opération pour votre processeur ! On dit que cette opération n'est pas atomique.
Je ne vais pas détailler ce point car ce n'est pas le sujet mais sachez que, pour une simple opération comme celle-ci, le processeur devra :

  • mettre la variable dans une case mémoire ;

  • ajouter la valeur 1 à cette case mémoire ;

  • réaffecter le résultat à la variable d'origine.

Et, dans un environnement multithread, le problème vient du fait que le système met un thread en pause pendant une de ces opérations non atomiques, ce qui engendre donc des résultats incohérents.
Pour pallier à ce genre de désagrément, il existe plusieurs façons de faire. C'est ce que je vous propose de voir dans le chapitre suivant. Mais avant ça, je continue sur ma lancé et je m'en vais vous raconter la fabuleuse histoire des propriétés des threads. :-°

Les propriétés des threads

Les threads utilisés dans le langage Java possèdent plusieurs propriétés intéressantes. Je vous propose de faire un rapide tour d'horizon de celles-ci.

Les groupes de threads

Il vous est possible de créer des groupes de threads qui pourront ainsi être arrêtés en même temps, grâce à l'objet ThreadGroup(String name).
Je ne m'étendrai pas sur ce sujet car nous verrons plus tard une autre façon de faire ce genre de choses mais, au moins, vous savez que ça existe.
Voici comment créer un groupe de threads :

class Test implements Runnable {
   public void run() {
      for (int i = 0; i < 10; i++) {
         System.out.println(Thread.currentThread().getName() + " - " + ++(TestProcessusThread.entier));         
         try {
            Thread.sleep(2000);
         } catch (InterruptedException e) {
            //Ici, lorsque nous invoquons l'interruption de thread
            //la méthode sleep lève une exception
            //nous terminons donc notre boucle à ce moment
            break;
         }
      }
   }
}
public class TestProcessusThread {
   public static Integer entier = 0;
   public static void main(String[] args) {  
      
      //On crée notre groupe en lui donnant un nom
      ThreadGroup tg = new ThreadGroup("Mon groupe");
      
      //Le constructeur de l'objet Thread peut prendre ce paramètre
      Thread t1 = new Thread(tg, new Test());
      Thread t2 = new Thread(tg, new Test());
      Thread t3 = new Thread(tg, new Test());
      Thread t4 = new Thread(tg, new Test());
      
      //On lance nos threads
      t1.start();
      t2.start();
      t3.start();
      t4.start();
      
      //Et après une petite pause
      try {
         Thread.currentThread().sleep(5000);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      //On demande à notre groupe d'interrompre le traitement
      //de tous les threads du groupe, c'est ce qui lève l'exception
      //dans la classe Test
      tg.interrupt();
   }   
}

Les priorités

Les threads Java ont une priorité, c'est à dire que le gestionnaire de thread privilégiera plutôt un thread à forte priorité plutôt qu'un autre pour le rendre RUNNABLE.
Java gère dix niveaux de priorité allant de 1 (correspondant à la constante Thread.MIN_PRIORITY) à 10 (correspondant à la constante Thread.MAX_PRIORITY) en passant par 5 (correspondant à la constante Thread.NORM_PRIORITY).
Plus la priorité d'un thread est élevée, plus il a de chance de passer dans l'état RUNNABLE. Cette priorité peut être modifiée via la méthode setPriority(int prio).

Pour cette raison, il est fortement conseillé de ne pas modifier le niveau de priorité des threads Java et de laisser le niveau par défaut : Thread.NORM_PRIORITY.

Pourquoi ?

C'est simple, si deux threads de priorités différentes sont exécutés et utilisent tous les deux la même ressource (variable, fichier ou autre), il peut se passer la chose suivante : le thread ayant la priorité la plus grande s'impose à celui ayant la priorité la plus basse, et donc votre thread à priorité basse ne peut pas travailler car les ressources dont il a besoin sont constamment monopolisées par le thread à priorité haute. On parle alors de famine.

Les démons

Vous pouvez définir cette propriété grâce à la méthode setDaemon(true) et, je vous rassure, il n'y a rien de maléfique ou de diabolique dans cette méthode. :p
Un démon est en fait un type de thread qui a une fonction particulière : il n'a aucun autre but que de servir d'autres threads. Par exemple, dans le monde Linux, beaucoup de démons sont lancés comme la gestion des impressions, les serveurs web etc. Ce sont des threads qui tournent en permanence pour répondre à un besoin comme une requête HTTP.

Ce type de thread aura la particularité de ne pas être pris en compte pour la fermeture d'un programme. En fait, tout programme Java se termine lorsque tous ses threads sont terminés. Et bien les démons ne sont pas pris en compte pour cette fermeture : si un programme lance 2 threads et 2 démons, le programme se terminera lorsque les deux threads seront terminés même si les démons tournent toujours.

La gestion des exceptions

Il y a encore une chose importante à savoir sur les threads : ceux-ci meurent si une exception non gérée est levée.
Vous avez la possibilité de gérer beaucoup de cas avec des blocs try{…}catch{…} afin de déterminer ce qu'il s'est passé, mais il peut survenir des exceptions dues à des interruptions ou d'autres choses !

On ne peut pas rajouter throws Exception dans la signature de la méthode run() ?

Et non… Vous voyez le problème. Heureusement, depuis Java 5, il existe l'interface UncaughtExceptionHandler qui contient une seule méthode, uncaughtException(Thread t, Throwable e), et qui permet de récupérer les exceptions non gérées avant que le thread ne meure définitivement. Vous pouvez alors définir votre propre gestionnaire d'exceptions non gérées et informer vos threads que celui-ci existe grâce à la méthode setDefaultUncaughtExceptionHandler().

Voici un code d'exemple :

package com.sdz.exception;

import java.lang.Thread.UncaughtExceptionHandler;

public class MyUncaughtExceptionHandler implements UncaughtExceptionHandler{

   public void uncaughtException(Thread t, Throwable e) {
      System.out.println("Une exception de type : " +e.getClass().getName());
      System.out.println("Est survenue dans " + t.getName());
   }
}
package com.sdz.exception;

import java.util.Random;

public class ThreadException extends Thread{

   public ThreadException(String name){
      setName(name);
      setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandler());
   }
   public void run(){
      Random rand = new Random();
      while(true){
         System.out.println(" - " + getName());
         int cas = rand.nextInt(5);
         
         switch(cas){
            case 0 :
               int i = 10 / 0;
               break;
            case 1 :
               Object str = "toto";
               double d = (double) str;
               break;  
            default : 
               System.out.println("aucune erreur...");
               break;
         }
      }
   }
}
package com.sdz.exception;

public class Main {
   public static void main(String[] args){
      
      for(int i = 0; i < 6; i++){
         Thread t = new ThreadException("Thread-" + i);
         t.start();
      }
   }
}

Vos threads sont toujours à l'agonie et finissent par mourir mais, maintenant, vous en connaissez la cause et cette cause est interceptée. :magicien:

Voilà, maintenant que ce petit rappel est fait, nous allons pouvoir rentrer dans le vif du sujet. Je vous propose donc de voir quels sont les mécanismes qui permettent de rendre les threads concurrents en toute sécurité pour vos données.
En avant pour la synchronisation !

Exemple de certificat de réussite
Exemple de certificat de réussite