Blue Flower

Chercher

1. Concurrence

1.1. Comprendre la programmation concurrente

La  programmation concurrente en informatique est la possibilité d'exécuter plusieurs programmes ou plusieurs parties d'un programme en parallèle sur une même machine. Par exemple, si une tâche consommatrice de temps peut être exécutée d'une manière asynchrone ou en parallèle avec d'autres programmes, alors on améliore l'exécution  et l'interactivité des programmes tournant sur une même machine. L'une des plus grandes difficultés de la programmation concurrente est de maintenir cohérent l'état des objets (au sens instance d'une classe) , même lorsque plusieurs threads les partagent.
Un ordinateur moderne a plusieurs UC ou plusieurs cœurs dans une UC. La capacité de démultiplier ces multi-cœurs peut être la clé pour une possibilité d'exécuter parallèlement un grand nombre de programmes.

1.2. Processus  ou  threads

Les  processus en informatique doivent pouvoir fonctionner indépendamment les uns des autres  au sein d'un programme informatique car en principe chaque processus ne doit pas avoir   accès aux données des autres processus. Les ressources d'un processus, par exemple la mémoire  et le temps CPU, lui sont  sont allouées par le système d'exploitation.
Un thread, appelé aussi processus léger, a sa propre pile d'appels, mais il peut avoir accès aux données partagées d' autres threads.
Toute application Java s'exécute dans un processus et  plusieurs threads peuvent  pour réaliser des  traitements de façon parallèle au sein d'une même application.


2. Les limites et les gains de la concurrence

2.1. La loi d'Amdahl

La programmation concurrente  permet d'exécuter certaines tâches d'un programme plus rapidement; ces tâches sont divisées en sous-tâches et ces sous-tâches sont  exécutées en parallèle. Toutefois le temps global d'exécution  est limité par les parties de la tâche qui peuvent être exécutées en parallèle.
Le gain théorique de performance possible peut être calculé par  la Loi d'Amdahl qui stipule que:
Si F est le pourcentage du programme qui ne peut pas fonctionner en parallèle et N  le nombre de processus, alors le gain de performance maximal est 1/(F + ((1-f) / n)).

2.2. Les problèmes liés à la Concurrence

Les threads ont leur propre pile d'appels, mais ils peuvent aussi avoir accès à des données partagées. Cela crée  deux problèmes essentiels,  à savoir la visibilité et l' accessibilités.
Un problème de visibilité arrive si le thread A lit des données partagées  qui ont été changées postérieurement par un thread B et que le thread A, au moment d'utiliser ces données  ignore ce changement.
Un problème d'accès peut arriver si plusieurs threads  ont accès aux mêmes données au même moment et les changent en même temps.

La visibilité et le problème d'accès aux données partagées peuvent mener à :

  • Des erreurs de comportement: dans ce cas le programme ne réagit pas comme il faut lorsqu'il accède aux données et cela peut  provoquer des inter-blocage (deadlocks).
  • Problème de sécurité: Le programme peut créer des données incorrectes.

précédent

1. Thread et processus

1.1. Processus et threads en Java

Tout programme java  s'exécute dans un  processus  en l'occurrence la JVM. Au démarrage de toute application java, l'environnement de java lance le processus principal de l'application.
Dans sa conception, le langage java intègre la notion de  thread qui est, par définition, un processus léger.
Dans une application java, deux objets peuvent interagir quand l'un invoque une méthode de l'autre objet; parfois cela se fait de façon concurrente. A partir de Java 1.5, l'API "java.util.concurrent" traite les problématiques de programmes concurrents donc des threads. Cette API est construit sur les concepts de programmation multithreadée. Les classes et les interfaces de cet API ne modifient pas la notion de thread mais l'enrichissent et étendent les fonctionnalités de l'interface Runnable et de la classe Thread.

1.2. Processus

Un processus dans un système UNIX / Linux est un ensemble de ressources telles que, le segment de code, le segment de données de l'utilisateur, le segment de données du système, le répertoire de travail, le descripteurs de fichiers, l’identificateur de l’utilisateur ayant lancé le processus, l'identificateur du groupe dont est issu le processus, les informations sur l’emplacement des données en mémoire. Sous UNIX comme sous les autres systèmes d'exploitation,  un processus  ce sont donc des informations nécessaires à l’ordonnanceur, aux valeurs des registres, au compteur d’instructions, à la pile d’exécution et aux informations relatives aux signaux.

illustration thread
thread

1.3. Thread

Un "thread" ou "processus léger" est une unité d’exécution autonome qui peut effectuer des tâches en parallèle avec d'autres threads; il est constitué d'un identificateur, d'un compteur de programmes, d'une pile et d'un ensemble de variables locales. Le flot de contrôle d'un thread est  purement séquentiel.
Plusieurs threads peuvent être associés à un "processus lourd" (qui possède donc plusieurs flot de contrôle séquentiels, ou parallèles). Tous les threads associés à un processus lourd ont en commun un certain nombre de ressources, telles que: une partie du code à exécuter, une partie des données, des fichiers ouverts et des signaux.
Quand on parle de thread il faut retenir  l’aspect "unité d’exécution". Contrairement aux processus, les threads au sein d'un processus partagent le même espace d’adressage. Tous ces aspects font que la communication inter-thread occasionne peu de surcharge et le passage contextuel (context switching) d’une thread à une autre est peu coûteux en temps CPU et en consommation de mémoire. Toutefois, si on ne fait pas très attention lors de l'utilisation des threads, des difficultés énormes peuvent surgir notamment lors de l'accès concurrent aux zones critiques d'un programme.

1.4. Gestion des threads

Pour éviter tous les problèmes relatifs à l'accès concurrent aux zones critiques, plusieurs mécanismes de synchronisation sont disponibles:

  • Les sémaphores
  • Les moniteurs
  • Les techniques introduites via les packages de java.util.concurrent

 La partie la plus intrinsèque et évidente de la synchronisation est gérée par des techniques qui s'inspirent des moniteurs et des sémaphores; les packages de "java.util.concurrent" complètent et améliorent les premiers en introduisant des notions comme:

  • Lock objects (objet de verrouillage): les objets de verrouillage supportent les mécanismes de verrouillage et d' exclusion mutuelle permettant de simplifier beaucoup d'applications concurrentes.
  • Executors : cet API de haut niveau permet de lancer et de gérer les threads. L'implémentation de Executor au sein de "java.util.concurrent" fournit une gestion de pool de threads pour toute sorte d'application Java.
  • Concurrent collections: les fonctionnalités de cet API rendent facile le management de larges collections de données et permettent ainsi de réduire le besoin de synchronisation.
  • Atomic variables : Le paquet java.util.concurrent.atomic définit les classes qui prennent en charge des opérations atomiques sur des variables simples; les variables  atomiques ont des fonctionnalités qui facilitent la synchronisation et qui permettent d'éviter les erreurs d'accès en écriture en mémoire.
  • ThreadLocalRandom (depuis la JDK 7): cette classe  permet de fournir une génération efficiente de nombre aléatoire pour gérer les threads dans des applications Java qui en ont besoin.

2 Création de threads

2.1. Généralité

En  Java on a deux façons pour créer un thread; soit on implémente l'interface Runnable du package "java.lang.Runnable", soit on étend la classe Thread du package "java.lang.Thread".
L'interface Runnable possède une seule méthode  appelée run().  La classe Thread, dans sa conception, implémente l'interface Runnable; elle possède d'autres méthodes en plus de la  méthode run(). C'est dans la  méthode run() qu'on l'on implémént le code qui sera exécuté par le thread; cette méthode run()  est appelée par la méthode start() de la classe Thread pour lancer l'exécution du thread. Quand la méthode "start()" lance l'exécution du thread, elle rend la main tout de suite à la fonction au sein de laquelle on l'a utilisée c'est à dire qu'elle n'attend pas la fin de la méthode "run()" pour passer la main. A partir de ce moment le Thread s'exécute parallèlement aux autres threads présents au sein de la JVM.
Un thread vit jusqu’à la fin de l’exécution de sa méthode run(). Lancer un thread signifie demander à la JVM d'exécuter cette classe dans un fil d'exécution particulier au sein d'un processus. Par défaut, un thread est créé dans le groupe courant (c'est à dire celui qui l'a créé). Au démarrage, un thread est créé dans le groupe de nom main. On peut créer d'autres groupes et créer des threads appartenant à ces groupes en se servant de la classe ThreadGroup comme suit:
ThreadGroup groupe = new ThreadGroup("Mon groupe");
Thread processus = new Thread(groupe, "Un processus");

...

2.2. Création de thread par dérivation de la classe Thread

Exemple
Dans cet exemple je crée une application qui affiche juste deux messages; un message via la méthode run() du thread et l'autre via la thread principal main() de l'application.

public class Principale {
private static class Thread1 extends Thread { public void run() { System.out.println("Je suis un Thread JAVA"); } } public static void main(String args[]) { Thread1 th = new Thread1(); th.start(); System.out.println("Je suis dans la fonction principale"); } }

 On crée  un thread appelé Thread1 par  dérivation de la classe java Thread; au sein  de cette sous-classe Thread1 on redéfinit la méthode run(); cette  méthode  sert juste à afficher un message. A l'intérieur de la classe "Principal" on va se servir d'une instance de la classe "Thread1" en déclarant la variable "th" de type Thread1, et pour lancer notre thread on  fait appel à la méthode start(). 

A l'exécution du programme on a à la sortie:
Je suis dans la fonction principale <--- affichage issu du main
Je suis un thread JAVA <--- affichage issu de l'exécution de run() i.e du thread

Ici on a aucun mécanisme qui permet de gérer les threads (main et Thread1; c'est la JVM qui s'en occupe)

2.3. Création de thread par implémentation de l'interface Runnable

L'interface  Runnable permet  l’utilisation  des  threads  dans  la Java  machine; Pour être réellement exécuté, un Runnable  doit être passé en paramètre à un Thread ou un ExecutorService (à voir dans la suite).

public class Principale {
private static class Thread2 implements Runnable { public void run() { System.out.println("Je suis un Thread JAVA"); } } public static void main(String args[]) { Thread2 th2 = new Thread2(); Thread th = new Thread (th2); th.start(); System.out.println("Je suis dans la fonction principale"); } }

A l’exécution du programme on a à la sortie comme ci-dessus:
Je suis dans la fonction principale <--- affichage issu du main
Je suis un thread JAVA <--- affichage issu de l'exécution de th2 i.e du thread

Pareil que ci-dessus au niveau de l'affichage ; on a aucun mécanisme qui permet de gérer les threads (main et Thread2) c'est la JVM qui s'en occupe. Comment donc gérer et contrôler les threads dans un programme écrit en java ?

Certaines méthodes de la Thread permettent de gérer  l'exécution des thread.

2.4. Les méthodes de la classe Thread

Les méthodes statiques:

Les méthodes statiques, appelées aussi méthodes de classe agissent sur le Thread appelant;

  • Thread.sleep( lon g ms ) : bloque le thread appelant pour la durée spécifiée;
  • Thread.yield() : Le thread appelant relâche le processeur  au profit d’un thread de même priorité; 
  • Thread.currentThread() : retourne une référence sur le thread appelant;

Les méthodes d'instance:
Ces méthodes sont  les méthodes qui peuvent agir sur une instance quelconque de la classe;

MonThread p = new Monthread();

  • p.start() : démarre le thread p;
  • p.isAlive(): détermine si p est vivant ou terminé
  • p.join(): bloque l'appelant jusqu'à ce que p soit terminé
  • p.setDaemon(): attache l' attribut "Deamon" à p
  • p.setPriority(): assigne la priorité "pr"  à p
  • p.getPriority(): retourne la priorité de p;
  • ...

3. Quelques exemples

3.1. Premier exemple

 /******** Parallel:  classe regroupant des méthodes utiles **********/
public class Parallel {
    void println( String s) { 
System.out.println(s);
} void println( int i) {
System.out.println(i);
} void tab( int n) { for (int i = 1; i <= n ; i++)
System.out.println(" "); } static void Zzz( ) {
sleep( negexp(0.1) ) ;
} static void Zzz(int n) {
sleep( negexp(n*0.1) ) ;
} static void sleep(double sec) { try {
Thread.sleep((long) (1000 * sec));
} catch (InterruptedException e) {} } static float negexp( double a ) {
return (float) ( - Math.log(Math.random( )) * a );
} static int randint( int a, int b ) {
return (int) ( a + (b-a) * Math.random( ) );
} static boolean draw( double prob ){
return Math.log(Math.random( )) <= prob ; } } /**************** Producteur/Consommateurs ************/ public class ProdCons extends Parallel{ static final int SIZE = 3 ; static int [] T = new int[SIZE]; static int in = 0, out = 0; // ================ les Threads ================ class Producteur extends Thread { public void run( ) {
int tmp;
Zzz( ); for (int i=1; i<=6; i++) { System.out.println( i + " ==>"); tmp = in; T[tmp] = i; in = (tmp+1) % SIZE; Zzz( ); } } } class Consommateur extends Thread{ public void run( ) {
int tmp;
Zzz( ); for (int i=1; i<=6; i++) {
tmp = out; System.out.println(" ==> " + T[tmp] ); Zzz( ); out = (tmp+1) % SIZE; } } } // ====== programme principal ================ public static void main(String args[]) {
new ProdCons( ).go( );
} void go ( ) { System.out.println( " >> Main démarre......." ); new Producteur( ).start( ); new Consommateur( ).start( ); sleep( 5 ); System.out.println( " >> Main termine"); } }

précédent

1. Ordonnancement des tâches JAVA

1.1. Généralité

La notion d'ordonnancement permet de voir clairement la synchronisation. Le choix d'un thread JAVA à exécuter dans un programme (au moins partiellement) se fait parmi les threads qui sont prêts. Si on est sur un système (machine) monoprocesseur, un seul thread peut être actif à un moment donné. Dans JAVA on a un ordonnanceur préemptif, qui est basé sur la priorité des processus: cela veut dire que l'ordonnanceur essaie de rendre actif (si on se place d'abord dans le cas simple où il n'y a pas deux threads de même priorité) le thread prêt de plus haute priorité. Le terme "Préemptif" veut dire que l'ordonnanceur use de son droit de préemption pour interrompre le thread courant de priorité moindre (le thread interrompu reste néanmoins prêt). Toutefois, il ne faut pas perdre de vu qu'un thread actif qui devient bloqué, ou qui termine rend la main à un autre thread, actif, même si celui-ci est de priorité moindre.
Il existe aussi des algorithmes qui permettent d'implémenter l'exclusion mutuelle à partir de quelques hypothèses de base et qui ne font aucunement appel aux priorités car un système de priorités ne peut en effet garantir des propriétés strictes comme l'exclusion mutuelle.

1.2. Moniteurs

Définition

On appellera Moniteur Java une classe dont:
* toutes les variables sont privées (private);
* toutes les méthodes sont synchronisées (synchronized).

Le respect de ces règles n'est pas une nécessité absolue. Mais il garantit un bon fonctionnement du programme. En RC (Région Critique), la variable partagée est globale. En Java, il n'existe pas de variables globales, et on doit passe la référence du moniteur à chaque thread contrôlée.
Si on analyse les diverses implantations du moniteur, on y trouve un socle commun garantissant une exclusion mutuelle d’accès à des variables partagées pendant une section critique de code; cette exclusion mutuelle est définie par «synchronized» en Java, par «protected» en Ada, par «monitor.Enter» et «monitor.Quit» en C#. Dans la terminologie des langages à objets, un objet partagé dont l’accès est contrôlé par un moniteur est accessible uniquement par des méthodes qui s’exécutent en exclusion mutuelle (encore que Java et C# autorisent aussi de ne mettre qu’un bloc d’instructions en exclusion mutuelle). Ce contrôle est assuré par un verrou d’accès à cet objet "monitoré".
Comme on l'a dit précédemment, chaque objet fournit un verrou, mais aussi un mécanisme de mise en attente (forme primitive de communication inter-threads) similaire aux variables de conditions ou aux moniteurs. On a vue que la méthode void wait() attend l'arrivée d'une condition sur l'objet sur lequel il s'applique (en général this). Il doit être appelé depuis l'intérieur d'une méthode ou d'un bloc "synchronized" (il y a aussi une version avec timeout) et que la méthode void notify() notifie un thread en attente d'une condition, de l'arrivée de celle-ci; cette méthode doit être appelée depuis une méthode déclarée "synchronized". Enfin, la méthode void notify_all() fait la même chose que "void notify()" mais pour tous les threads en attente sur l'objet.

1.3. Sémaphores

Un sémaphore binaire (il y a aussi d'autres sémaphores créés à partir des sémaphores binaires) est un objet sur lequel on peut appliquer les méthodes P() (un processus "acquiert" ou "verrouille" le sémaphore) et V() (un processus "relâche" ou "déverrouille" le sémaphore). Tant qu'un processus a un verrou sur le sémaphore, aucun autre ne peut l'obtenir.
On peut voir une implémentation dans un exercice.

précédent suivant

1. Verrous et synchronisation des threads

1.1. Les états d'un thread

Un thread est à tout moment dans un des cinq états suivants:

  1. création
    le thread vient d'être créé, mais n'a pas encore été exécuté; dans cet état on fait une déclaration pour créer un nouveau thread mais ce denier ne démarre pas tout de suite;
    "Thread monThread = new MonThreadClass(); "
  2. exécutable
    le thread est candidat à l'exécution, il attend que le système lui alloue le processeur pendant une certaine durée, appelée quantum de temps;
    "Thread monThread = new MonThreadClass();
    monThread.start();"
  3. en cours d'exécution
    le thread est en cours d'exécution par le processeur (sur un système monoprocesseur, un seul thread peut se trouver dans cet état),
  4. bloqué
    le thread a provoqué une opération bloquante ou s'est "endormi" volontairement pour une certaine durée; on est dans l'une des situations suivantes:
    Une application fait appel à sa méthode "suspend()",
    Une application appelle sa méthode "sleep()" ,
    un thread utilise sa méthode "wait()" pour attendre selon une condition liée à une variable,
    le thread est bloqué sur une I/O .
    Exemple:
    Thread myThread = new MyThreadClass();
    myThread.start();
    try {
    myThread.sleep(10000);
    } catch (InterruptedException e){
    }
  5. détruit
    le thread a fini son exécution ou a été arrêté par un autre thread.
cycle de vie
cycle de vie

Pour faciliter la gestion des différents états que peut traverser un thread, le langage java offre des mécanismes de régulation notamment des systèmes de verrou qui permettent de protéger certaines parties du code afin qu'elles soient exécutées par plusieurs threads de façon cohérente. La façon la plus simple (la plus facile) est de verrouiller certaines méthodes ou variables en les précédant du mot clé synchronized; ce mot "synchronized" en java assure:

  • Qu'un seul thread peut exécuter un bloc de code donné à un moment donné.
  • Que chaque thread qui entre dans un bloc de code "synchronized" est sûr de voir tous les effets des modifications prévues sur les variables partagées

1.2. La synchronisation

La synchronisation est nécessaire pour gérer l'exclusion mutuelle à l'accès d'un zone de code critique par plusieurs threads.
Le mot clé "synchronized" appelé modificateur de méthode d'instance contrôle les modifications de variables partagées au sein d'une zone critique . Cela assure qu' à chaque instant, un seul thread peut exécuter et modifier des variables dans zone critique les autres threads attendront leur tour.

Comme les threads d'une même Machine Virtuelle partagent le même espace mémoire, ils ont accès à n'importe quelles méthodes ou quels champs d'objets existant dans cette espace. En principe cela est très pratique, mais dans certains cas, il est préférable que deux threads n'aient pas accès n'importe quand à certaines données. Exemple, Un thread qui doit modifier une variable et un autre qui a besoin de la lire pour l'afficher ne doivent pas y avoir accès au même moment. Dans ce cas il est impératif de les synchroniser.

1.3. Moniteur

Dans le langage Java, tout objet (instance d'une classe) comporte un verrou intrinsèque ou verrou implicite qui peut empêcher que deux threads différents accèdent simultanément à un même objet lorsqu'une méthode de l'un des threads est déclarée synchronized sur cet objet. Ce verrou s'appelle moniteur; Le mot-clé synchronized permet de demander l'acquisition d'un moniteur donné pour la durée d'un bloc de code.
Si une ou plusieurs méthodes sont déclarées synchronized dans une classe, si on a un objet de cette classe et si l'une des méthodes "synchronized" est invoquée sur l'objet en question, deux cas se présentent:

  1. Soit l' objet n'est pas verrouillé
    Dans ce cas le système pose un verrou sur cet objet puis la méthode est exécutée normalement et quand la méthode est terminée, le système retire le verrou sur cet Objet.
  2. Soit l'objet est déjà verrouillé
    Si le thread courant n'est pas celui qui a verrouillé l'objet, le système met le thread courant dans l'état bloqué, tant que l'objet est verrouillé. Une fois que l'objet est déverrouillé, le système remet ce thread dans l'état exécutable, pour qu'il puisse essayer de verrouiller l'objet et exécuter la méthode "synchronized".

Le modificateur synchronized ne garantit pas l'ordre dans lequel les threads seront exécutés. Pour cela, il existe une méthode de la classe Object qui permet de mettre en attente volontairement un thread sur un objet; c' est la méthode "wait()", il existe une autre méthode de la classe Object qui permet de prévenir les threads en attente sur cet objet que celui-ci n'est plus verrouillé; ce sont les méthodes "notify()" (pour avertir un seul thread mis en attente sur l' objet verrouillé par synchronized )) ou "notifyAll()" (pour tous les threads mis en attente sur l' objet verrouillé par synchronized).
Ces méthodes ne peuvent être invoquées que sur un objet verrouillé par le thread courant, c'est-à-dire le thread qui est en train d'exécuter une méthode ou un bloc synchronized ( verrouillé sur cet objet). Si ce n'est pas le cas, une exception IllegalMonitorStateException est déclenchée.
Quand la méthode wait() est invoquée sur un objet (qui peut être this), le thread courant perd le contrôle; il est mis en attente et l'ensemble des verrous est retiré. Comme chaque objet Java mémorise l'ensemble des threads mis en attente sur lui, le thread courant est ajouté à la liste des threads en attente sur l'objet qui a invoqué "wait()".
Un thread mis en attente est retiré de la liste d'attente quand une des trois raisons suivantes survient :

  1. Le thread1 a été mis en attente en donnant en argument à la méthode "wait()" un délai qui a fini de s'écouler,
  2. Le thread courant a invoqué la méthode "notify()" sur objet1, et thread1 a été choisi parmi tous les threads en attente,
  3. Le thread courant a invoqué la méthode "notifyAll()" sur l'objet. Le thread est mis alors dans l'état exécutable, et essaie de verrouiller l'objet, pour continuer son exécution. Quand il devient le thread courant, l'ensemble des verrous qui avait été enlevé de l'objet à l'appel de la méthode "wait ()" est remis sur l'objet, pour que le thread et l'objet se retrouvent dans le même état qu'avant l'invocation de "wait()".

Un thread mis en attente en utilisant la méthode "wait()" sans argument sur un objet, ne peut redevenir exécutable qu'une fois qu'un autre thread a invoqué "notify()" ou "notifyAll()" sur ce même objet. Donc la méthode "wait()" doit toujours être utilisé avec la méthode "notify()", et être invoqué avant cette dernière méthode.

Qu'est ce qui se passe si notify() est appelé et qu'il n' y a pas de thread mis en attente par wait()?

Dans la pratique ce cas ne peut pas avoir lieu; toutefois si cela arrive, la méthode notify() n'a aucun effet et la notification est perdue.

Exemple d'utilisation de wait()/notify()

Dans notre exemple on commence par créer une classe qui gère un message; cette classe sera utilisée dans deux autre classes à savoir la classe Waiter et la classe Notifier.

La classe Waiter implémente l'interface Runnable. Un objet Waiter attend un objet Notifier pour terminer son exécution sur l'objet Message; cela est rendu possible par la le clé "synchronized" sur l'objet Message.

La classe Notifier implémente l'interface Runnable. L'objet Notifier est endormi pendant 3 secondes. Après ça, il modifie le message et puis notifie le thread en attente sur l'objet Message qu'il peut reprendre son exécution; Ceci est fait au sein d'un "bloc synchronized" synchronisé sur l'objet Message.

La classe WaitNotifyExample crée un objet Message. Elle crée aussi un objet Waiter avec l'objet Message. Elle crée et démarre un thread avec l'objet Waiter. Après ça, elle crée un objet Notifier avec l'objet Message. elle crée et démarre un autre thread avec l'objet Notifier.

package com.omara;

public class Message {
	private String text;
	public Message(String text) {
		this.text = text;
	}
	public String getText() {
		return text;
	}
	public void setText(String text) {
		this.text = text;
	}
}
//+++++++++++++++++++++++++++++
package com.omara; import java.util.Date; public class Waiter implements Runnable { Message message; public Waiter(Message message) { this.message = message; } @Override public void run() { synchronized (message) { try { System.out.println("Waiter is waiting for the notifier at " + new Date()); message.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("Waiter is done waiting at " + new Date()); System.out.println("Waiter got the message: " + message.getText()); } } //++++++++++++++++++++++++++ package com.omara; import java.util.Date; public class Notifier implements Runnable { Message message; public Notifier(Message message) { this.message = message; } @Override public void run() { System.out.println("Notifier is sleeping for 3 seconds at " + new Date()); try { Thread.sleep(3000); } catch (InterruptedException e1) { e1.printStackTrace(); } synchronized (message) { message.setText("Notifier took a nap for 3 seconds"); System.out.println("Notifier is notifying waiting thread to wake up at " + new Date()); message.notify(); } } } //++++++++++++++++++++++++++ package com.omara; public class WaitNotifyExample { public static void main(String[] args) { Message message = new Message("Howdy"); Waiter waiter = new Waiter(message); Thread waiterThread = new Thread(waiter, "waiterThread"); waiterThread.start(); Notifier notifier = new Notifier(message); Thread notifierThread = new Thread(notifier, "notifierThread"); notifierThread.start(); } }

Voir ci-dessous l'affichage de l'exécution du programme. En regardant la sortie après l'exécution du programme WaitNotifyExampleFrom, on note que le le thread Waiter est mis en attente pendant que le thread Notifer détient le verrou sur l'objet message. Lorsque le Thread Notifier notifie qu'il a fini , l'exécution du thread Waiter reprend.

Affichage du résultat de l' exécution de WaitNotifyExample:

Waiter is waiting for the notifier at Tue May 19 17:52:57 PDT 2009
Notifier is sleeping for 3 seconds at Tue May 19 17:52:57 PDT 2009
Notifier is notifying waiting thread to wake up at Tue May 19 17:53:00 PDT 2009
Waiter is done waiting at Tue May 19 17:53:00 PDT 2009
Waiter got the message: Notifier took a nap for 3 seconds

 

1.4. Bloc synchronisé

On appelle bloc synchronisé, tout bloc portant une déclaration synchronized. On peut poser le mot-clé synchronized de trois façons. Pour chacune de ces façons, le bloc synchronisé fonctionne avec un paramètre de synchronisation.

  • On peut ajouter le mot-clé synchronized en tant que modificateur d'une méthode statique. Dans ce cas le paramètre de synchronisation est la classe qui possède ce bloc.
  • On peut l'ajouter comme modificateur d'une méthode non statique. Dans ce cas, le paramètre de synchronisation est l'instance de cette classe dans laquelle on se trouve. On peut accéder à cette instance par le mot-clé this.
  • On peut enfin créer un bloc commençant par le mot-clé synchronized. Dans ce cas, le paramètre de synchronisation doit être passé explicitement à l'ouverture de ce bloc. Ce peut être tout objet Java.

1.5. Fonctionnement d'un bloc synchronisé

La paramètre du bloc synchronisé joue le rôle de garde pour ce bloc synchronisé. Cet objet possède une unique clé. Cette clé est nécessaire pour pouvoir entrer dans le bloc synchronisé, et exécuter les instructions qu'il contient.
Quand un thread obtient cette clé, il entre dans le bloc, et exécute le code qui s'y trouve. À la sortie du bloc, il rend la clé à l'objet qui garde le bloc.
Aucun thread ne peut entrer dans ce bloc s'il ne possède pas la clé qui permet d'y entrer.
Supposons qu'un thread se présente à l'entrée du bloc, alors qu'un premier est dedans, en train de l'exécuter. La clé n'étant pas disponible, cet autre thread se verra refuser l'accès au bloc. Il devra attendre que le premier thread rende la clé afin de pouvoir y entrer à son tour.
Tout objet Java possède une clé, il s'agit d'un mécanisme bas niveau, présent sur la classe Object. Lorsque le langage Java a été conçu, cette idée était considérée comme excellente, aujourd'hui elle est plutôt considérée comme insuffisante. Il n'empêche, cela explique que tout objet Java peut être utilisé pour garder un bloc d'instructions.
Ce mécanisme de synchronisation permet de résoudre le problème d'accès de façon concurrente à une ou plusieurs variable que nous avons exposé. Cela dit, il pose un problème de performance. Effectivement, si un grand nombre de threads sont en concurrence d'exécution, tout bloc synchronisé devient un goulet d'étranglement dans lequel les threads ne peuvent passer qu'un par un.
L'accès à l'état d'un objet, et la modification de cet état, est un des quelques problèmes cruciaux de la programmation concurrente. Jusqu'à la version 4, Java n'exposait pas beaucoup d'éléments pour traiter ces questions. Depuis la version 5, l'API s'est considérablement enrichie (voir Concurrence: Amélioration).

1.6. Exemple avec synchronized

Pour illustrer le fonctionnement du mot "synchronized" on va définir deux versions d'une classe appelée Counter; on utilise cette classe dans un programme appelé "CounterTest."

Première version de Counter sans synchronisation:

public class Counter {
    private int value = 0;
    public void increment() {
        value++;
    }
    public int getValue() {
        return value;
    }
}

Pour tester le comportement de ce compteur, le programme CounterTest ci-dessous lance NB_THREADS threads qui effectuent chacun NB_INCREMENTS appels à la méthode "increment()". A la fin du programme, on s'attend donc à ce que la valeur du compteur soit de "NB_THREADS * NB_INCREMENTS".
Avec 5 threads et 100 incréments, le compteur vaut normalement 500. Le test est répété plusieurs fois afin de vérifier sa stabilité.
La version de Counter sans synchronized dans la classe CounterTest donne des résultats incohérents à chaque fois qu'on augmente le nombre de thread; cela donne:
Avec un seul Thread on a:
1 thread(s) X 1000000 increments = 1000000
1 thread(s) X 1000000 increments = 1000000
1 thread(s) X 1000000 increments = 1000000
1 thread(s) X 1000000 increments = 1000000

Avec 2 threads on a:
2 thread(s) X 1000000 increments = 1007644
2 thread(s) X 1000000 increments = 1999360
2 thread(s) X 1000000 increments = 1999384
2 thread(s) X 1000000 increments = 1784593

Deuxième version avec "synchronized".

public class Counter {
    private int value = 0;
    private final Object valueMonitor = new Object(); // Protects "value"
    public void increment() {
        // Acquire the monitor before modifying the data
        synchronized (valueMonitor) {
            value++;
        }
    }
    public int getValue() {
        // Acquire the monitor before reading the data too !
        synchronized (valueMonitor) {
            return value;
        }
    }
}

La solution à ce problème consiste à s'assurer que les trois opérations seront toujours exécutées de manière atomique, c'est-à-dire que leur séquence sera toujours exécutée en intégralité et sans interruption de la part d'autres threads.
Les moniteurs et les locks de Java donnent justement cette garantie.

public class CounterTest {
 
    private static final int NB_THREADS = 1;
    private static final int NB_INCREMENTS = 1_000_000;
 
    public static void main(String[] args) throws InterruptedException {
 
        final Counter counter = new Counter();
 
        // A job that increments the counter NB_INCREMENT times
        Runnable incrementer = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < NB_INCREMENTS; i++) {
                    counter.increment();
                }
            }
        };
 
        // Submit several jobs that increment the counter
        ExecutorService pool = Executors.newFixedThreadPool(4);
        for (int i = 0; i < NB_THREADS; i++) {
            pool.submit(incrementer);
        }
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
        // Verify the counter's final value
        int counterValue = counter.getValue();
        System.out.printf("%d thread(s) X %7d increments = %d %n", NB_THREADS, NB_INCREMENTS, counterValue);
    }
}

L'exécution du test avec 5 threads donne cette fois le résultat attendu, et de manière reproductible :
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000

1.7. Deadlock

Une mauvaise gestion de la synchronisation entre blocs peut mener à une situation de blocage total d'une application appelée deadlock.
Une deadlock intervient lorsqu'un premier thread "T1" se trouve dans un bloc synchronisé "B1", et est en attente à l'entrée d'un autre bloc synchronisé "B2". Malheureusement, dans ce bloc "B2", se trouve déjà un thread "T2", en attente de pouvoir entrer dans "B1".

Ces deux threads s'attendent mutuellement, et ne peuvent exécuter le moindre code. La situation est bloquée, la seule façon de la débloquer est d'interrompre un des deux threads .

Construisons un exemple de deadlock, afin d'illustrer la situation.

1.8. Exemple de Deadlock

public class TestThread { 
    public static Object Lock1 = new Object(); 
    public static Object Lock2 = new Object(); 
    public static void main(String args[]) { 
     ThreadDemo1 T1 = new ThreadDemo1(); 
     ThreadDemo2 T2 = new ThreadDemo2(); 
     T1.start(); 
     T2.start(); 
   } 
   private static class ThreadDemo1 extends Thread { 
    public void run() { 
     synchronized (Lock1) { 
     System.out.println("Thread 1: Holding lock 1..."); 
     try { 
      Thread.sleep(10); 
     } catch (InterruptedException e) {} 
     System.out.println("Thread 1: Waiting for lock 2..."); 
     synchronized (Lock2) { 
      System.out.println("Thread 1: Holding lock 1 & 2..."); 
     } 
   } 
  } 
 } 
   private static class ThreadDemo2 extends Thread { 
     public void run() { 
      synchronized (Lock2) { 
       System.out.println("Thread 2: Holding lock 2..."); 
        try {
                 Thread.sleep(10); 
        } catch (InterruptedException e) {} 
        System.out.println("Thread 2: Waiting for lock 1..."); 
      synchronized (Lock1) { 
        System.out.println("Thread 2: Holding lock 1 & 2..."); 
      } 
    } 
  } 
 } 
} 

Quand on compile et qu'on exécute ce programme on obtient une situation de Deadlock; à la sortie on obtient ce qui suit :

Thread 1 : Holding lock1. ...
Thread 2 : Holding lock2. ...
Thread 1 : Waiting for lock2. ...
Thread 2 : Waiting for lock1. ... 

2. Priorité

2.1. Généralités

On peut affecter à chaque thread une priorité, qui est un nombre entier. Plus ce nombre est grand, plus le processus est prioritaire. void setPriority(int priority) assigne une priorité au thread donné et int getPriority() renvoie la priorité d'un thread donné.
L'idée est que plus un processus est prioritaire, plus l'ordonnanceur JAVA doit lui permettre de s'exécuter tôt et vite. La priorité peut être maximale: Thread.MAX_PRIORITY(en général 10), normale (par défaut): Thread.NORM_PRIORITY (en général 5), ou minimale Thread.MIN_PRIORITY(elle vaut en général 0). Pour bien comprendre cela, il nous faut décrire un peu en détail la façon dont sont ordonnancés les threads JAVA.
On peut également déclarer un processus comme étant un démon ou pas:
setDaemon(Boolean on);
boolean isDaemon();
La méthode setDaemon doit être appelée sur un processus avant qu'il soit lancé (par la méthode start()). Un processus démon est typiquement un processus "support" aux autres. Il a la propriété d'être détruit quand il n'y a plus aucun processus utilisateur (non-démon) restant (en fait il y a un certain nombre de threads systèmes par JVM: ramasse-miettes, horloge, etc...). En fait, il est même détruit dès que le processus qui l'a créé est détruit ou terminé.

2.2. Niveau de sécurité

Quand peut-on partager un objet entre plusieurs threads en toute sécurité, sans risquer de voir l'état de cet objet corrompu ou avoir des problèmes de cohérence entre ce que voit chaque thread sur l'état de l'objet ? Afin de répondre à cette question, des niveaux de sécurité ont été définis. Vous devriez toujours documenter vos classes en fonction de ces niveaux, pour permettre aux programmeurs qui utilisent vos classes de savoir dans quelle mesure ils peuvent les utiliser dans des programmes concurrents.

Cinq niveaux ont été définis et sont résumés par Joshua Bloch dans son livre Effective Java. Voici les cinq niveaux, le premier étant le plus sûr, et les suivants étant de moins en moins sûrs :

  1. Immutable
  2. Unconditonally Tread-Safe
  3. Conditonally Tread-Safe
  4. Not Thread-Safe
  5. Thread-Hostile

précédent

 

1. Gestion explicite des files d’attente

1.1. Généralité

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 (voir Exemple):

1.2. Exemple

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
}

précédent