Les threads

Accueil

Définition

Java permet de concevoir des programmes contenant plusieurs processus.
En principe, tout programme contient au moins un processus.
Celui-ci commence en début de la fonction public static void main()
(Sauf pour les applets)

Un processus (thread) peut être représenté par un pointeur d’instruction, qui désigne l’instruction courante.

En principe, chaque instruction du programme est exécutée une et une seule fois, de la première à la dernière.
Les boucles et conditions permettent de déroger à cette règle :
D’exécuter certaines instructions plusieurs fois ou même pas du tout.
Les fonctions permettent de résumer un ensemble d’instruction en une seule.

Ce qui est nouveau, c’est que Java permet de disposer de plusieurs pointeurs d’instruction.
Un logiciel multi-threads permet d’exécuter plusieurs tâches en même temps.

Spontanément, tout programme possède un thread, il s’appelle main.

Pour vous en convaincre, voyez le message d’erreur affiché lors d’une division par zéro :

Exception in thread "main"

La notion de thread, également appelée processus léger, sous-processus ou fil conducteur, vous est donc familière.
Ce qui est nouveau, c’est que nous allons apprendre à en créer plusieurs et à les gérer.

Notion de thread

Il s’agit de créer une entité qui s’exécutera indépendamment du programme principal qui l’a créée.
Le thread aura donc une vie autonome et indépendante du processus principal dont il dérive.
S’il est conçu comme Démon, il peut même survivre à la terminaison du programme principal.
Il y a un pattern de conception qui repose là-dessus: Le fork and die, avec son dérivé récursif : le fork and start.

Deux threads peuvent donc s’exécuter en parallèle.
Comme votre cœur qui bat pendant que vos poumons respirent et votre intestin digère.

Un exemple plus parlant est le correcteur orthographique qui fonctionne en arrière-plan pendant que votre traitement de texte continue de réceptionner vos frappes clavier et d’afficher, sur le document, les caractères que vous frappez.

Vous n’êtes pas obligés d’attendre la fin de la correction orthographique pour reprendre votre dactylographie.

Il en va de même pour le film que vous commencez à visionner pendant qu’un autre thread continue de le télécharger.
Vous ne devez pas attendre la fin du processus de téléchargement pour commencer le processus suivant: le visionnage.

Les threads apportent donc de la fluidité à un programme, qui peut effectuer une tâche de fond pendant la tâche principale, sans bloquer l’utilisateur.

Conception d’un thread

Il existe deux manières de concevoir un thread :

  1. Implémenter l’interface Runnable
  2. Hériter de la classe Thread

La première est de loin la préférée des développeurs, car elle permet d’hériter d’une autre classe.
Java pratique l’héritage unique. Si on hérite déjà de Thread, on ne peut hériter d’une autre classe.

L’avantage d’étendre la classe Thread est qu’on peut l’utiliser en mode statique, comme dans l’exemple ci-après.

Quelle que soit la méthode que vous choisirez, vous devrez, dans votre thread, implémenter une méthode run qui contiendra le corps du thread, les instructions qu’il devra accomplir.

Cette méthode sera exécutée une et une seule fois, lorsque vous démarrerez votre thread.
Au terme de cette méthode, le thread sera mort, et radié de la mémoire.

Cet exemple crée et lance un thread dénommé Spoutnik, dont le seul rôle est de faire bip à intervalles réguliers.
Vous observerez que la classe principale (Main) se termine immédiatement.
Ensuite, le thread effectue son travail à intervalles réguliers.

Cet exemple illustre plusieurs caractéristiques d’un thread :
Le corps de ses instructions est contenu dans sa méthode run.

Pour le démarrer, il faut invoquer la méthode start().
Si vous invoquiez la méthode run(), vous perdriez le bénéfice du multi-threads.
Ce serait un simple appel de fonction comme un autre.

Essayez de remplacer monSpoutnik.start(); par monSpoutnik.run();.
Vous verrez que la main() se termine à la fin du thread et non plus au début.

Un thread peut avoir un nom sous forme de String.

Un thread peut, de sa propre initiative ou sur injonction extérieure, se mettre en sommeil pour laisser la main à un autre processus.
L’instruction sleep sert à mettre le thread en sommeil pendant un temps donné en millisecondes.

Une fois la procédure run() terminée, le thread meurt.

La classe principale
Le thread Spoutnik

L’exécution simultanée de plusieurs threads

L’intérêt de la notion de thread est justement de pouvoir en créer plusieurs.
Avec l’apparition récente (début 2012) des cartes multi-processeurs, le multi-threads prend soudain un regain d’intérêt.
Car chaque thread peut occuper un processeur. Le gain de temps est réel.

S’il n’existe qu’un seul processeur, ou un nombre de processeurs inférieur au nombre de threads, Java va les faire fonctionner en temps partagé.
Chaque thread reçoit le processeur pendant un quota de temps au terme duquel il en est dépossédé d’autorité (comme si on lui imposait un sleep) au profit d’un autre thread.

Tous les systèmes ne connaissent pas le temps partagé.
Sur certains systèmes, comme Windows, lorsqu’un thread a le processeur, il le garde jusqu’à sa terminaision (la fin de sa méthode run())
Il est alors nécessaire de programmer, à intervalles réguliers, un yeld().

L’instruction yeld() provoque la mise en sommeil d’un thread au profit d’autre thread de priorité identique.
Ce sont donc les threads eux-mêmes qui doivent gérer leur accès au processeur (prise et relâchement).
Cette instruction yeld() n’est évidemment pas nécessaire lorsque le thread contient des sleep() ou procède régulièrement à des entrées-sorties, qui ont pour effet implicite de le mettre en sommeil.

Perfectionnons l’exemple de tout à l’heure :

Désormais, deux threads vont s’exécuter en parallèle.
Le constructeur permet d’en déterminer :

Nous voyons que les deux threads s’exécutent en totale indépendance l’un de l’autre.

La classe principale
Le thread Spoutnik

La priorité d’un thread

Lorsque deux threads se disputent l’accès au processeur, la notion de priorité peut déterminer lequel y aura droit en premier.

Spontanément, un thread hérite de la priorité du thread qui l’a créé.
La priorité est une propriété matérialisée sous forme d’un entier de 1 à 10.
Le thread de priorité supérieure l’emporte sur un thread de priorité inférieure.
Le thread main a une priorité de 5.

Sauf instruction contraire, tous les threads ont donc la priorité 5.
Ils concourent en temps partagé pour le processeur, chacun pendant un quota de temps.

La priorité se règle avec la méthode setPriority(int priority);

Pour que deux threads en conflit fassent jouer leurs priorités, il faut qu’ils soient tous deux prêts ou actifs.
Un thread A de priorité 2 peut donc parfaitement occuper le processeur pendant qu’un thread B de priorité 6 est en sommeil.

Par contre, dès que le thread B se réveille, il dépossède immédiatement A du processeur, et commence ou continue son exécution.

Dans l’exemple ci-dessus, rien ne sert donc de donner à chaque thread une priorité supérieure ou inférieure à l’autre, car les temps d’exécution sont tellement courts (le temps d’un println), qu’ils ne sont jamais en conflit.

L’instruction sleep n’est pas la seule cause de mise en sommeil d’un thread.
Celui-ci peut se mettre volontairement en attente avec wait(), dont un autre thread le réveillera avec notify();.
Lorsqu’un thread effectue une entrée-sortie, il est d’office mis en sommeil pendant le temps d’exécution de celle-ci.
Ce qui laisse aux éventuels threads de priorité inférieure, la possibilité de se réveiller et de continuer à fonctionner jusqu’à ce que le thread de priorité supérieure obtienne son entrée-sortie.

La priorité ne signifie donc absolument pas qu’un thread de priorité supérieure doive avoir terminé sa méthode run, et donc fini son travail, pour que le thread de priorité inférieure puisse commencer le sien.

Un exemple de thread de basse priorité qui fonctionne très efficacement est le garbage collector de Java.

Contrairement à C, Java désalloue spontanément la mémoire d’un objet qui n’est plus référencé.
Ce travail est effectué par un thread de basse priorité : le Garbage Collector.
Il s’enclenche lorsque le programme ne fait rien (attente d’un événement d’interface, par exemple).

Lorsque la machine virtuelle Java vient à manquer de mémoire, elle rehausse la priorité du garbage collector.
garbageCollector.setPriority (10);

Ce qui a pour effet ce bloquer le programme (normalement de priorité 5), le temps de nettoyer la RAM.
Ainsi, on ne se plante pas sur un out of memory error.

Le cycle de vie d’un thread

Un thread naît, vit et meurt.

Lorsque vous concevez un thread : Thread monThread = new Thread(); il est dit .

Lorsque vous invoquez l’ instruction Start : monThread.start(), il est prêt.
Contrairement aux apparences, Start ne fait pas démarrer le thread.
Elle le rend prêt à démarrer. Il aura le processeur dès qu’il le pourra.
Mais si un thread de priorité supérieure est en cours, il attendra que ce thread meure ou sommeille pour démarrer.

Lorsque le système d’exploitation, ou la machine virtuelle Java, lui en donne la possibilité, le thread prend le processeur.
Il est actif.

Ce cycle Actif - En sommeil peut se répéter plusieurs fois dans la vie du thread.

Plusieurs causes peuvent mettre un thread en sommeil :

En fin de sommeil, un thread ne redevient pas immédiatement actif pour autant.
Il redevient prêt.
Il ne sera actif qu’une fois qu’il pourra reprendre le processeur.
Ce n’est pas le thread qui décide de reprendre le processeur. Un thread ne peut que se déclarer prêt.

C’est le système d’exploitation, ou la machine virtuelle Java, qui le donne à un des threads prêts, selon sa priorité.
ou en choisit un au hasard, entre threads de priorités identiques, en commençant par les threads de priorité supérieure.

Une fois que le thread a terminé sa méthode run(), il est mort.

Il n’y a plus moyen de relancer un thread mort en invoquant sa méthode start() : monThread.start()
Il faut en re-créer un nouveau, puis le lancer par start().

La synchronisation de threads

Une fois démarrés, les threads agissent en totale indépendance les uns des autres.
Il peut en résulter des comportements incohérents.

Dans l’exemple suivant, notre application contient deux threads qui ont tous deux accès à une ressource commune : un entier partagé.

L’entier partagé est une classe qui ne contient qu’un seul membre : un nombre entier.
Ce nombre étant privé, un getter et un setter permettent de le modifier.

Le producteur est un thread qui va placer, dans le nombre entier partagé, successivement toutes les valeurs de 1 à 10.
Entre chaque accès, il sommeille pendant une durée aléatoire entre 1 et 4 secondes.

D’un comportement analogue au producteur, le consommateur va lire la valeur commune et l’additionner à une variable interne.
Entre deux lectures, il sommeille aussi pendant une durée aléatoire comprise entre 1 et 4 secondes.

La somme des nombres entiers de 1 à 10 est 55.
Nous verrons que, dans la pratique, la somme recueillie par le consommateur est rarement 55.

La classe EntierPartage contient la ressource commune.
Le producteur modifie l’entier partagé à intervalles irréguliers.
Le consommateur le consulte aussi à intervalles irréguliers.

La classe d’accueil crée l’entier partagé.
Ensuite, elle crée et lance les threads.

La classe d’accueil (Main)
L’entier partagé
Le producteur
Le consommateur

Le problème provient de ce que les deux threads agissent en totale indépendance l’un de l’autre.

Il faudrait un canal de communication entre eux, afin que le consommateur attende que :

Cette synchronisation est possible grâce aux instruction wait() et notify().
 

Wait et Notify

wait()

L’ instruction wait() est une instruction interne au thread, qui lui ordonne de se mettre en sommeil.
A la différence du sleep(), le réveil ne proviendra pas de l’écoulement du temps,
mais de la notification par un autre thread.

notify()

L’instruction notify() a pour effet de réveiller un thread en sommeil consécutif à un wait().

Attention au sommeil infini. Notifiy ne réveille qu’un seul thread en sommeil.
Pour éviter le sommeil éternel, il existe notifyall(),
qui réveille tous les threads en sommeil consécutif à un wait().

Nous allons donc modifier notre objet partagé afin qu’il se souvienne de la dernière action qu’il vient de subir et l’interdise tant que la seconde n’a pas été accomplie.

En d’autres termes :

Avec pour état initial, la permission d’écrire et l’interdiction de lire.

La classe d’accueil, le producteur et le consommateur n’ont pas changé.

Voici le nouveau code source de l’entier partagé.
Désormais, il est Synchronisé

L’entier partagé synchronisé

Les méthodes synchronisées

Qu’est ce qui a changé ?
Tout d’abord, les méthodes setValeur et getValeur sont synchronisées.
Voyez le nouveau mot-clef synchronized dans leur déclaration.

public synchronized void setValeur (int pVal)

public synchronized int getValeur ()

Ce qui signifie qu’un seul thread peut les exécuter à la fois.

Ensuite, elles contiennent une instruction wait() en leur début, pour mettre le thread appelant en attente.
En attente de quoi ?

En attente de la notification, par l’autre thread, de ce que l’opération réciproque a bien été effectuée.

Une autre nouveauté est la variable booléenne modifiable,
qui permet à l’entier partagé de se souvenir s’il vient d’être lu ou écrit,
et d’interdire une seconde tentative de cette action tant que l’autre n’a pas été accomplie.

Chaque thread doit attendre le feu vert de l’autre pour continuer.

 

Reprenez maintenant votre projet initial,
dont vous remplacerez l’ancien entier partagé non synchronisé par celui-ci, synchronisé.
Vous verrez que vous arriverez toujours au total de 55 !

L’arrêt d’un thread

Tout comme il existe une méthode start(), il existe aussi une méthode stop().
Ne l’utilisez jamais !

Invoquer la méthode stop revient à tuer un thread.
C’est brutal, comme tuer un processus ou débrancher la prise d’un ordinateur en service.

Si votre thread contient une boucle, éventuellement contenant elle-même un sleep pour effectuer une action à intervalles réguliers, contrôlez votre boucle avec un booléen.
Ce booléen sera initialisé à true dans le constructeur du thread.
Rien, dans cette boucle, ne mettra le booléen à false.
La boucle sera donc infinie.

Mais, prévoyez dans votre classe, un setter setContact (boolean pFlag) qui permet de mettre le booléen à faux.
Ou une procédure
arret()
{
 contact = false;
}

Ainsi, le thread aura tout le loisir de terminer sa boucle, puis sa méthode run(), qui peut encore contenir des instructions après la fin de boucle (fermeture de fichiers, de connexions, …)

Alors que stop() provoque l’arrêt immédiat du thread, ce qui peut laisser le système dans un état incohérent.