IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Thinking in Java, 3rd ed. Revision 4.0


précédentsommairesuivant

XIII. Concurrence

Les objets fournissent un moyen de diviser un programme en sections indépendantes. On a aussi, souvent, besoin de convertir un programme en sous-tâches distinctes, exécutables indépendamment les unes des autres.

Chacune de ces tâches indépendantes est appelée un thread. On les programme comme si chaque thread s'exécutait tout seul et avait l'UCT pour lui tout seul. En fait, un mécanisme sous-jacent répartit le temps UCT à votre place, mais, en général, point n'est besoin d'y penser, ce qui facilite grandement la programmation multitâche.

Un processus est un programme exécutable indépendant qui possède son propre espace d'adressage. Un système opératoire multitâche peut exécuter plus d'un processus (programme) à la fois en transférant l'UCT périodiquement d'une tâche à une autre, tout en donnant l'impression que chaque processus progresse tout seul. Un thread est un flux de contrôle séquentiel unique à l'intérieur d'un processus. Par conséquent, un processus peut contenir plusieurs threads s'exécutant concurremment.

Le multitâche peut servir à bien des usages, mais, en général, on a une partie d'un programme qui est lié à une ressource ou un évènement particuliers, et l'on ne veut pas que cela suspende l'exécution du reste du programme. On crée alors un thread associé à l'évènement ou à la ressource et on le fait s'exécuter indépendamment du programme principal.

La programmation concurrente, c'est comme entrer dans un nouveau monde et apprendre un nouveau langage de programmation, ou tout du moins un nouveau jeu de concepts linguistiques. Avec l'arrivée de la gestion des threads dans la plupart des systèmes opératoires des micro-ordinateurs, sont aussi apparues des extensions pour les threads dans les langages de programmation ou les librairies. En tout cas, la programmation des threads :

  • Semble mystérieuse et suppose un certain changement dans la façon de penser la programmation ;
  • Est semblable à la gestion des threads dans d'autres langages de programmation, de sorte que si l'on comprend les threads, on comprend un langage commun.

Et bien que la gestion des threads puisse faire apparaître Java comme un langage de programmation plus compliqué que les autres, ce n'est pas entièrement la faute de Java. Les threads sont pleins de pièges.

Comprendre la programmation concurrente est aussi difficile que comprendre le polymorphisme. Avec quelque effort, on peut comprendre le mécanisme de base, mais il faut de sérieuses études et une vive intelligence pour saisir véritablement le sujet. Le but de ce chapitre est de donner de solides fondements des bases de la concurrence, de façon à comprendre les concepts et à écrire des programmes multitâches corrects. Il faut prendre conscience du fait que l'on peut facilement devenir trop confiant ; ainsi, si l'on écrit quelque chose d'un peu complexe, il faut lire des livres consacrés à ce sujet.

XIII-A. Motivation

Un des arguments les plus forts en faveur de la concurrence est de produire une interface utilisateur qui réagit bien. Prenons l'exemple d'un programme qui effectue une opération sollicitant fortement l'UCT, et qui finit donc par ignorer les saisies utilisateur et par ne plus réagir du tout. Le problème fondamental est que ce programme doit continuer à effectuer ses propres opérations, tout en redonnant le contrôle à l'interface utilisateur, de telle sorte qu'il puisse lui répondre. Par exemple, s'il existe un bouton « quitter » dans l'interface utilisateur, on ne veut pas être forcé de l'examiner dans chaque section de code, mais on veut tout de même que ce bouton réagisse correctement, comme si on le testait régulièrement.

Une méthode conventionnelle ne peut continuer à effectuer ses propres opérations tout en redonnant le contrôle au reste du programme. De fait, cela semble impossible à faire, comme si l'UCT devait être à deux endroits à la fois, mais c'est précisément l'illusion que donne la concurrence.

La concurrence peut aussi être utilisée pour optimiser le débit. Par exemple, il se peut qu'on veuille accomplir un travail important, pendant que l'on est bloqué en attente d'entrée sur un port E/S. Sans la programmation par threads, la seule solution envisageable serait de tester le port E/S, ce qui est peu commode et pourrait se révéler particulièrement difficile à faire.

Avec une machine multiprocesseur on peut répartir des threads multiples entre les processeurs, ce qui peut considérablement augmenter le débit. C'est souvent le cas avec des serveurs web multiprocesseurs, qui peuvent répartir de nombreuses requêtes utilisateurs entre les UCT dans un programme qui alloue un thread par requête.

Il ne faut pas oublier qu'un programme comprenant de nombreux threads doit pouvoir fonctionner sur une machine à UCT unique. Par conséquent, on doit pouvoir écrire le même programme sans utiliser de threads. Néanmoins la programmation par threads fournit un important avantage au niveau de l'organisation, de sorte que la conception du programme en est grandement simplifiée. Certains types de problèmes, comme les jeux vidéo de simulation par exemple, sont très difficiles à résoudre sans gestion de la concurrence.

Le modèle de programmation par threads est un cadre qui permet de jongler plus aisément entre plusieurs opérations se déroulant au même moment à l'intérieur d'un même programme. Avec des threads, l'UCT passe des uns aux autres et consacre à chacun d'eux une partie de son temps. Chaque thread croit avoir constamment l'UCT pour lui tout seul, mais en réalité le temps UCT est partagé entre tous les threads. La seule exception à cette règle se rencontre lorsque le programme tourne sur des UCT multiples, mais un des grands avantages de la programmation par threads est qu'elle dégage le programmeur de la nécessité de gérer cette couche, ainsi le programme n'a pas à savoir s'il tourne sur une UCT unique ou plusieurs. Par conséquent les threads sont un moyen de créer des programmes qui évoluent de façon transparente - si un programme tourne trop lentement, il est facile de le rendre plus rapide en ajoutant de l'UCT à l'ordinateur. Le multitâche et la programmation par threads sont en général les meilleurs moyens d'utiliser les systèmes multiprocesseurs.

La programmation par threads peut réduire quelque peu l'efficience des calculs sur les machines à UCT unique, mais la nette amélioration au niveau de la conception du programme, de l'équilibrage des ressources et du confort de l'utilisateur en vaut souvent la peine. En général, les threads permettent de créer des projets moins monolithiques ; sans quoi, certaines parties du programme seraient forcées de s'occuper explicitement de tâches qui sont normalement gérées par les threads.

XIII-B. Threads simples

Le moyen le plus simple de créer un thread est d'hériter de la classe java.lang.Thread, qui possède tous les composants nécessaires pour créer et exécuter des threads. La méthode la plus importante pour un Thread est la méthode run( ), que l'on doit redéfinir pour que le thread exécute les ordres désirés. La méthode run( ) correspond donc au code qui sera exécuté « en même temps » que les autres threads d'un même programme.

L'exemple suivant crée cinq threads ; chacun d'eux est identifié par un numéro unique généré à l'aide d'une variable statique. La méthode run( ) du Thread est redéfinie de façon à décrémenter un compteur chaque fois qu'elle passe à l'intérieur de sa boucle et à retourner à l'appelant lorsque le compteur atteint zéro (au moment où la méthode run( ) retourne à l'appelant, le thread prend fin via le mécanisme de thread).

 
Sélectionnez
//: c13:SimpleThread.java
// Exemple très simple de programmation par thread.
import com.bruceeckel.simpletest.*;

public class SimpleThread extends Thread {
    private static Test monitor = new Test();
    private int countDown = 5;
    private static int threadCount = 0;
    public SimpleThread() {
        super("" + ++threadCount); // Stocke le nom du thread
        start();
    }
    public String toString() {
        return "#" + getName() + ": " + countDown;
    }
    public void run() {
        while(true) {
            System.out.println(this);
            if(--countDown == 0) return;
        }
    }
    public static void main(String[] args) {
        for(int i = 0; i < 5; i++)
            new SimpleThread();
        monitor.expect(new String[] {
            "#1: 5",
            "#2: 5",
            "#3: 5",
            "#5: 5",
            "#1: 4",
            "#4: 5",
            "#2: 4",
            "#3: 4",
            "#5: 4",
            "#1: 3",
            "#4: 4",
            "#2: 3",
            "#3: 3",
            "#5: 3",
            "#1: 2",
            "#4: 3",
            "#2: 2",
            "#3: 2",
            "#5: 2",
            "#1: 1",
            "#4: 2",
            "#2: 1",
            "#3: 1",
            "#5: 1",
            "#4: 1"
        }, Test.IGNORE_ORDER + Test.WAIT);
    }
} ///:~

Un nom spécifique est donné à chaque objet thread via l'appel au constructeur Thread approprié. Le nom du thread est récupéré dans la méthode toString( ) en utilisant getName( ).

La méthode run( ) d'un objet Thread contient pratiquement toujours une boucle qui continue jusqu'à ce que le thread ne soit plus nécessaire. On doit donc créer une condition de sortie de cette boucle (ou, comme dans le précédent programme, simplement sortir de la méthode run( ) avec return). La méthode run( ) prend souvent la forme d'une boucle infinie, ce qui signifie que, sauf s'il existe un élément qui provoque l'arrêt de run( ), elle continue indéfiniment (on verra plus loin dans ce chapitre comment indiquer sans risque à un thread de s'arrêter).

Dans la méthode main( ) , on voit qu'un grand nombre de threads sont créés et exécutés. La méthode start( ) de la classe Thread effectue une initialisation spéciale pour le thread, puis appelle run( ). Les différentes étapes sont les suivantes : le constructeur est appelé pour construire l'objet, il appelle start( ) pour configurer le thread et le mécanisme d'exécution du thread appelle run( ). Si l'on n'appelle pas start( ) (ce qu'il ne faut pas faire dans le constructeur, comme on le verra dans les exemples suivants), le thread ne démarre jamais.

Le résultat de l'exécution de ce programme diffère d'une exécution à l'autre, parce que le mécanisme d'ordonnancement des threads n'est pas déterministe. En fait, on peut observer des différences spectaculaires entre le résultat de ce programme simple produit par une version du JDK et celui produit par la version suivante. Par exemple, dans une version antérieure du JDK la fréquence du découpage du temps en tranches était faible, de sorte que le thread 1 pouvait aller jusqu'à l'extinction, avant que le thread 2 ne passe au travers de toutes ses boucles, etc. Dans la version 1.4 du JDK, on obtient quelque chose d'approchant le résultat de SimpleThread.java, ce qui indique que l'ordonnanceur gère mieux le découpage du temps en tranches - chaque thread semble être servi régulièrement. En général, ce type de changement comportemental n'est pas mentionné par Sun ; on ne peut donc pas prévoir un comportement logique. La meilleure approche est d'être aussi conventionnel que possible quand on écrit le code des threads.

Quand la méthode main( ) crée les objets Thread, elle ne retient de référence à aucun d'eux. Avec un objet ordinaire, cela ferait qu'il deviendrait la proie idéale du ramasse-miettes. Ce n'est pas le cas avec un Thread. Chaque Thread « s'enregistre » lui-même, de sorte qu'il existe quelque part une référence à ce thread. Le ramasse-miettes ne peut capturer le thread avant qu'il sorte de sa méthode run( ) et meure.

XIII-B-1. Cession de priorité

Quand on sait qu'on a déjà fait ce qu'on voulait dans la méthode run( ), on peut suggérer au mécanisme d'ordonnancement des threads qu'on en a fait assez et qu'un autre thread peut disposer de l'UCT. Cette suggestion (et ce n'est qu'une suggestion - rien ne garantit que l'implémentation en tiendra compte) prend la forme de la méthode yield( ).

On peut modifier l'exemple précédent en cédant la main après chaque boucle :

 
Sélectionnez
//: c13:YieldingThread.java
// On suggère quand commuter les threads avec la méthode yield().
import com.bruceeckel.simpletest.*;

public class YieldingThread extends Thread {
    private static Test monitor = new Test();
    private int countDown = 5;
    private static int threadCount = 0;
    public YieldingThread() {
        super("" + ++threadCount);
        start();
    }
    public String toString() {
        return "#" + getName() + ": " + countDown;
    }
    public void run() {
        while(true) {
            System.out.println(this);
            if(--countDown == 0) return;
            yield();
        }
    }
    public static void main(String[] args) {
        for(int i = 0; i < 5; i++)
            new YieldingThread();
        monitor.expect(new String[] {
            "#1: 5",
            "#2: 5",
            "#4: 5",
            "#5: 5",
            "#3: 5",
            "#1: 4",
            "#2: 4",
            "#4: 4",
            "#5: 4",
            "#3: 4",
            "#1: 3",
            "#2: 3",
            "#4: 3",
            "#5: 3",
            "#3: 3",
            "#1: 2",
            "#2: 2",
            "#4: 2",
            "#5: 2",
            "#3: 2",
            "#1: 1",
            "#2: 1",
            "#4: 1",
            "#5: 1",
            "#3: 1"
        }, Test.IGNORE_ORDER + Test.WAIT);
    }
} ///:~

La méthode yield( ) permet de répartir beaucoup mieux les résultats. Mais on notera que si la chaîne de caractères résultante est plus longue, le résultat sera à peu près le même qu'avec SimpleThread.java (on essaiera en changeant la méthode toString( ) de sorte qu'elle produise des chaînes de caractères de plus en plus longues et l'on observera ce qui se passe). Comme le mécanisme d'ordonnancement est préemptif, il décide d'interrompre un thread et de commuter vers un autre quand il le veut ; par conséquent, si l'E/S (qui est exécutée par le thread main( ) ) prend trop de temps, elle sera interrompue avant que run( ) puisse exécuter yield( ). En général, la méthode yield( ) n'est utile que dans de rares occasions, et l'on ne peut s'y fier pour effectuer une mise au point sérieuse d'une application.

XIII-B-2. Endormissement

L'appel à la méthode sleep( ) est un autre moyen de contrôler le comportement des threads en cessant leur exécution pendant un nombre donné de millisecondes. Dans l'exemple précédent, si l'on remplace l'appel à yield( ) par un appel à sleep( ), on obtient le programme suivant :

 
Sélectionnez
//: c13:SleepingThread.java
// Appel à sleep() pour cesser l'exécution pendant un certain temps.
import com.bruceeckel.simpletest.*;

public class SleepingThread extends Thread {
    private static Test monitor = new Test();
    private int countDown = 5;
    private static int threadCount = 0;
    public SleepingThread() {
        super("" + ++threadCount);
        start();
    }
    public String toString() {
        return "#" + getName() + ": " + countDown;
    }
    public void run() {
        while(true) {
            System.out.println(this);
            if(--countDown == 0) return;
            try {
                sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
    public static void
    main(String[] args) throws InterruptedException {
        for(int i = 0; i < 5; i++)
            new SleepingThread().join();
        monitor.expect(new String[] {
            "#1: 5",
            "#1: 4",
            "#1: 3",
            "#1: 2",
            "#1: 1",
            "#2: 5",
            "#2: 4",
            "#2: 3",
            "#2: 2",
            "#2: 1",
            "#3: 5",
            "#3: 4",
            "#3: 3",
            "#3: 2",
            "#3: 1",
            "#4: 5",
            "#4: 4",
            "#4: 3",
            "#4: 2",
            "#4: 1",
            "#5: 5",
            "#5: 4",
            "#5: 3",
            "#5: 2",
            "#5: 1"
        });
    }
} ///:~

L'appel à sleep( ) doit être placé dans un bloc try car sleep( ) peut être interrompu avant que la pause ne prenne fin. Cela arrive lorsqu'un élément possède une référence au thread et lui applique la méthode interrupt( ) (interrupt( ) a aussi un effet sur le thread si les méthodes wait( ) ou join( ) lui ont été appliquées ; les appels à ces méthodes doivent donc être placés dans un bloc try similaire - on étudiera ces méthodes plus tard). D'ordinaire, lorsqu'on se propose de sortir d'un thread suspendu via interrupt( ), on utilise la méthode wait( ) plutôt que la méthode sleep( ), de façon à ce qu'il soit peu probable que la sortie se produise à l'intérieur de la clause catch. On suit ici le précepte « Ne récupérez pas une exception, à moins que vous ne sachiez quoi faire avec », en la jetant à nouveau en tant que RuntimeException.

On notera que le résultat est déterministe - chaque thread effectue son décrément avant que le suivant démarre. Cela est dû au fait que join( ) (qu'on étudiera bientôt) est appliqué à chaque thread, de sorte que la méthode main( ) attend que le thread ait fini avant de continuer. Si l'on n'utilisait pas join( ), on verrait que les threads ont tendance à s'exécuter dans n'importe quel ordre, ce qui signifie que la méthode sleep( ) n'est pas non plus un moyen de contrôler l'ordre d'exécution des threads. Elle ne fait que suspendre momentanément l'exécution d'un thread. La seule garantie qu'elle procure est que le thread s'endormira pendant au moins 100 millisecondes ; mais le thread peut reprendre son exécution après un laps de temps plus long, parce que l'ordonnanceur de thread doit le recontacter après que le délai de sommeil a expiré.

Si l'on doit contrôler l'ordre d'exécution des threads, le mieux est de ne pas utiliser de thread du tout, mais au contraire d'écrire ses propres routines de coopération qui donnent le contrôle à chacun d'entre eux dans un ordre donné.

XIII-B-3. Priorités

La priorité d'un thread indique à l'ordonnanceur l'importance de ce thread. Bien que l'ordre que l'UCT affecte à un jeu de threads existants soit indéterminé, l'ordonnanceur tendra à servir d'abord le thread de plus grande priorité, lorsque de nombreux threads sont bloqués en attente d'exécution. Néanmoins, cela ne signifie pas que les threads de moindre priorité ne seront pas exécutés (c'est-à-dire que les priorités ne génèrent pas d'interblocage). Les threads de moindre priorité ont simplement tendance à être exécutés moins souvent.

Voici le programme SimpleThread.java modifié pour montrer le fonctionnement des niveaux de priorités. Les priorités sont réglées par la méthode setPriority( ) de la classe Thread.

 
Sélectionnez
//: c13:SimplePriorities.java
// Montre l'utilisation des priorités des threads.
import com.bruceeckel.simpletest.*;

public class SimplePriorities extends Thread {
    private static Test monitor = new Test();
    private int countDown = 5;
    private volatile double d = 0; // Pas d'optimisation
    public SimplePriorities(int priority) {
        setPriority(priority);
        start();
    }
    public String toString() {
        return super.toString() + ": " + countDown;
    }
    public void run() {
        while(true) {
            // Une opération coûteuse et interruptible :
            for(int i = 1; i < 100000; i++)
                d = d + (Math.PI + Math.E) / (double)i;
            System.out.println(this);
            if(--countDown == 0) return;
        }
    }
    public static void main(String[] args) {
        new SimplePriorities(Thread.MAX_PRIORITY);
        for(int i = 0; i < 5; i++)
            new SimplePriorities(Thread.MIN_PRIORITY);
        monitor.expect(new String[] {
            "Thread[Thread-1,10,main]: 5",
            "Thread[Thread-1,10,main]: 4",
            "Thread[Thread-1,10,main]: 3",
            "Thread[Thread-1,10,main]: 2",
            "Thread[Thread-1,10,main]: 1",
            "Thread[Thread-2,1,main]: 5",
            "Thread[Thread-2,1,main]: 4",
            "Thread[Thread-2,1,main]: 3",
            "Thread[Thread-2,1,main]: 2",
            "Thread[Thread-2,1,main]: 1",
            "Thread[Thread-3,1,main]: 5",
            "Thread[Thread-4,1,main]: 5",
            "Thread[Thread-5,1,main]: 5",
            "Thread[Thread-6,1,main]: 5",
            "Thread[Thread-3,1,main]: 4",
            "Thread[Thread-4,1,main]: 4",
            "Thread[Thread-5,1,main]: 4",
            "Thread[Thread-6,1,main]: 4",
            "Thread[Thread-3,1,main]: 3",
            "Thread[Thread-4,1,main]: 3",
            "Thread[Thread-5,1,main]: 3",
            "Thread[Thread-6,1,main]: 3",
            "Thread[Thread-3,1,main]: 2",
            "Thread[Thread-4,1,main]: 2",
            "Thread[Thread-5,1,main]: 2",
            "Thread[Thread-6,1,main]: 2",
            "Thread[Thread-4,1,main]: 1",
            "Thread[Thread-3,1,main]: 1",
            "Thread[Thread-6,1,main]: 1",
            "Thread[Thread-5,1,main]: 1"
        }, Test.IGNORE_ORDER + Test.WAIT);
    }
} ///:~

Dans cette version, la méthode toString( ) est redéfinie pour utiliser Thread.toString( ), qui imprime le nom du thread , son niveau de priorité et le « groupe de thread » auquel il appartient. On définit soi-même le nom du thread via le constructeur ; ici, il est automatiquement généré sous la forme Thread-1, Thread-2, etc. Comme les threads s'identifient eux-mêmes, il n'y a pas de numéroDeThread dans cet exemple. La méthode toString( ) redéfinie indique aussi la valeur du compteur du thread.

On voit que le niveau de priorité du thread 1 est maximal, tous les autres threads ont un niveau de priorité minimal.

On a ajouté à l'intérieur de la méthode run( ) une boucle de 100 000 passes comprenant un calcul en virgule flottante plutôt gourmand et qui implique une addition et une division sur des double. On a rendu la variable d volatile pour s'assurer qu'aucune optimisation n'aurait lieu. Sans ce calcul, on ne verrait pas l'influence de la fixation des niveaux de priorité (on essaiera en mettant en commentaires la boucle for contenant les calculs en double). Avec ce calcul, on voit que l'ordonnanceur donne la préférence au thread 1 (en tout cas, c'est ce qui se passe sur ma machine Windows 2000). Même si l'impression sur la console est aussi une opération coûteuse, on ne voit pas les niveaux de priorité de cette façon, parce que l'impression sur la console n'est pas interrompue (autrement, l'affichage sur la console deviendrait incompréhensible pendant les opérations sur thread), tandis que le calcul mathématique peut être interrompu. Le calcul dure suffisamment longtemps pour que le mécanisme d'ordonnancement des threads intervienne, modifie les threads et tienne compte des priorités, de sorte que le thread 1 ait la préférence.

On peut aussi lire la priorité d'un thread existant avec la méthode getPriority( ) et la changer à tout moment (et pas seulement dans le constructeur comme cela a été fait dans SimplePriorities.java) avec setPriority( ).

Bien que le JDK possède 10 niveaux de priorité, cela ne correspond pas bien au niveau de priorité de nombreux systèmes opératoires. Par exemple, Windows 2000 possède 7 niveaux de priorité, qui ne sont pas fixés, par conséquent la correspondance est indéterminée (bien que Solaris de Sun ait 231 niveaux). La seule approche portable est de s'en tenir à MAX_PRIORITY, NORM_PRIORITY et MIN_PRIORITY lorsque l'on règle les niveaux de priorité.

XIII-B-4. Les threads démons

Un thread « démon » est un thread fait pour fournir un service général en arrière-plan aussi longtemps que le programme tourne, mais il ne constitue pas une partie essentielle du programme. Par conséquent, quand tous les threads non-démons se terminent, le programme s'arrête. À l'inverse, s'il y a au moins un thread non-démon en cours d'exécution, le programme ne s'arrête pas. Par exemple, il existe un thread non-démon qui exécute la méthode main( ).

 
Sélectionnez
//: c13:SimpleDaemons.java
// Les threads démons n'empêchent pas le programme de se terminer.

public class SimpleDaemons extends Thread {
    public SimpleDaemons() {
        setDaemon(true); // Doit être appelé avant start()
        start();
    }
    public void run() {
        while(true) {
            try {
                sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(this);
        }
    }
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++)
            new SimpleDaemons();
    }
} ///:~

On doit spécifier que le thread est un thread démon en appelant la méthode setDaemon( ) avant que le thread soit démarré. À l'intérieur de la méthode run( ), le thread est mis en sommeil pendant un court instant. Lorsque tous les threads ont été démarrés, le programme se termine immédiatement, avant qu'aucun n'ait pu imprimer ses propres données, car il n'existe pas de thread non-démon (autre que main( )) qui maintienne le programme en fonctionnement. Il se termine donc sans qu'aucune impression n'ait eu lieu.

On peut détecter si un thread est un démon via la méthode isDaemon( ). Si un thread est un thread démon, alors tout thread créé par lui devient automatiquement un démon, comme l'exemple suivant le prouve :

 
Sélectionnez
//: c13:Daemons.java
// Les threads démons engendrent d'autres threads démons.
import java.io.*;
import com.bruceeckel.simpletest.*;

class Daemon extends Thread {
    private Thread[] t = new Thread[10];
    public Daemon() {
        setDaemon(true);
        start();
    }
    public void run() {
        for(int i = 0; i < t.length; i++)
            t[i] = new DaemonSpawn(i);
        for(int i = 0; i < t.length; i++)
            System.out.println("t[" + i + "].isDaemon() = "
                + t[i].isDaemon());
        while(true)
            yield();
    }
}

class DaemonSpawn extends Thread {
    public DaemonSpawn(int i) {
        start();
        System.out.println("DaemonSpawn " + i + " started");
    }
    public void run() {
        while(true)
            yield();
    }
}

public class Daemons {
    private static Test monitor = new Test();
    public static void main(String[] args) throws Exception {
        Thread d = new Daemon();
        System.out.println("d.isDaemon() = " + d.isDaemon());
        // Permet aux threads démons de 
        // terminer leur processus de démarrage :
        Thread.sleep(1000);
        monitor.expect(new String[] {
            "d.isDaemon() = true",
            "DaemonSpawn 0 started",
            "DaemonSpawn 1 started",
            "DaemonSpawn 2 started",
            "DaemonSpawn 3 started",
            "DaemonSpawn 4 started",
            "DaemonSpawn 5 started",
            "DaemonSpawn 6 started",
            "DaemonSpawn 7 started",
            "DaemonSpawn 8 started",
            "DaemonSpawn 9 started",
            "t[0].isDaemon() = true",
            "t[1].isDaemon() = true",
            "t[2].isDaemon() = true",
            "t[3].isDaemon() = true",
            "t[4].isDaemon() = true",
            "t[5].isDaemon() = true",
            "t[6].isDaemon() = true",
            "t[7].isDaemon() = true",
            "t[8].isDaemon() = true",
            "t[9].isDaemon() = true"
        }, Test.IGNORE_ORDER + Test.WAIT);
    }
} ///:~

Le thread Daemon positionne son drapeau démon à « vrai », puis génère une poignée d'autres threads - qui ne se mettent pas eux-mêmes en mode démon - pour montrer qu'ils deviennent quand même des démons. Ensuite il entre dans une boucle infinie qui appelle yield( ) pour redonner le contrôle aux autres processus.

Rien n'empêche le programme de se terminer une fois que la méthode main( ) a fini son travail, puisqu'il n'y a que des threads démons qui s'exécutent. Le thread main( ) est mis en sommeil pendant une seconde pour que l'on puisse voir les effets du démarrage de tous les threads démons. Sans cela, on ne verrait que quelques-uns des effets résultant de leur création (on fera varier le délai de sleep( ) pour observer ce comportement).

XIII-B-5. Rattachement à un thread

Un thread peut appeler la méthode join( ) sur un autre thread, de façon à ce qu'il démarre après que le second thread s'est achevé. Si un thread appelle t.join( ) sur un autre thread t, alors le thread appelant est suspendu jusqu'à ce que le thread cible t se termine (lorsque t.isAlive( ) devient faux).

On peut aussi fournir à la méthode join( ) un paramètre de temps (en millisecondes ou en nanosecondes), de sorte que si le thread cible ne s'achève pas dans ce laps de temps, l'appel à la méthode join( ) retourne quand même au processus appelant.

On peut interrompre l'appel à la méthode join( ) via un appel à la méthode interrupt( ) sur le thread appelant ; une structure try-catch est donc obligatoire.

L'exemple suivant illustre toutes ces opérations :

 
Sélectionnez
//: c13:Joining.java
// Comprendre la méthode join().
import com.bruceeckel.simpletest.*;

class Sleeper extends Thread {
    private int duration;
    public Sleeper(String name, int sleepTime) {
        super(name);
        duration = sleepTime;
        start();
    }
    public void run() {
        try {
            sleep(duration);
        } catch (InterruptedException e) {
            System.out.println(getName() + " was interrupted. " +
                "isInterrupted(): " + isInterrupted());
            return;
        }
        System.out.println(getName() + " has awakened");
    }
}

class Joiner extends Thread {
    private Sleeper sleeper;
    public Joiner(String name, Sleeper sleeper) {
        super(name);
        this.sleeper = sleeper;
        start();
    }
    public void run() {
        try {
            sleeper.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(getName() + " join completed");
    }
}

public class Joining {
    private static Test monitor = new Test();
    public static void main(String[] args) {
        Sleeper
            sleepy = new Sleeper("Sleepy", 1500),
            grumpy = new Sleeper("Grumpy", 1500);
        Joiner
            dopey = new Joiner("Dopey", sleepy),
            doc = new Joiner("Doc", grumpy);
        grumpy.interrupt();
        monitor.expect(new String[] {
            "Grumpy was interrupted. isInterrupted(): false",
            "Doc join completed",
            "Sleepy has awakened",
            "Dopey join completed"
        }, Test.AT_LEAST + Test.WAIT);
    }
} ///:~

Un Sleeper est un type de Thread qui s'endort pendant un laps de temps spécifié dans son constructeur. À l'intérieur de la méthode run( ), l'appel à sleep( ) peut se terminer lorsque le laps de temps est écoulé, mais il peut aussi être interrompu. Dans la structure catch, on rend compte de l'interruption ainsi que de la valeur de la méthode isInterrupted( ). Quand un autre thread appelle interrupt( ) sur ce thread, un drapeau est positionné pour indiquer que le thread a été interrompu. Néanmoins, ce drapeau est effacé quand l'exception est capturée ; le résultat est donc toujours faux à l'intérieur de la structure catch. Ce drapeau est utilisé dans le cas où un thread voudrait examiner son état d'interruption en dehors de l'exception.

Un Joiner est un thread qui attend qu'un Sleeper se réveille en appelant la méthode join( ) sur l'objet Sleeper. Dans la méthode main( ), chaque Sleeper est couplé à un Joiner, et l'on voit dans le résultat que si le Sleeper est interrompu ou qu'il s'achève normalement, le Joiner s'achève conjointement avec le Sleeper.

XIII-B-6. Variantes d'écriture du code

Dans les exemples simples que l'on a vus jusqu'à présent, les objets threads héritent tous de la classe Thread. C'est logique puisque tous les objets ne sont manifestement créés que comme threads et n'ont pas d'autres fonctions. Néanmoins, il se peut que la classe hérite déjà d'une autre classe, auquel cas on ne peut la faire hériter de la classe Thread (Java ne gère pas l'héritage multiple). En l'occurrence, on peut utiliser l'approche alternative qui consiste à implémenter l'interface Runnable. Runnable précise seulement qu'il faut implémenter la méthode run( ), et la classe Thread implémente, elle aussi, Runnable.

Cet exemple montre l'essentiel :

 
Sélectionnez
//: c13:RunnableThread.java
// SimpleThread utilisant l'interface Runnable.

public class RunnableThread implements Runnable {
    private int countDown = 5;
    public String toString() {
        return "#" + Thread.currentThread().getName() +
            ": " + countDown;
    }
    public void run() {
        while(true) {
            System.out.println(this);
            if(--countDown == 0) return;
        }
    }
    public static void main(String[] args) {
        for(int i = 1; i <= 5; i++)
            new Thread(new RunnableThread(), "" + i).start();
        // Résultat identique à celui de SimpleThread.java
    }
} ///:~

La seule chose requise par une classe Runnable est une méthode run( ), mais si l'on veut faire autre chose avec l'objet Thread (comme getName( ) dans la méthode toString( )) on doit obtenir une référence à cet objet en appelant Thread.currentThread( ). Ce constructeur Thread spécial accepte en paramètres un Runnable et un nom pour le thread.

Le fait pour un élément de posséder une interface Runnable signifie simplement qu'il possède une méthode run( ), rien de plus - cela n'engendre aucune aptitude innée au mécanisme de thread, comme celles d'une classe héritée de la classe Thread. Ainsi, pour générer un thread à partir d'un objet Runnable, on doit créer un objet Thread distinct - comme indiqué dans cet exemple - en passant l'objet Runnable au constructeur Thread spécial. On peut alors appliquer à ce thread la méthode start( ), qui effectue l'initialisation courante et appelle ensuite la méthode run( ).

Ce qu'il y a de commode avec l'interface Runnable, c'est que tout appartient à la même classe ; c'est-à-dire que Runnable permet une sous-classe abstraite en association avec une classe de base et d'autres interfaces. Si l'on a besoin d'accéder à un élément, on le fait simplement sans passer par un objet distinct. Néanmoins, les classes internes fournissent ce même accès simple à toutes les parties d'une classe externe ; l'accès aux membres d'une classe n'est donc pas une raison impérative d'utiliser Runnable comme une sous-classe abstraite plutôt que d'utiliser une sous-classe interne à la classe Thread.

L'utilisation de Runnable indique, en général, que l'on veut créer un processus dans une partie de code - implémenté dans la méthode run( ) - plutôt que de créer un objet représentant ce processus. On peut débattre de la question, selon que l'on estime qu'il est plus logique de représenter un thread comme un objet ou bien comme une entité complètement différente, c'est-à-dire un processus. (68) Si l'on choisit d'y penser comme à un processus, alors on se libère de l'impératif orienté objet selon lequel « tout est objet». Cela signifie aussi qu'il n'y a pas de raison de rendre toute la classe Runnable, si l'on veut seulement démarrer un processus pour piloter une partie d'un programme. C'est pourquoi il est souvent plus logique de cacher le code des threads à l'intérieur d'une classe en utilisant une classe interne, comme indiqué ici :

 
Sélectionnez
//: c13:ThreadVariations.java
// Création de threads avec des classes internes.
import com.bruceeckel.simpletest.*;

// Utilisation d'une classe interne nommée :
class InnerThread1 {
    private int countDown = 5;
    private Inner inner;
    private class Inner extends Thread {
        Inner(String name) {
            super(name);
            start();
        }
        public void run() {
            while(true) {
                System.out.println(this);
                if(--countDown == 0) return;
                try {
                    sleep(10);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        public String toString() {
            return getName() + ": " + countDown;
        }
    }
    public InnerThread1(String name) {
        inner = new Inner(name);
    }
}

// Utilisation d'une classe interne anonyme :
class InnerThread2 {
    private int countDown = 5;
    private Thread t;
    public InnerThread2(String name) {
        t = new Thread(name) {
            public void run() {
                while(true) {
                    System.out.println(this);
                    if(--countDown == 0) return;
                    try {
                        sleep(10);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
            public String toString() {
                return getName() + ": " + countDown;
            }
        };
        t.start();
    }
}

// Utilisation d'une implémentation nommée de Runnable :
class InnerRunnable1 {
    private int countDown = 5;
    private Inner inner;
    private class Inner implements Runnable {
        Thread t;
        Inner(String name) {
            t = new Thread(this, name);
            t.start();
        }
        public void run() {
            while(true) {
                System.out.println(this);
                if(--countDown == 0) return;
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        public String toString() {
            return t.getName() + ": " + countDown;
        }
    }
    public InnerRunnable1(String name) {
        inner = new Inner(name);
    }
}

// Utilisation d'une implémentation anonyme de Runnable :
class InnerRunnable2 {
    private int countDown = 5;
    private Thread t;
    public InnerRunnable2(String name) {
        t = new Thread(new Runnable() {
            public void run() {
                while(true) {
                    System.out.println(this);
                    if(--countDown == 0) return;
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
            public String toString() {
                return Thread.currentThread().getName() +
                    ": " + countDown;
            }
        }, name);
        t.start();
    }
}

// Méthode isolée pour exécuter une portion de code en tant que thread :
class ThreadMethod {
    private int countDown = 5;
    private Thread t;
    private String name;
    public ThreadMethod(String name) { this.name = name; }
    public void runThread() {
        if(t == null) {
            t = new Thread(name) {
                public void run() {
                    while(true) {
                        System.out.println(this);
                        if(--countDown == 0) return;
                        try {
                            sleep(10);
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
                public String toString() {
                    return getName() + ": " + countDown;
                }
            };
            t.start();
        }
    }
}

public class ThreadVariations {
    private static Test monitor = new Test();
    public static void main(String[] args) {
        new InnerThread1("InnerThread1");
        new InnerThread2("InnerThread2");
        new InnerRunnable1("InnerRunnable1");
        new InnerRunnable2("InnerRunnable2");
        new ThreadMethod("ThreadMethod").runThread();
        monitor.expect(new String[] {
            "InnerThread1: 5",
            "InnerThread2: 5",
            "InnerThread2: 4",
            "InnerRunnable1: 5",
            "InnerThread1: 4",
            "InnerRunnable2: 5",
            "ThreadMethod: 5",
            "InnerRunnable1: 4",
            "InnerThread2: 3",
            "InnerRunnable2: 4",
            "ThreadMethod: 4",
            "InnerThread1: 3",
            "InnerRunnable1: 3",
            "ThreadMethod: 3",
            "InnerThread1: 2",
            "InnerThread2: 2",
            "InnerRunnable2: 3",
            "InnerThread2: 1",
            "InnerRunnable2: 2",
            "InnerRunnable1: 2",
            "ThreadMethod: 2",
            "InnerThread1: 1",
            "InnerRunnable1: 1",
            "InnerRunnable2: 1",
            "ThreadMethod: 1"
        }, Test.IGNORE_ORDER + Test.WAIT);
    }
} ///:~

InnerThread1 crée une classe interne nommée qui étend la classe Thread et génère une instance de cette classe interne à l'intérieur de son constructeur. C'est logique quand la classe interne a des fonctionnalités particulières (de nouvelles méthodes) auxquelles on a besoin d'accéder à partir d'autres méthodes. Néanmoins, la plupart du temps on crée un thread pour la simple raison de pouvoir utiliser les fonctionnalités de la classe Thread, il n'est donc pas nécessaire de créer une classe interne nommée. InnerThread2 propose une alternative : une sous-classe interne anonyme de la classe Thread est créée à l'intérieur du constructeur et elle est transtypée vers une référence ascendante à un Thread t. Quand d'autres méthodes de la classe ont besoin d'accéder à t, elles peuvent le faire via l'interface Thread et n'ont pas besoin de connaître le type exact de l'objet.

Les troisième et quatrième classes de l'exemple reproduisent les deux premières classes, mais elles utilisent l'interface Runnable au lieu de la classe Thread. C'est simplement pour montrer que Runnable n'apporte rien de plus dans ce cas, mais est, en fait, un peu plus compliqué à écrire (et à lire). En conséquence, j'incline à utiliser Thread sauf si je suis contraint, d'une manière ou d'une autre, à utiliser Runnable.

La classe ThreadMethod montre la création d'un thread à l'intérieur d'une méthode. On appelle la méthode quand on est prêt à exécuter le thread, et la méthode retourne à l'appelant après le démarrage du thread. Si le thread effectue seulement une opération auxiliaire au lieu d'être un élément fondamental de la classe, c'est probablement une approche plus utile / plus appropriée que de démarrer un thread à l'intérieur du constructeur de la classe.

XIII-B-7. Création d'interfaces utilisateurs réagissant bien

Comme énoncé précédemment, une des motivations de l'utilisation du mécanisme de thread est de créer une interface utilisateur qui réagit bien. Bien que nous n'abordions pas les interfaces utilisateurs graphiques avant le Chapitre 14, on verra ici un exemple simple d'une interface utilisateur basée sur la console. L'exemple suivant a deux versions : l'une reste bloquée dans un calcul et ne peut donc jamais lire la saisie à la console, la seconde met le calcul à l'intérieur d'un thread et peut donc effectuer le calcul et surveiller la saisie à la console.

 
Sélectionnez
//: c13:ResponsiveUI.java
// Bonne réaction de l'interface utilisateur.
import com.bruceeckel.simpletest.*;

class UnresponsiveUI {
    private volatile double d = 1;
    public UnresponsiveUI() throws Exception {
        while(d > 0)
            d = d + (Math.PI + Math.E) / d;
        System.in.read(); // Ligne inaccessible
    }
}

public class ResponsiveUI extends Thread {
    private static Test monitor = new Test();
    private static volatile double d = 1;
    public ResponsiveUI() {
        setDaemon(true);
        start();
    }
    public void run() {
        while(true) {
            d = d + (Math.PI + Math.E) / d;
        }
    }
    public static void main(String[] args) throws Exception {
        //! new UnresponsiveUI(); // Ce processus doit être tué
        new ResponsiveUI();
        Thread.sleep(300);
        System.in.read(); // 'monitor' fournit la saisie
        System.out.println(d); // Montre le déroulement du programme
    }
} ///:~

UnresponsiveUI effectue un calcul à l'intérieur d'une boucle infinie while, elle ne peut donc évidemment jamais atteindre la ligne de saisie à la console (le while conditionnel fait croire au compilateur que la ligne de saisie est accessible). Quand on exécute le programme après avoir ôté le commentaire devant la ligne qui crée UnresponsiveUI, on doit tuer le processus pour en sortir.

Pour que le programme réagisse correctement, on met le calcul à l'intérieur d'une méthode run( ), ce qui permet qu'il soit préempté. Quand on appuie sur la touche Entrée, on voit que le calcul s'exécute effectivement en arrière-plan, tandis que le programme attend la saisie utilisateur (pour le test, la ligne de saisie à la console est fournie automatiquement à System.in.read( ) par l'objet com.bruceeckel.simpletest.Test, expliqué au Chapitre 15).

XIII-C. Partage de ressources limitées

On peut se représenter un programme à thread unique comme une entité isolée se déplaçant dans l'espace du problème et effectuant une seule chose à la fois. Comme il n'y a qu'une seule entité, on n'est jamais obligé de réfléchir au problème de deux entités essayant d'utiliser la même ressource au même moment, par exemple deux personnes essayant de garer leur voiture au même endroit, de passer le seuil d'une porte ensemble ou encore de parler en même temps.

Avec la programmation multithreads, les éléments ne sont plus isolés ; il se peut que plusieurs threads essaient d'utiliser la même ressource limitée en même temps. Les conflits sur ressource doivent être évités, ou alors on aura deux threads qui essaieront d'accéder au même compte bancaire en même temps, d'imprimer sur la même imprimante, de régler la même soupape, et ainsi de suite.

XIII-C-1. Accès incorrect aux ressources

Soit l'exemple suivant dans lequel la classe « garantit » qu'elle fournira toujours un nombre pair quand on appellera la méthode getValue( ). Néanmoins, il existe un second thread nommé « Watcher » qui appelle sans arrêt getValue( ) et vérifie que cette valeur est vraiment paire. Cela semble une activité inutile, puisqu'il est manifeste en regardant le code que la valeur sera effectivement paire. Mais c'est là qu'on va être surpris. Voici la première version du programme :

 
Sélectionnez
//: c13:AlwaysEven.java
// Illustration de conflit entre threads sur des ressources
// lors de la lecture d'un objet en état intermédiaire instable.

public class AlwaysEven {
    private int i;
    public void next() { i++; i++; }
    public int getValue() { return i; }
    public static void main(String[] args) {
        final AlwaysEven ae = new AlwaysEven();
        new Thread("Watcher") {
            public void run() {
                while(true) {
                    int val = ae.getValue();
                    if(val % 2 != 0) {
                        System.out.println(val);
                        System.exit(0);
                    }
                }
            }
        }.start();
        while(true)
            ae.next();
    }
} ///:~

Dans le main( ), on crée un objet AlwaysEven - qui doit être final car on y accède à l'intérieur de la classe anonyme interne définie comme un Thread. Si la valeur lue par le thread n'est pas paire, le thread l'imprime (comme preuve qu'il a capturé l'objet en état instable) puis sort du programme.

Cet exemple illustre un problème fondamental que l'on rencontre lors de l'utilisation de threads. On ne sait jamais à quel moment un thread va s'exécuter. Imaginez que vous êtes assis à table, sur le point de piquer avec votre fourchette le dernier morceau de nourriture restant dans l'assiette, et au moment où votre fourchette l'atteint, la nourriture disparaît soudain (parce que votre thread a été suspendu et qu'un autre thread est venu voler la nourriture). C'est le problème que l'on doit résoudre lorsqu'on écrit des programmes concurrents.

Parfois on ne se soucie pas de savoir si un élément accède à une ressource au moment même où l'on essaie de l'utiliser (la nourriture est dans une autre assiette). Mais pour que le multithreading fonctionne, on a besoin d'un moyen d'éviter que deux threads accèdent à la même ressource, au moins pendant les périodes critiques.

La prévention de ce genre de conflit se résume à poser un verrou sur la ressource quand un thread l'utilise. Le premier thread qui accède à la ressource la verrouille. Les autres threads ne peuvent plus accéder à cette ressource tant qu'elle n'est pas déverrouillée ; dès qu'elle l'est, un autre thread la verrouille et l'utilise, etc. Si le siège avant d'une voiture est la ressource limitée, l'enfant qui crie « Prems ! » active le verrou.

XIII-C-1-a. Un framework qui teste les ressources

Avant d'aller plus loin, essayons de simplifier un peu les choses en créant un petit framework qui effectuera des tests sur ces types d'exemples de threads. On peut réaliser cela en isolant le code qui pourrait être commun aux différents exemples. On notera, tout d'abord, que le thread « watcher » guette un invariant violé dans un objet particulier. C'est-à-dire que l'objet est censé maintenir les conventions qui régissent son état interne, et si l'on voit l'objet du dehors dans un état intermédiaire invalide, alors l'invariant a été violé du point de vue du client (cela ne signifie pas que l'objet ne peut jamais être dans un état intermédiaire invalide, mais simplement qu'il ne devrait pas être visible dans cet état par un client). Par conséquent, on veut pouvoir détecter la violation de l'invariant et connaître la valeur de violation. Pour obtenir ces deux valeurs à partir d'un unique appel de méthode, on les combine dans une interface étiquetée qui n'existe que pour fournir un nom significatif dans le code :

 
Sélectionnez
//: c13:InvariantState.java
// Messager transportant des données invariantes
public interface InvariantState {} ///:~

Dans ce modèle, l'information sur le succès ou l'échec est encodée dans le nom de la classe pour rendre le résultat plus lisible. La classe indiquant le succès est la suivante :

 
Sélectionnez
//: c13:InvariantOK.java
// Indique que le test sur l'invariant a réussi
public class InvariantOK implements InvariantState {} ///:~

Pour indiquer l'échec, l'objet InvariantFailure transmettra en général un objet contenant des informations sur les causes de l'échec pour pouvoir l'afficher :

 
Sélectionnez
//: c13:InvariantFailure.java
// Indique que le test sur l'invariant a échoué

public class InvariantFailure implements InvariantState {
    public Object value;
    public InvariantFailure(Object value) {
        this.value = value;
    }
} ///:~

Maintenant, on peut définir une interface qui devra être implémentée par toute classe qui souhaite que son invariance soit testée :

 
Sélectionnez
//: c13:Invariant.java
public interface Invariant {
    InvariantState invariant();
} ///:~

Avant de créer le thread générique, on notera que certains exemples de ce chapitre ne se comportent pas comme prévu sur toutes les plates-formes. Nombre de ces exemples tentent de montrer les violations d'un comportement à thread unique quand de multiples threads sont présents, et ceci peut ne pas se produire tout le temps. (69) Ou bien, un programme d'exemple peut essayer de prouver que la violation n'a pas lieu en tentant (et en échouant) de démontrer la violation. Dans ce cas, il faut un moyen d'arrêter le programme après quelques secondes. La classe suivante le fait en créant une sous-classe de la classe Timer incluse dans la librairie standard :

 
Sélectionnez
//: c13:Timeout.java
// Fixe une limite temporelle à l'exécution du programme
import java.util.*;

public class Timeout extends Timer {
    public Timeout(int delay, final String msg) {
        super(true); // Thread démon
        schedule(new TimerTask() {
            public void run() {
                System.out.println(msg);
                System.exit(0);
            }
        }, delay);
    }
} ///:~

La limite temporelle est donnée en millisecondes ; le message sera imprimé si la limite est dépassée. On notera qu'en appelant super(true), le thread est créé en tant que démon, de sorte que, si le programme prend fin d'une autre façon, ce thread ne l'empêchera pas de se terminer. La méthode Timer.schedule( ) reçoit en paramètre une sous-classe TimerTask (créée ici comme une classe interne anonyme), dont la méthode run( ) est exécutée après que le laps de temps (en millisecondes) fixé par le second argument de la méthode schedule( ) a expiré. L'utilisation de Timer est généralement plus simple et plus claire que l'écriture directe du code avec un sleep( ) explicite. De plus, Timer est conçu pour gérer un grand nombre (de l'ordre du millier) de tâches planifiées concurremment ; il peut donc être un outil très utile.

On peut maintenant utiliser l'interface Invariant et la classe Timeout dans le thread InvariantWatcher :

 
Sélectionnez
//: c13:InvariantWatcher.java
// Vérifie constamment que l'invariant n'est pas violé

public class InvariantWatcher extends Thread {
    private Invariant invariant;
    public InvariantWatcher(Invariant invariant) {
        this.invariant = invariant;
        setDaemon(true);
        start();
    }
    // S'arrête après un moment :
    public
    InvariantWatcher(Invariant invariant, final int timeOut){
        this(invariant);
        new Timeout(timeOut,
            "Délai expiré sans violation de l'invariant");
    }
    public void run() {
        while(true) {
            InvariantState state = invariant.invariant();
            if(state instanceof InvariantFailure) {
                System.out.println("Invariant violé : "
                    + ((InvariantFailure)state).value);
                System.exit(0);
            }
        }
    }
} ///:~

Le constructeur prend pour paramètre une référence à l'objet Invariant à tester et démarre le thread. Le second constructeur appelle le premier, puis crée un Timeout qui arrête tout après le laps de temps désiré - on utilise ceci dans les situations où le programme pourrait ne pas se terminer par la violation d'un invariant. On relève et on teste InvariantState dans la méthode run( ) ; si le test échoue, sa valeur est imprimée. On notera qu'on ne peut jeter d'exception à l'intérieur de ce thread, car cela ne mettrait fin qu'au thread, pas au programme.

On peut maintenant réécrire AlwaysEven.java en utilisant ce framework :

 
Sélectionnez
//: c13:EvenGenerator.java
// AlwaysEven.java utilisant le testeur d'invariance

public class EvenGenerator implements Invariant {
    private int i;
    public void next() { i++; i++; }
    public int getValue() { return i; }
    public InvariantState invariant() {
        int val = i; // On la stocke au cas où elle changerait
        if(val % 2 == 0)
            return new InvariantOK();
        else
            return new InvariantFailure(new Integer(val));
    }
    public static void main(String[] args) {
        EvenGenerator gen = new EvenGenerator();
        new InvariantWatcher(gen);
        while(true)
            gen.next();
    }
} ///:~

Quand on définit la méthode invariant( ), on doit stocker toutes les valeurs intéressantes dans des variables locales. De cette manière, on peut retourner la valeur réellement testée, et non pas celle qui aurait pu être modifiée (par un autre thread) dans l'intervalle.

Dans ce cas, le problème ne réside pas dans le fait que l'objet passe par un état qui viole l'invariance, mais que des méthodes peuvent être appelées par des threads pendant que l'objet est dans cet état intermédiaire instable.

XIII-C-2. Colliding over resources

The worst thing that happens with EvenGenerator is that a client thread might see it in an unstable intermediate state. The object's internal consistency is maintained, however, and it eventually becomes visible in a good state. But if two threads are actually modifying an object, the contention over shared resources is much worse, because the object can be put into an incorrect state.

Consider the simple concept of a semaphore, which is a flag object used for communication between threads. If the semaphore's value is zero, then whatever it is monitoring is available, but if the value is nonzero, then the monitored entity is unavailable, and the thread must wait for it. When it's available, the thread increments the semaphore and then goes ahead and uses the monitored entity. Because incrementing and decrementing are atomic operations (that is, they cannot be interrupted), the semaphore keeps two threads from using the same entity at the same time.

If the semaphore is going to properly guard the entity that it is monitoring, then it must never get into an unstable state. Here's a simple version of the semaphore idea:

 
Sélectionnez
//: c13:Semaphore.java
// A simple threading flag

public class Semaphore implements Invariant {
    private volatile int semaphore = 0;
    public boolean available() { return semaphore == 0; }
    public void acquire() { ++semaphore; }
    public void release() { --semaphore; }
    public InvariantState invariant() {
        int val = semaphore;
        if(val == 0 || val == 1)
            return new InvariantOK();
        else
            return new InvariantFailure(new Integer(val));
    }
} ///:~

The core part of the class is straightforward, consisting of available( ), acquire( ), and release( ). Since a thread should check for availability before acquiring, the value of semaphore should never be other than one or zero, and this is tested by invariant( ).

But look what happens when Semaphore is tested for thread consistency:

 
Sélectionnez
//: c13:SemaphoreTester.java
// Colliding over shared resources

public class SemaphoreTester extends Thread {
    private volatile Semaphore semaphore;
    public SemaphoreTester(Semaphore semaphore) {
        this.semaphore = semaphore;
        setDaemon(true);
        start();
    }
    public void run() {
        while(true)
            if(semaphore.available()) {
                yield(); // Makes it fail faster
                semaphore.acquire();
                yield();
                semaphore.release();
                yield();
            }
    }
    public static void main(String[] args) throws Exception {
        Semaphore sem = new Semaphore();
        new SemaphoreTester(sem);
        new SemaphoreTester(sem);
        new InvariantWatcher(sem).join();
    }
} ///:~

The SemaphoreTester creates a thread that continuously tests to see if a Semaphore object is available, and if so acquires and releases it. Note that the semaphore field is volatile to make sure that the compiler doesn't optimize away any reads of that value.

In main( ), two SemaphoreTester threads are created, and you'll see that in short order the invariant is violated. This happens because one thread might get a true result from calling available( ), but by the time that thread calls acquire( ), the other thread may have already called acquire( ) and incremented the semaphore field. The InvariantWatcher may see the field with too high a value, or possibly see it after both threads have called release( ) and decremented it to a negative value. Note that InvariantWatcher join( )s with the main thread to keep the program running until there is a failure.

On my machine, I discovered that the inclusion of yield( ) caused failure to occur much faster, but this will vary with operating systems and JVM implementations. You should experiment with taking the yield( ) statements out; the failure might take a very long time to occur, which demonstrates how difficult it can be to detect a flaw in your program when you're writing multithreaded code.

This class emphasizes the risk of concurrent programming: If a class this simple can produce problems, you can never trust any assumptions about concurrency.

XIII-C-3. Resolving shared resource contention

To solve the problem of thread collision, virtually all multithreading schemes serialize access to shared resources. This means that only one thread at a time is allowed to access the shared resource. This is ordinarily accomplished by putting a locked clause around a piece of code so that only one thread at a time may pass through that piece of code. Because this locked clause produces mutual exclusion, a common name for such a mechanism is mutex.

Consider the bathroom in your house; multiple people (threads) may each want to have exclusive use of the bathroom (the shared resource). To access the bathroom, a person knocks on the door to see if it's available. If so, they enter and lock the door. Any other thread that wants to use the bathroom is « blocked » from using it, so that thread waits at the door until the bathroom is available.

The analogy breaks down a bit when the bathroom is released and it comes time to give access to another thread. There isn't actually a line of people and we don't know for sure who gets the bathroom next, because the thread scheduler isn't deterministic that way. Instead, it's as if there is a group of blocked threads milling about in front of the bathroom, and when the thread that has locked the bathroom unlocks it and emerges, the one that happens to be nearest the door at the moment goes in. As noted earlier, suggestions can be made to the thread scheduler via yield( ) and setPriority( ), but these suggestions may not have much of an effect depending on your platform and JVM implementation.

Java has built-in support to prevent collisions over resources in the form of the synchronized keyword. This works much like the Semaphore class was supposed to: When a thread wishes to execute a piece of code guarded by the synchronized keyword, it checks to see if the semaphore is available, then acquires it, executes the code, and releases it. However, synchronized is built into the language, so it's guaranteed to always work, unlike the Semaphore class.

The shared resource is typically just a piece of memory in the form of an object, but may also be a file, I/O port, or something like a printer. To control access to a shared resource, you first put it inside an object. Then any method that accesses that resource can be made synchronized. This means that if a thread is inside one of the synchronized methods, all other threads are blocked from entering any of the synchronized methods of the class until the first thread returns from its call.

Since you typically make the data elements of a class private and access that memory only through methods, you can prevent collisions by making methods synchronized. Here is how you declare synchronized methods:

 
Sélectionnez
synchronized void f() { /* ... */ }
synchronized void g(){ /* ... */ }

Each object contains a single lock (also referred to as a monitor)that is automatically part of the object (you don't have to write any special code). When you call any synchronized method, that object is locked and no other synchronized method of that object can be called until the first one finishes and releases the lock. In the preceding example, if f( ) is called for an object, g( ) cannot be called for the same object until f( ) is completed and releases the lock. Thus, there is a single lock that is shared by all the synchronized methods of a particular object, and this lock prevents common memory from being written by more than one thread at a time.

One thread may acquire an object's lock multiple times. This happens if one method calls a second method on the same object, which in turn calls another method on the same object, etc. The JVM keeps track of the number of times the object has been locked. If the object is unlocked, it has a count of zero. As a thread acquires the lock for the first time, the count goes to one. Each time the thread acquires a lock on the same object, the count is incremented. Naturally, multiple lock acquisition is only allowed for the thread that acquired the lock in the first place. Each time the thread leaves a synchronized method, the count is decremented, until the count goes to zero, releasing the lock entirely for use by other threads.

There's also a single lock per class (as part of the Class object for the class), so that synchronized static methods can lock each other out from simultaneous access of static data on a class-wide basis.

XIII-C-3-a. Synchronizing the EvenGenerator

By adding synchronized to EvenGenerator.java, we can prevent the undesirable thread access:

 
Sélectionnez
//: c13:SynchronizedEvenGenerator.java
// Using "synchronized" to prevent thread collisions

public
class SynchronizedEvenGenerator implements Invariant {
    private int i;
    public synchronized void next() { i++; i++; }
    public synchronized int getValue() { return i; }
    // Not synchronized so it can run at
    // any time and thus be a genuine test:
    public InvariantState invariant() {
        int val = getValue();
        if(val % 2 == 0)
            return new InvariantOK();
        else
            return new InvariantFailure(new Integer(val));
    }
    public static void main(String[] args) {
        SynchronizedEvenGenerator gen =
            new SynchronizedEvenGenerator();
        new InvariantWatcher(gen, 4000); // 4-second timeout
        while(true)
            gen.next();
    }
} ///:~

You'll notice that both next( ) and getValue( ) are synchronized. If you synchronize only one of the methods, then the other is free to ignore the object lock and can be called with impunity. This is an important point: Every method that accesses a critical shared resource must be synchronized or it won't work right. On the other hand, InvariantState is not synchronized because it is doing the testing, and we want it to be called at any time so that it produces a true test of the object.

XIII-C-3-b. Atomic operations

A common piece of lore often repeated in Java threading discussions is that « atomic operations do not need to be synchronized. » An atomic operation is one that cannot be interrupted by the thread scheduler; if the operation begins, then it will run to completion before the possibility of a context switch (switching execution to another thread).

The atomic operations commonly mentioned in this lore include simple assignment and returning a value when the variable in question is a primitive type that is not a long or a double. The latter types are excluded because they are larger than the rest of the types, and the JVM is thus not required to perform reads and assignments as single atomic operations (a JVM may choose to do so anyway, but there's no guarantee). However, you do get atomicity if you use the volatile keyword with long or double.

If you were to blindly apply the idea of atomicity to SynchronizedEvenGenerator.java, you would notice that

 
Sélectionnez
public synchronized int getValue() { return i; }

fits the description. But try removing synchronized, and the test will fail, because even though return i is indeed an atomic operation, removing synchronized allows the value to be read while the object is in an unstable intermediate state. You must genuinely understand what you're doing before you try to apply optimizations like this. There are no easily-applicable rules that work.

As a second example, consider something even simpler: a class that produces serial numbers. (70) Each time nextSerialNumber( ) is called, it must return a unique value to the caller:

 
Sélectionnez
//: c13:SerialNumberGenerator.java

public class SerialNumberGenerator {
    private static volatile int serialNumber = 0;
    public static int nextSerialNumber() {
        return serialNumber++;
    }
} ///:~

SerialNumberGenerator is about as simple a class as you can imagine, and if you're coming from C++ or some other low-level background, you would expect the increment to be an atomic operation, because increment is usually implemented as a microprocessor instruction. However, in the JVM an increment is not atomic and involves both a read and a write, so there's room for threading problems even in such a simple operation.

The serialNumber field is volatile because it is possible for each thread to have a local stack and maintain copies of some variables there. If you define a variable as volatile, it tells the compiler not to do any optimizations that would remove reads and writes that keep the field in exact synchronization with the local data in the threads.

To test this, we need a set that doesn't run out of memory, in case it takes a long time to detect a problem. The CircularSet shown here reuses the memory used to store ints, with the assumption that by the time you wrap around, the possibility of a collision with the overwritten values is minimal. The add( ) and contains( ) methods are synchronized to prevent thread collisions:

 
Sélectionnez
//: c13:SerialNumberChecker.java
// Operations that may seem safe are not,
// when threads are present.

// Reuses storage so we don't run out of memory:
class CircularSet {
    private int[] array;
    private int len;
    private int index = 0;
    public CircularSet(int size) {
        array = new int[size];
        len = size;
        // Initialize to a value not produced
        // by the SerialNumberGenerator:
        for(int i = 0; i < size; i++)
            array[i] = -1;
    }
    public synchronized void add(int i) {
        array[index] = i;
        // Wrap index and write over old elements:
        index = ++index % len;
    }
    public synchronized boolean contains(int val) {
        for(int i = 0; i < len; i++)
            if(array[i] == val) return true;
                return false;
    }
}

public class SerialNumberChecker {
    private static CircularSet serials =
        new CircularSet(1000);
    static class SerialChecker extends Thread {
        SerialChecker() { start(); }
        public void run() {
            while(true) {
                int serial =
                    SerialNumberGenerator.nextSerialNumber();
                if(serials.contains(serial)) {
                    System.out.println("Duplicate: " + serial);
                    System.exit(0);
                }
                serials.add(serial);
            }
        }
    }
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++)
            new SerialChecker();
        // Stop after 4 seconds:
        new Timeout(4000, "No duplicates detected");
    }
} ///:~

SerialNumberChecker contains a static CircularSet that contains all the serial numbers that have been extracted, and a nested Thread that gets serial numbers and ensures that they are unique. By creating multiple threads to contend over serial numbers, you'll discover that the threads get a duplicate serial number reasonably soon (note that this program may not indicate a collision on your machine, but it has successfully detected collisions on a multiprocessor machine). To solve the problem, add the synchronized keyword to nextSerialNumber( ).

The atomic operations that are supposed to be safe are reading and assignment of primitives. However, as seen in EvenGenerator.java, it's still easily possible to use an atomic operation that accesses your object while it's in an unstable intermediate state, so you cannot make any assumptions. On top of this, the atomic operations are not guaranteed to work with long and double (although some JVM implementations do guarantee atomicity for long and double operations, you won't be writing portable code if you depend on this).

It's safest to use the following guidelines:

  • If you need to synchronize one method in a class, synchronize all of them. It's often difficult to tell for sure if a method will be negatively affected if you leave synchronization out.
  • Be extremely careful when removing synchronization from methods. The typical reason to do this is for performance, but in JDK 1.3 and 1.4 the overhead of synchronized has been greatly reduced. In addition, you should only do this after using a profiler to determine that synchronized is indeed the bottleneck.
XIII-C-3-c. Fixing Semaphore

Now consider Semaphore.java. It would seem that we should be able to repair this by synchronizing the three class methods, like this:

 
Sélectionnez
//: c13:SynchronizedSemaphore.java
// Colliding over shared resources

public class SynchronizedSemaphore extends Semaphore {
    private volatile int semaphore = 0;
    public synchronized boolean available() {
        return semaphore == 0;
    }
    public synchronized void acquire() { ++semaphore; }
    public synchronized void release() { --semaphore; }
    public InvariantState invariant() {
        int val = semaphore;
        if(val == 0 || val == 1)
            return new InvariantOK();
        else
            return new InvariantFailure(new Integer(val));
    }
    public static void main(String[] args) throws Exception {
        SynchronizedSemaphore sem =new SynchronizedSemaphore();
        new SemaphoreTester(sem);
        new SemaphoreTester(sem);
        new InvariantWatcher(sem).join();
    }
} ///:~

This looks rather odd at first-SynchronizedSemaphore is inherited from Semaphore, and yet all the overridden methods are synchronized, but the base-class versions aren't. Java doesn't allow you to change the method signature during overriding, and yet doesn't complain about this. That's because the synchronized keyword is not part of the method signature, so you can add it in and it doesn't limit overriding.

The reason for inheriting from Semaphore is to reuse the SemaphoreTester class. When you run the program you'll see that it still causes an InvariantFailure.

Why does this fail? By the time a thread detects that the Semaphore is available because available( ) returns true, it has released the lock on the object. Another thread can dash in and increment the semaphore value before the first thread does. The first thread still assumes the Semaphore object is available and so goes ahead and blindly enters the acquire( ) method, putting the object into an unstable state. This is just one more lesson about rule zero of concurrent programming: Never make any assumptions.

The only solution to this problem is to make the test for availability and the acquisition a single atomic operation-which is exactly what the synchronized keyword provides in conjunction with the lock on an object. That is, Java's lock and synchronized keyword is a built-in semaphore mechanism, so you don't need to create your own.

XIII-C-4. Critical sections

Sometimes, you only want to prevent multiple thread access to part of the code inside a method instead of the entire method. The section of code you want to isolate this way is called a critical section and is also created using the synchronized keyword. Here, synchronized is used to specify the object whose lock is being used to synchronize the enclosed code:

 
Sélectionnez
synchronized(syncObject) {
    // This code can be accessed 
    // by only one thread at a time
}

This is also called a synchronized block; before it can be entered, the lock must be acquired on syncObject. If some other thread already has this lock, then the critical section cannot be entered until the lock is given up.

The following example compares both approaches to synchronization by showing how the time available for other threads to access an object is significantly increased by using a synchronized block instead of synchronizing an entire method. In addition, it shows how an unprotected class can be used in a multithreaded situation if it is controlled and protected by another class:

 
Sélectionnez
//: c13:CriticalSection.java
// Synchronizing blocks instead of entire methods. Also
// demonstrates protection of a non-thread-safe class
// with a thread-safe one.
import java.util.*;

class Pair { // Not thread-safe
    private int x, y;
    public Pair(int x, int y) {
        this.x = x;
        this.y = y;
    }
    public Pair() { this(0, 0); }
    public int getX() { return x; }
    public int getY() { return y; }
    public void incrementX() { x++; }
    public void incrementY() { y++; }
    public String toString() {
        return "x: " + x + ", y: " + y;
    }
    public class PairValuesNotEqualException
            extends RuntimeException {
        public PairValuesNotEqualException() {
            super("Pair values not equal: " + Pair.this);
        }
    }
    // Arbitrary invariant -- both variables must be equal:
    public void checkState() {
        if(x != y)
            throw new PairValuesNotEqualException();
    }
}

// Protect a Pair inside a thread-safe class:
abstract class PairManager {
    protected Pair p = new Pair();
    private List storage = new ArrayList();
    public synchronized Pair getPair() {
        // Make a copy to keep the original safe:
        return new Pair(p.getX(), p.getY());
    }
    protected void store() { storage.add(getPair()); }
    // A "template method":
    public abstract void doTask();
}

// Synchronize the entire method:
class PairManager1 extends PairManager {
    public synchronized void doTask() {
        p.incrementX();
        p.incrementY();
        store();
    }
}

// Use a critical section:
class PairManager2 extends PairManager {
    public void doTask() {
        synchronized(this) {
            p.incrementX();
            p.incrementY();
        }
        store();
    }
}

class PairManipulator extends Thread {
    private PairManager pm;
    private int checkCounter = 0;
    private class PairChecker extends Thread {
        PairChecker() { start(); }
        public void run() {
            while(true) {
                checkCounter++;
                pm.getPair().checkState();
            }
        }
    }
    public PairManipulator(PairManager pm) {
        this.pm = pm;
        start();
        new PairChecker();
    }
    public void run() {
        while(true) {
            pm.doTask();
        }
    }
    public String toString() {
        return "Pair: " + pm.getPair() +
            " checkCounter = " + checkCounter;
    }
}

public class CriticalSection {
    public static void main(String[] args) {
        // Test the two different approaches:
        final PairManipulator
            pm1 = new PairManipulator(new PairManager1()),
            pm2 = new PairManipulator(new PairManager2());
        new Timer(true).schedule(new TimerTask() {
            public void run() {
                System.out.println("pm1: " + pm1);
                System.out.println("pm2: " + pm2);
                System.exit(0);
            }
        }, 500); // run() after 500 milliseconds
    }
} ///:~

As noted, Pair is not thread-safe because its invariant (admittedly arbitrary) requires that both variables maintain the same values. In addition, as seen earlier in this chapter, the increment operations are not thread-safe, and because none of the methods are synchronized, you can't trust a Pair object to stay uncorrupted in a threaded program.

The PairManager class holds a Pair object and controls all access to it. Note that the only public methods are getPair( ), which is synchronized, and the abstract doTask( ). Synchronization for this method will be handled when it is implemented.

The structure of PairManager, where some of the functionality is implemented in the base class with one or more abstract methods defined in derived classes, is called a Template Method in Design Patterns parlance. (71) Design patterns allow you to encapsulate change in your code; here, the part that is changing is the template method doTask( ). In PairManager1 the entire doTask( ) is synchronized, but in PairManager2 only part of doTask( ) is synchronized by using a synchronized block. Note that the synchronized keyword is not part of the method signature and thus may be added during overriding.

PairManager2 is observing, in effect, that store( ) is a protected method and thus is not available to the general client, but only to subclasses. Thus, it doesn't necessarily need to be guarded inside a synchronized method, and is instead placed outside of the synchronized block.

A synchronized block must be given an object to synchronize upon, and usually the most sensible object to use is just the current object that the method is being called for: synchronized(this), which is the approach taken in PairManager2. That way, when the lock is acquired for the synchronized block, other synchronized methods in the object cannot be called. So the effect is that of simply reducing the scope of synchronization.

Sometimes this isn't what you want, in which case you can create a separate object and synchronize on that. The following example demonstrates that two threads can enter an object when the methods in that object synchronize on different locks:

 
Sélectionnez
//: c13:SyncObject.java
// Synchronizing on another object
import com.bruceeckel.simpletest.*;

class DualSynch {
    private Object syncObject = new Object();
    public synchronized void f() {
        System.out.println("Inside f()");
        // Doesn't release lock:
        try {
            Thread.sleep(500);
        } catch(InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("Leaving f()");
    }
    public void g() {
        synchronized(syncObject) {
            System.out.println("Inside g()");
            try {
                Thread.sleep(500);
            } catch(InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("Leaving g()");
        }
    }
}

public class SyncObject {
    private static Test monitor = new Test();
    public static void main(String[] args) {
        final DualSynch ds = new DualSynch();
        new Thread() {
            public void run() {
                ds.f();
            }
        }.start();
        ds.g();
        monitor.expect(new String[] {
            "Inside g()",
            "Inside f()",
            "Leaving g()",
            "Leaving f()"
        }, Test.WAIT + Test.IGNORE_ORDER);
    }
} ///:~

The DualSync method f( ) synchronizes on this (by synchronizing the entire method) and g( ) has a synchronized block that synchronizes on syncObject. Thus, the two synchronizations are independent. This is demonstrated in main( ) by creating a Thread that calls f( ). The main( ) thread is used to call g( ). You can see from the output that both methods are running at the same time, so neither one is blocked by the synchronization of the other.

Returning to CriticalSection.java, PairManipulator is created to test the two different types of PairManager by running doTask( ) in one thread and an instance of the inner class PairChecker in the other. To trace how often it is able to run the test, PairChecker increments checkCounter every time it is successful. In main( ), two PairManipulator objects are created and allowed to run for awhile. When the Timer runs out, it executes its run( ) method, that displays the results of each PairManipulator and exits. When you run the program, you should see something like this:

 
Sélectionnez
pm1: Pair: x: 58892, y: 58892 checkCounter = 44974
pm2: Pair: x: 73153, y: 73153 checkCounter = 100535

Although you will probably see a lot of variation from one run to the next, in general you will see that PairManager1.doTask( ) does not allow the PairChecker nearly as much access as PairManager2.doTask( ), which has the synchronized block and thus provides more unlocked time. This is typically the reason that you want to use a synchronized block instead of synchronizing the whole method: to allow other threads more access (as long as it is safe to do so).

Of course, all synchronization depends on programmer diligence: Every piece of code that can access a shared resource must be wrapped in an appropriate synchronized block.

XIII-D. États d'un thread

Un thread peut être dans l'un des quatre états :

  • Nouveau (New) : l'objet thread a été créé, mais il n'a pas encore été démarré, donc il peut ne pas s'exécuter.
  • Exécutable (Runnable) : cela signifie qu'un thread peut être exécuté lorsque le gestionnaire du temps processeur a des cycles CPU disponibles pour le thread. Ainsi, le thread pourrait être exécuté ou non à tout moment, mais il n'y a rien pour l'empêcher d'être exécuté si l'ordonnanceur peut arranger cela ; il n'est pas mort ou bloqué.
  • Mort (Dead) : la manière normale de mourir pour un thread est lorsque sa méthode run( ) a fini son exécution. Avant que cela soit déprécié dans Java 2, vous pouviez aussi appeler stop( ), mais cela pourrait facilement mettre de votre programme dans un état instable. Il y a aussi une méthode destroy( ) (qui n'a jamais été implémentée et qui ne le le sera probablement jamais, donc elle est effectivement dépréciée). Vous allez en savoir plus sur la manière de coder un équivalent de stop( ) plus tard dans ce chapitre.
  • Bloqué (Blocked) : le thread pourrait être exécuté, mais il y a quelque chose qui l'en empêche. Pendant qu'un thread est dans l'état bloqué, l'ordonnanceur va simplement le « sauter » et ne pas lui donner de temps CPU. En attendant qu'un thread rentre dans l'état exécutable, aucune opération ne sera effectuée.

XIII-D-1. Passer à l'état bloqué

Quand un thread est bloqué, il y a une raison pour qu'il ne continue pas son exécution. Un thread peut devenir bloqué pour les raisons suivantes :

  • Vous avez mis le thread à dormir en appelant sleep(milliseconds), auquel cas il ne tournera pas pendant le temps spécifié.
  • Vous avez suspendu l'exécution du thread avec wait( ). Il ne redeviendra pas exécutable avant que le thread ne reçoive le message notify( ) ou notifyAll( ). Nous allons les examiner dans la prochaine section.
  • Le thread attend la fin d'une I/O.
  • Le thread essaye d'appeler une méthode synchronized sur un autre objet et le verrou de cet objet n'est pas libre.

Dans du vieux code, vous pouvez également voir suspend( ) et resume( ) utilisés pour bloquer et débloquer des threads, mais ceux-ci sont dépréciés dans Java 2 (parce qu'ils ont tendance à donner des impasses) et ne seront donc pas examinés dans ce livre.

XIII-E. Coopération entre les threads

Après avoir compris que les threads peuvent entrer en collision les uns avec les autres et comment les empêcher d'entrer en collision, l'étape suivante consiste à apprendre comment faire coopérer des threads les uns avec les autres. La clé pour y parvenir est d'établir une communication entre les threads, ce qui est implémenté en toute sécurité en utilisant les méthodes Object wait( ) et notify( ).

XIII-E-1. Wait and notify

It's important to understand that sleep( ) does not release the lock when it is called. On the other hand, the method wait( ) does release the lock, which means that other synchronized methods in the thread object can be called during a wait( ). When a thread enters a call to wait( ) inside a method, that thread's execution is suspended, and the lock on that object is released.

There are two forms of wait( ). The first takes an argument in milliseconds that has the same meaning as in sleep( ): « Pause for this period of time. » The difference is that in wait( ):

  • The object lock is released during the wait( ).
  • You can come out of the wait( ) due to a notify( ) or notifyAll( ),or by letting the clock run out.

The second form of wait( ) takes no arguments; this version is more commonly used. This wait( ) continues indefinitely until the thread receives a notify( ) or notifyAll( ).

One fairly unique aspect of wait( ), notify( ), and notifyAll( ) is that these methods are part of the base class Object and not part of Thread, as is sleep( ). Although this seems a bit strange at first-to have something that's exclusively for threading as part of the universal base class-it's essential because they manipulate the lock that's also part of every object. As a result, you can put a wait( ) inside any synchronized method, regardless of whether that class extends Thread or implements Runnable. In fact, the only place you can call wait( ), notify( ), or notifyAll( ) is within a synchronized method or block (sleep( ) can be called within non-synchronized methods since it doesn't manipulate the lock). If you call any of these within a method that's not synchronized, the program will compile, but when you run it, you'll get an IllegalMonitorStateException with the somewhat nonintuitive message « current thread not owner. » This message means that the thread calling wait( ), notify( ), or notifyAll( ) must « own » (acquire) the lock for the object before it can call any of these methods.

You can ask another object to perform an operation that manipulates its own lock. To do this, you must first capture that object's lock. For example, if you want to notify( ) an object x, you must do so inside a synchronized block that acquires the lock for x:

 
Sélectionnez
synchronized(x) {
    x.notify();
}

Typically, wait( ) is used when you're waiting for some condition that is under the control of forces outside of the current method to change (typically, this condition will be changed by another thread). You don't want to idly wait while testing the condition inside your thread; this is called a « busy wait » and it's a very bad use of CPU cycles. So wait( ) allows you to put the thread to sleep while waiting for the world to change, and only when a notify( ) or notifyAll( ) occurs does the thread wake up and check for changes. Thus, wait( ) provides a way to synchronize activities between threads.

As an example, consider a restaurant that has one chef and one waitperson. The waitperson must wait for the chef to prepare a meal. When the chef has a meal ready, the chef notifies the waitperson, who then gets the meal and goes back to waiting. This is an excellent example of thread cooperation: The chef represents the producer, and the waitperson represents the consumer. Here is the story modeled in code:

 
Sélectionnez
//: c13:Restaurant.java
// The producer-consumer approach to thread cooperation.
import com.bruceeckel.simpletest.*;

class Order {
    private static int i = 0;
    private int count = i++;
    public Order() {
        if(count == 10) {
            System.out.println("Out of food, closing");
            System.exit(0);
        }
    }
    public String toString() { return "Order " + count; }
}

class WaitPerson extends Thread {
    private Restaurant restaurant;
    public WaitPerson(Restaurant r) {
        restaurant = r;
        start();
    }
    public void run() {
        while(true) {
            while(restaurant.order == null)
                synchronized(this) {
                    try {
                        wait();
                    } catch(InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            System.out.println(
                "Waitperson got " + restaurant.order);
            restaurant.order = null;
        }
    }
}

class Chef extends Thread {
    private Restaurant restaurant;
    private WaitPerson waitPerson;
    public Chef(Restaurant r, WaitPerson w) {
        restaurant = r;
        waitPerson = w;
        start();
    }
    public void run() {
        while(true) {
            if(restaurant.order == null) {
                restaurant.order = new Order();
                System.out.print("Order up! ");
                synchronized(waitPerson) {
                    waitPerson.notify();
                }
            }
            try {
                sleep(100);
            } catch(InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

public class Restaurant {
    private static Test monitor = new Test();
    Order order; // Package access
    public static void main(String[] args) {
        Restaurant restaurant = new Restaurant();
        WaitPerson waitPerson = new WaitPerson(restaurant);
        Chef chef = new Chef(restaurant, waitPerson);
        monitor.expect(new String[] {
            "Order up! Waitperson got Order 0",
            "Order up! Waitperson got Order 1",
            "Order up! Waitperson got Order 2",
            "Order up! Waitperson got Order 3",
            "Order up! Waitperson got Order 4",
            "Order up! Waitperson got Order 5",
            "Order up! Waitperson got Order 6",
            "Order up! Waitperson got Order 7",
            "Order up! Waitperson got Order 8",
            "Order up! Waitperson got Order 9",
            "Out of food, closing"
        }, Test.WAIT);
    }
} ///:~

Order is a simple self-counting class, but notice that it also includes a way to terminate the program; on order 10, System.exit( ) is called.

A WaitPerson must know what Restaurant they are working for because they must fetch the order from the restaurant's « order window, » restaurant.order. In run( ), the WaitPerson goes into wait( ) mode, stopping that thread until it is woken up with a notify( ) from the Chef. Since this is a very simple program, we know that only one thread will be waiting on the WaitPerson's lock: the WaitPerson thread itself. For this reason it's safe to call notify( ). In more complex situations, multiple threads may be waiting on a particular object lock, so you don't know which thread should be awakened. The solutions is to call notifyAll( ), which wakes up all the threads waiting on that lock. Each thread must then decide whether the notification is relevant.

Notice that the wait( ) is wrapped in a while( ) statement that is testing for the same thing that is being waited for. This seems a bit strange at first-if you're waiting for an order, once you wake up the order must be available, right? The problem is that in a multithreading application, some other thread might swoop in and grab the order while the WaitPerson is waking up. The only safe approach is to always use the following idiom for a wait( ):

while(conditionIsNotMet)
wait( );

This guarantees that the condition will be met before you get out of the wait loop, and if you have either been notified of something that doesn't concern the condition (as can happen with notifyAll( )), or the condition changes before you get fully out of the wait loop, you are guaranteed to go back into waiting.

A Chef object must know what restaurant he or she is working for (so the Orders can be placed in restaurant.order)and the WaitPerson who is picking up the meals, so that WaitPerson can be notified when an order is ready. In this simplified example, the Chef is generating the Order objects, then notifying the WaitPerson that an order is ready.

Observe that the call to notify( ) must first capture the lock on waitPerson. The call to wait( ) in WaitPerson.run( ) automatically releases the lock, so this is possible. Because the lock must be owned in order to call notify( ), it's guaranteed that two threads trying to call notify( ) on one object won't step on each other's toes.

The preceding example has only a single spot for one thread to store an object so that another thread can later use that object. However, in a typical producer-consumer implementation, you use a first-in, first-out queue in order to store the objects being produced and consumed. See the exercises at the end of the chapter to learn more about this.

XIII-E-2. Using Pipes for I/O between threads

It's often useful for threads to communicate with each other by using I/O. Threading libraries may provide support for interthread I/O in the form of pipes. These exist in the Java I/O library as the classes PipedWriter (which allows a thread to write into a pipe) and PipedReader (which allows a different thread to read from the same pipe). This can be thought of as a variation of the producer-consumer problem, where the pipe is the canned solution.

Here's a simple example in which two threads use a pipe to communicate:

 
Sélectionnez
//: c13:PipedIO.java
// Using pipes for inter-thread I/O
import java.io.*;
import java.util.*;

class Sender extends Thread {
    private Random rand = new Random();
    private PipedWriter out = new PipedWriter();
    public PipedWriter getPipedWriter() { return out; }
    public void run() {
        while(true) {
            for(char c = 'A'; c <= 'z'; c++) {
                try {
                    out.write(c);
                    sleep(rand.nextInt(500));
                } catch(Exception e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}

class Receiver extends Thread {
    private PipedReader in;
    public Receiver(Sender sender) throws IOException {
        in = new PipedReader(sender.getPipedWriter());
    }
    public void run() {
        try {
            while(true) {
                // Blocks until characters are there:
                System.out.println("Read: " + (char)in.read());
            }
        } catch(IOException e) {
            throw new RuntimeException(e);
        }
    }
}

public class PipedIO {
    public static void main(String[] args) throws Exception {
        Sender sender = new Sender();
        Receiver receiver = new Receiver(sender);
        sender.start();
        receiver.start();
        new Timeout(4000, "Terminated");
    }
} ///:~

Sender and Receiver represent threads that are performing some tasks and need to communicate with each other. Sender creates a PipedWriter, which is a standalone object, but inside Receiver the creation of PipedReader must be associated with a PipedWriter in the constructor. The Sender puts data into the Writer and sleeps for a random amount of time. However, Receiver has no sleep( ) or wait( ). But when it does a read( ), it automatically blocks when there is no more data. You get the effect of a producer-consumer, but no wait( ) loop is necessary.

Notice that the sender and receiver are started in main( ), after the objects are completely constructed. If you don't start completely constructed objects, the pipe can produce inconsistent behavior on different platforms.

XIII-E-3. More sophisticated cooperation

Only the most basic cooperation approach (producer-consumer, usually implemented with wait( ) and notify( )/notifyAll( )) has been introduced in this section. This will solve most kinds of thread cooperation problems, but there are numerous more sophisticated approaches that are described in more advanced texts (in particular, Lea, noted at the end of this chapter).

XIII-F. Deadlock

Because threads can become blocked and because objects can have synchronized methods that prevent threads from accessing that object until the synchronization lock is released, it's possible for one thread to get stuck waiting for another thread, which in turn waits for another thread, etc., until the chain leads back to a thread waiting on the first one. You get a continuous loop of threads waiting on each other, and no one can move. This is called deadlock.

If you try running a program and it deadlocks right away, you immediately know you have a problem and you can track it down. The real problem is when your program seems to be working fine but has the hidden potential to deadlock. In this case you may get no indication that deadlocking is a possibility, so it will be latent in your program until it unexpectedly happens to a customer (and you probably won't be able to easily reproduce it). Thus, preventing deadlock by careful program design is a critical part of developing concurrent programs.

Let's look at the classic demonstration of deadlock, invented by Dijkstra: the dining philosophers problem. The basic description specifies five philosophers (but the example shown here will allow any number). These philosophers spend part of their time thinking and part of their time eating. While they are thinking, they don't need any shared resources, but when they are eating, they sit at a table with a limited number of utensils. In the original problem description, the utensils are forks, and two forks are required to get spaghetti from a bowl in the middle of the table, but it seems to make more sense to say that the utensils are chopsticks; clearly, each philosopher will require two chopsticks in order to eat.

A difficulty is introduced into the problem: As philosophers, they have very little money, so they can only afford five chopsticks. These are spaced around the table between them. When a philosopher wants to eat, he or she must get the chopstick to the left and the one to the right. If the philosopher on either side is using the desired chopstick, then our philosopher must wait.

Note that the reason this problem is interesting is because it demonstrates that a program can appear to run correctly but actually be deadlock prone. To show this, the command-line arguments allow you to adjust the number of philosophers and a factor to affect the amount of time each philosopher spends thinking. If you have lots of philosophers and/or they spend a lot of time thinking, you may never see the deadlock even though it remains a possibility. The default command-line arguments tend to make it deadlock fairly quickly:

 
Sélectionnez
//: c13:DiningPhilosophers.java
// Demonstrates how deadlock can be hidden in a program.
// {Args: 5 0 deadlock 4}
import java.util.*;

class Chopstick {
    private static int counter = 0;
    private int number = counter++;
    public String toString() {
        return "Chopstick " + number;
    }
}

class Philosopher extends Thread {
    private static Random rand = new Random();
    private static int counter = 0;
    private int number = counter++;
    private Chopstick leftChopstick;
    private Chopstick rightChopstick;
    static int ponder = 0; // Package access
    public Philosopher(Chopstick left, Chopstick right) {
        leftChopstick = left;
        rightChopstick = right;
        start();
    }
    public void think() {
        System.out.println(this + " thinking");
        if(ponder > 0)
            try {
                sleep(rand.nextInt(ponder));
            } catch(InterruptedException e) {
                throw new RuntimeException(e);
            }
    }
    public void eat() {
        synchronized(leftChopstick) {
            System.out.println(this + " has "
                + this.leftChopstick + " Waiting for "
                + this.rightChopstick);
            synchronized(rightChopstick) {
                System.out.println(this + " eating");
            }
        }
    }
    public String toString() {
        return "Philosopher " + number;
    }
    public void run() {
        while(true) {
            think();
            eat();
        }
    }
}

public class DiningPhilosophers {
    public static void main(String[] args) {
        if(args.length < 3) {
            System.err.println("usage:\n" +
                "java DiningPhilosophers numberOfPhilosophers " +
                "ponderFactor deadlock timeout\n" +
                "A nonzero ponderFactor will generate a random " +
                "sleep time during think().\n" +
                "If deadlock is not the string " +
                "'deadlock', the program will not deadlock.\n" +
                "A nonzero timeout will stop the program after " +
                "that number of seconds.");
            System.exit(1);
        }
        Philosopher[] philosopher =
            new Philosopher[Integer.parseInt(args[0])];
        Philosopher.ponder = Integer.parseInt(args[1]);
        Chopstick
            left = new Chopstick(),
            right = new Chopstick(),
            first = left;
        int i = 0;
        while(i < philosopher.length - 1) {
            philosopher[i++] =
                new Philosopher(left, right);
            left = right;
            right = new Chopstick();
        }
        if(args[2].equals("deadlock"))
            philosopher[i] = new Philosopher(left, first);
        else // Swapping values prevents deadlock:
            philosopher[i] = new Philosopher(first, left);
        // Optionally break out of program:
        if(args.length >= 4) {
            int delay = Integer.parseInt(args[3]);
            if(delay != 0)
                new Timeout(delay * 1000, "Timed out");
        }
    }
} ///:~

Both Chopstick and Philosopher use an autoincremented static counter to give each element an identification number. Each Philosopher is given a reference to a left and right Chopstick object; these are the utensils that must be picked up before that Philosopher can eat.

The static field ponder indicates whether the philosophers will spend any time thinking. If the value is nonzero, then it will be used to randomly generate a sleep time inside think( ). This way, you can show that if your threads (philosophers) are spending more time on other tasks (thinking) then they have a much lower probability of requiring the shared resources (chopsticks) and thus you can convince yourself that the program is deadlock free, even though it isn't.

Inside eat( ), a Philosopher acquires the left chopstick by synchronizing on it. If the chopstick is unavailable, then the philosopher blocks while waiting. When the left chopstick is acquired, the right one is acquired the same way. After eating, the right chopstick is released, then the left.

In run( ), each Philosopher just thinks and eats continuously.

The main( ) method requires at least three arguments and prints a usage message if these are not present. The third argument can be the string « deadlock, » in which case the deadlocking version of the program is used. Any other string will cause the non-deadlocking version to be used. The last (optional) argument is a timeout factor, which will abort the program after that number of seconds (whether it's deadlocked or not). The timeout is necessary for the program to be run automatically as part of the book code testing process.

After the array of Philosopher is created and the ponder value is set, two Chopstick objects are created, and the first one is also stored in the first variable for use later. Every reference in the array except the last one is initialized by creating a new Philosopher object and handing it the left and right chopsticks. After each initialization, the left chopstick is moved to the right and the right is given a new Chopstick object to be used for the next Philosopher.

In the deadlocking version, the last Philosopher is given the left chopstick and the first chopstick that was stored earlier. That's because the last Philosopher is sitting right next to the very first one, and they both share that first chopstick. With this arrangement, it's possible at some point for all the philosophers to be trying to eat and waiting on the philosopher next to them to put down their chopstick, and the program will deadlock.

Try experimenting with different command-line values to see how the program behaves, and in particular to see all the ways that the program can appear to be executing without deadlock.

To repair the problem, you must understand that deadlock can occur if four conditions are simultaneously met:

  • Mutual exclusion: At least one resource used by the threads must not be shareable. In this case, a chopstick can be used by only one philosopher at a time.
  • At least one process must be holding a resource and waiting to acquire a resource currently held by another process. That is, for deadlock to occur, a philosopher must be holding one chopstick and waiting for the other one.
  • A resource cannot be preemptively taken away from a process. All processes must only release resources as a normal event. Our philosophers are polite and they don't grab chopsticks from other philosophers.
  • A circular wait must happen, whereby a process waits on a resource held by another process, which in turn is waiting on a resource held by another process, and so on, until one of the processes is waiting on a resource held by the first process, thus gridlocking everything. In this example, the circular wait happens because each philosopher tries to get the left chopstick first and then the right. In the preceding example, the deadlock is broken by swapping the initialization order in the constructor for the last philosopher, causing that last philosopher to actually get the right chopstick first, then the left.

Because all of these conditions must be met in order to cause deadlock, you only need to stop one of them from occurring in order to prevent deadlock. In this program, the easiest way to prevent deadlock is to break condition four. This condition happens because each philosopher is trying to pick up their chopsticks in a particular sequence: first left, then right. Because of that, it's possible to get into a situation where each of them is holding their left chopstick and waiting to get the right one, causing the circular wait condition. However, if the last philosopher is initialized to try to get the right chopstick first and then the left, then that philosopher will never prevent the philosopher on the immediate left from picking up his or her right chopstick, so the circular wait is prevented. This is only one solution to the problem, but you could also solve it by preventing one of the other conditions (see more advanced threading books for more details).

There is no Java language support to help prevent deadlock; it's up to you to avoid it by careful design. These are not comforting words to the person who's trying to debug a deadlocking program.

XIII-G. The proper way to stop

One change that was introduced in Java 2 to reduce the possibility of deadlock is the deprecation of the Thread class's stop( ), suspend( ), and resume( ) methods.

The reason that the stop( ) method is deprecated is because it doesn't release the locks that the thread has acquired, and if the objects are in an inconsistent state (« damaged »), other threads can view and modify them in that state. The resulting problems can be subtle and difficult to detect. Instead of using stop( ), you should use a flag to tell the thread when to terminate itself by exiting its run( ) method. Here's a simple example:

 
Sélectionnez
//: c13:Stopping.java
// The safe way to stop a thread.
import java.util.*;

class CanStop extends Thread {
    // Must be volatile:
    private volatile boolean stop = false;
    private int counter = 0;
    public void run() {
        while(!stop && counter < 10000) {
            System.out.println(counter++);
        }
        if(stop)
            System.out.println("Detected stop");
    }
    public void requestStop() { stop = true; }
}

public class Stopping {
    public static void main(String[] args) {
        final CanStop stoppable = new CanStop();
        stoppable.start();
        new Timer(true).schedule(new TimerTask() {
            public void run() {
                System.out.println("Requesting stop");
                stoppable.requestStop();
            }
        }, 500); // run() after 500 milliseconds
    }
} ///:~

The flag stop must be volatile so that the run( ) method is sure to see it (otherwise the value may be cached locally). The « job » of this thread is to print out 10,000 numbers, so it is finished whenever counter >= 10000 or someone requests a stop. Note that requestStop( ) is not synchronized because stop is both boolean (changing it to true is an atomic operation) and volatile.

In main( ), a CanStop object is started, then a Timer is set up to call requestStop( ) after one half second. The constructor for Timer is passed the argument true to make it a daemon thread so that it doesn't prevent the program from terminating.

XIII-H. Interrupting a blocked thread

There are times when a thread blocks-such as when it is waiting for input-and it cannot poll a flag as it does in the previous example. In these cases, you can use the Thread.interrupt( ) method to break out of the blocked code:

 
Sélectionnez
//: c13:Interrupt.java
// Using interrupt() to break out of a blocked thread.
import java.util.*;

class Blocked extends Thread {
    public Blocked() {
        System.out.println("Starting Blocked");
        start();
    }
    public void run() {
        try {
            synchronized(this) {
                wait(); // Blocks
            }
        } catch(InterruptedException e) {
            System.out.println("Interrupted");
        }
        System.out.println("Exiting run()");
    }
}

public class Interrupt {
    static Blocked blocked = new Blocked();
    public static void main(String[] args) {
        new Timer(true).schedule(new TimerTask() {
            public void run() {
                System.out.println("Preparing to interrupt");
                blocked.interrupt();
                blocked = null; // to release it
            }
        }, 2000); // run() after 2000 milliseconds
    }
} ///:~

The wait( ) inside Blocked.run( ) produces the blocked thread. When the Timer runs out, the object's interrupt( ) method is called. Then the blocked reference is set to null so the garbage collector will clean it up (not necessary here, but important in a long-running program).

XIII-I. Thread groups

A thread group holds a collection of threads. The value of thread groups can be summed up by a quote from Joshua Bloch (72) , the software architect at Sun who fixed and greatly improved the Java collections library in JDK 1.2:

« Thread groups are best viewed as an unsuccessful experiment, and you may simply ignore their existence. »

If you've spent time and energy trying to figure out the value of thread groups (as I have), you may wonder why there was not some more official announcement from Sun on the topic, sooner than this (the same question could be asked about any number of other changes that have happened to Java over the years). The Nobel Laureate economist Joseph Stiglitz has a philosophy of life that would seem to apply here. (73) It's called The Theory of Escalating Commitment:

« The cost of continuing mistakes is borne by others, while the cost of admitting mistakes is borne by yourself. »

There is one tiny remaining use for thread groups. If a thread in the group throws an uncaught exception, ThreadGroup.uncaughtException( ) is invoked, which prints a stack trace to the standard error stream. If you want to modify this behavior, you must override this method.

XIII-J. Summary

It is vital to learn when to use concurrency and when to avoid it. The main reasons to use it are: to manage a number of tasks whose intermingling will make more efficient use of the computer (including the ability to transparently distribute the tasks across multiple CPUs), allow better code organization, or be more convenient for the user. The classic example of resource balancing is to use the CPU during I/O waits. The classic example of user convenience is to monitor a « stop » button during long downloads.

An additional advantage to threads is that they provide « light » execution context switches (on the order of 100 instructions) rather than « heavy » process context switches (thousands of instructions). Since all threads in a given process share the same memory space, a light context switch changes only program execution and local variables. A process change - the heavy context switch - must exchange the full memory space.

The main drawbacks to multithreading are:

  • Slowdown occurs while waiting for shared resources.
  • Additional CPU overhead is required to manage threads.
  • Unrewarded complexity arises from poor design decisions.
  • Opportunities are created for pathologies such as starving, racing, deadlock, and livelock.
  • Inconsistenciesoccur across platforms. For instance, while developing some of the examples for this book, I discovered race conditions that quickly appeared on some computers but that wouldn't appear on others. If you developed a program on the latter, you might get badly surprised when you distribute it.

One of the biggest difficulties with threads occurs because more than one thread might be sharing a resource-such as the memory in an object-and you must make sure that multiple threads don't try to read and change that resource at the same time. This requires judicious use of the synchronized keyword, which is an essential tool, but must be understood thoroughly because it can quietly introduce deadlock situations.

In addition, there's a certain art to the application of threads. Java is designed to allow you to create as many objects as you need to solve your problem-at least in theory. (Creating millions of objects for an engineering finite-element analysis, for example, might not be practical in Java.) However, it seems that there is an upper bound to the number of threads you'll want to create, because at some number, threads seem to become balky. This critical point can be hard to detect, and will often depend on the OS and JVM; it could be less than a hundred or in the thousands. As you often create only a handful of threads to solve a problem, this is typically not much of a limit; yet in a more general design it becomes a constraint.

A significant nonintuitive issue in threading is that, because of thread scheduling, you can typically make your applications run faster by inserting calls to yield( ) or even sleep( ) inside run( )'s main loop. This definitely makes it feel like an art, in particular when the longer delays seem to speed up performance. The reason this happens is that shorter delays can cause the end-of-sleep( ) scheduler interrupt to happen before the running thread is ready to go to sleep, forcing the scheduler to stop it and restart it later so it can finish what it was doing and then go to sleep. The extra context switches can end up slowing things down, and the use of yield( ) or sleep( ) may prevent the extra switches. It takes extra thought to realize how messy things can get.

For more advanced discussions of threading, see Concurrent Programming in Java, 2d Edition, by Doug Lea, Addison-Wesley, 2000.

XIII-K. Exercises

Solutions to selected exercises can be found in the electronic document The Thinking in Java Annotated Solution Guide, available for a small fee from www.BruceEckel.com.

  1. Inherit a class from Thread and override the run( ) method. Inside run( ), print a message, and then call sleep( ). Repeat this three times, then return from run( ). Put a start-up message in the constructor and override finalize( ) to print a shut-down message. Make a separate thread class that calls System.gc( ) and System.runFinalization( ) inside run( ), printing a message as it does so. Make several thread objects of both types and run them to see what happens.
  2. Experiment with different sleep times in Daemons.java to see what happens.
  3. In Chapter 8, locate the GreenhouseController.java example, which consists of four files. In Event.java, the class Event is based on watching the time. Change Event so that it is a Thread, and change the rest of the design so that it works with this new Thread-based Event.
  4. Modify the previous exercise so that the java.util.Timer class is used to run the system.
  5. Modify SimpleThread.java so that all the threads are daemon threads and verify that the program ends as soon as main( ) is able to exit.
  6. Demonstrate that java.util.Timer scales to large numbers by creating a program that generates many Timer objects that perform some simple task when the timeout completes (if you want to get fancy, you can jump forward to the « Windows and Applets » chapter and use the Timer objects to draw pixels on the screen, but printing to the console is sufficient).
  7. Demonstrate that a synchronized method in a class can call a second synchronized method in the same class, which can then call a third synchronized method in the same class. Create a separate Thread object that invokes the first synchronized method.
  8. Create two Thread subclasses, one with a run( ) that starts up and then calls wait( ). The other class's run( ) should capture the reference of the first Thread object. Its run( ) should call notifyAll( ) for the first thread after some number of seconds have passed so that first thread can print a message.
  9. Create an example of a « busy wait. » One thread sleeps for awhile and then sets a flag to true. The second thread watches that flag inside a while loop (this is the « busy wait ») and when the flag becomes true, sets it back to false and reports the change to the console. Note how much wasted time the program spends inside the « busy wait » and create a second version of the program that uses wait( ) instead of the « busy wait. »
  10. Modify Restaurant.java to use notifyAll( ) and observe any difference in behavior.
  11. Modify Restaurant.java so that there are multiple WaitPersons, and indicate which one gets each Order.
  12. Modify Restaurant.java so that multiple WaitPersons generate order requests to multiple Chefs, who produce orders and notify the WaitPerson who generated the request. You'll need to use queues for both incoming order requests and outgoing orders.
  13. Modify the previous exercise to add Customer objects that are also threads. The Customers will place order requests with WaitPersons, who give the requests to the Chefs, who fulfill the orders and notify the appropriate WaitPerson, who gives it to the appropriate Customer.
  14. Modify PipedIO.java so that Sender reads and sends lines from a text file.
  15. Change DiningPhilosophers.java so that the philosophers just pick the next available chopstick (when a philosopher is done with their chopsticks, they drop them into a bin. When a philosopher wants to eat, they take the next two available chopsticks from the bin). Does this eliminate the possibility of deadlock? Can you re-introduce deadlock by simply reducing the number of available chopsticks?
  16. Inherit a class from java.util.Timer and implement the requestStop( ) method as in Stopping.java.
  17. Modify SimpleThread.java so that all threads receive an interrupt( ) before they are completed.
  18. Solve a single producer, single consumer problem using wait( ) and notify( ). The producer must not overflow the receiver's buffer, which can happen if the producer is faster than the consumer. If the consumer is faster than the producer, then it must not read the same data more than once. Do not assume anything about the relative speeds of the producer or consumer.

précédentsommairesuivant
Runnable existait dans Java 1.0, tandis que les classes internes ne furent introduites qu'avec Java 1.1, ce qui peut, en partie, justifier l'existence de Runnable. De plus, les architectures multithreads traditionnelles se concentraient sur l'exécution d'une fonction plutôt que sur un objet. Je préfère toujours hériter de la classe Thread quand c'est possible ; cela me semble plus net et plus souple.
Certains exemples furent développés sur une machine bi-processeur Win2K qui montrait immédiatement les collisions. Néanmoins, les mêmes exemples exécutés sur des machines à processeur unique peuvent tourner pendant un laps de temps assez long sans montrer de collision : c'est le genre de comportement angoissant qui rend la programmation multithread difficile. On peut développer sur une machine à processeur unique et penser que le code produit est compatible avec les threads, puis découvrir des dysfonctionnements dès qu'on l'installe sur une machine bi-processeur.
Inspired by Joshua Bloch's Effective Java, Addison-Wesley 2001, page 190.
See Design Patterns, by Gamma et. al., Addison-Wesley 1995.
Effective Java, by Joshua Bloch, Addison-Wesley 2001, page 211.
And in a number of other places throughout the experience of Java. Well, why stop there?-I've consulted on more than a few projects where this has applied.

Ce document est issu de http://www.developpez.com et reste la propriété exclusive de son auteur. La copie, modification et/ou distribution par quelque moyen que ce soit est soumise à l'obtention préalable de l'autorisation de l'auteur.