Référence
Programmation
concurrente en
Brian Goetz
Java
avec Tim Peierls, Joshua Bloch, Joseph Bowbeer,
David Holmes et Doug Lea
Réseaux
et télécom
Programmation
Génie logiciel
Sécurité
Système
d’exploitation
Programmation
concurrente
en Java
Brian Goetz
Avec la collaboration de :
Tim Peierls,
Joshua Bloch,
Joseph Bowbeer,
David Holmes
et Doug Lea
Traduction : Éric Jacoboni
Relecture technique : Éric Hébert, Architecte Java JEE
Nicolas de Loof, Architecte Java
Pearson Education France a apporté le plus grand soin à la réalisation de ce livre afin de vous four-
nir une information complète et fiable. Cependant, Pearson Education France n’assume de respon-
sabilités, ni pour son utilisation, ni pour les contrefaçons de brevets ou atteintes aux droits de tierces
personnes qui pourraient résulter de cette utilisation.
Les exemples ou les programmes présents dans cet ouvrage sont fournis pour illustrer les descriptions
théoriques. Ils ne sont en aucun cas destinés à une utilisation commerciale ou professionnelle.
Pearson Education France ne pourra en aucun cas être tenu pour responsable des préjudices
ou dommages de quelque nature que ce soit pouvant résulter de l’utilisation de ces exemples ou
programmes.
Tous les noms de produits ou marques cités dans ce livre sont des marques déposées par leurs
propriétaires respectifs.
Publié par Pearson Education France Titre original :
47 bis, rue des Vinaigriers Java Concurrency in Practice
75010 PARIS
Tél. : 01 72 74 90 00 Traduit de l’américain par Éric Jacoboni
www.pearson.fr
ISBN original : 978-0-321-34960-6
Mise en pages : TyPAO Copyright © 2006 by Pearson Education, Inc
All rights reserved
ISBN : 978-2-7440-4109-9
Copyright © 2009 Pearson Education France Édition originale publiée par
Tous droits réservés Addison-Wesley Professional,
800 East 96th Street, Indianapolis,
Indiana 46240 USA
Aucune représentation ou reproduction, même partielle, autre que celles prévues à l’article L. 122-5 2˚ et 3˚ a) du code de la
propriété intellectuelle ne peut être faite sans l’autorisation expresse de Pearson Education France ou, le cas échéant, sans
le respect des modalités prévues à l’article L. 122-10 dudit code.
No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including
photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.
Table des matières
Table des listings ................................................................................................................ XI
Préface ................................................................................................................................ XIX
Préface à l’édition française ............................................................................................. XXI
Présentation de l’ouvrage ................................................................................................. XXIII
Structure de l’ouvrage ................................................................................................ XXIII
Exemples de code ....................................................................................................... XXV
Remerciements ........................................................................................................... XXVI
1 Introduction .................................................................................................................. 1
1.1 Bref historique de la programmation concurrente .......................................... 1
1.2 Avantages des threads ..................................................................................... 3
1.2.1 Exploitation de plusieurs processeurs ............................................... 3
1.2.2 Simplicité de la modélisation ............................................................ 4
1.2.3 Gestion simplifiée des événements asynchrones .............................. 5
1.2.4 Interfaces utilisateur plus réactives ................................................... 5
1.3 Risques des threads ......................................................................................... 6
1.3.1 Risques concernant la "thread safety" ............................................... 6
1.3.2 Risques sur la vivacité ....................................................................... 9
1.3.3 Risques sur les performances ............................................................ 9
1.4 Les threads sont partout .................................................................................. 10
Partie I
Les bases
2 Thread safety ................................................................................................................ 15
2.1 Qu’est-ce que la thread safety ? ...................................................................... 17
2.1.1 Exemple : une servlet sans état ......................................................... 19
2.2 Atomicité ......................................................................................................... 19
2.2.1 Situations de compétition .................................................................. 21
IV Table des matières
2.2.2 Exemple : situations de compétition dans une initialisation
paresseuse .......................................................................................... 22
2.2.3 Actions composées ............................................................................ 23
2.3 Verrous ............................................................................................................ 24
2.3.1 Verrous internes ................................................................................. 26
2.3.2 Réentrance ......................................................................................... 27
2.4 Protection de l’état avec les verrous ................................................................ 28
2.5 Vivacité et performances ................................................................................. 31
3 Partage des objets ......................................................................................................... 35
3.1 Visibilité .......................................................................................................... 35
3.1.1 Données obsolètes ............................................................................. 37
3.1.2 Opérations 64 bits non atomiques ..................................................... 38
3.1.3 Verrous et visibilité ........................................................................... 39
3.1.4 Variables volatiles ............................................................................. 40
3.2 Publication et fuite .......................................................................................... 42
3.2.1 Pratiques de construction sûres ......................................................... 44
3.3 Confinement des objets ................................................................................... 45
3.3.1 Confinement ad hoc .......................................................................... 46
3.3.2 Confinement dans la pile ................................................................... 46
3.3.3 ThreadLocal ..................................................................................... 47
3.4 Objets non modifiables .................................................................................... 49
3.4.1 Champs final ................................................................................. 51
3.4.2 Exemple : utilisation de volatile pour publier des objets
non modifiables ................................................................................. 51
3.5 Publication sûre ............................................................................................... 53
3.5.1 Publication incorrecte : quand les bons objets deviennent mauvais . 53
3.5.2 Objets non modifiables et initialisation sûre ..................................... 54
3.5.3 Idiomes de publication correcte ........................................................ 55
3.5.4 Objets non modifiables dans les faits ................................................ 56
3.5.5 Objets modifiables ............................................................................ 57
3.5.6 Partage d’objets de façon sûre ........................................................... 57
4 Composition d’objets ................................................................................................... 59
4.1 Conception d’une classe thread-safe ............................................................... 59
4.1.1 Exigences de synchronisation ........................................................... 60
4.1.2 Opérations dépendantes de l’état ...................................................... 61
4.1.3 Appartenance de l’état ...................................................................... 62
4.2 Confinement des instances .............................................................................. 63
4.2.1 Le patron moniteur de Java ............................................................... 65
4.2.2 Exemple : gestion d’une flotte de véhicules ...................................... 66
Table des matières V
4.3 Délégation de la thread safety ......................................................................... 67
4.3.1 Exemple : gestionnaire de véhicules utilisant la délégation ............. 69
4.3.2 Variables d’état indépendantes .......................................................... 70
4.3.3 Échecs de la délégation ..................................................................... 71
4.3.4 Publication des variables d’état sous-jacentes .................................. 73
4.3.5 Exemple : gestionnaire de véhicules publiant son état ..................... 73
4.4 Ajout de fonctionnalités à des classes thread-safe existantes ............................ 75
4.4.1 Verrouillage côté client ..................................................................... 76
4.4.2 Composition ...................................................................................... 78
4.5 Documentation des politiques de synchronisation .......................................... 78
4.5.1 Interprétation des documentations vagues ........................................ 80
5 Briques de base ............................................................................................................. 83
5.1 Collections synchronisées ............................................................................... 83
5.1.1 Problèmes avec les collections synchronisées .................................. 83
5.1.2 Itérateurs et ConcurrentModificationException ........................ 86
5.1.3 Itérateurs cachés ................................................................................ 87
5.2 Collections concurrentes ................................................................................. 88
5.2.1 ConcurrentHashMap ......................................................................... 89
5.2.2 Opérations atomiques supplémentaires sur les Map .......................... 91
5.2.3 CopyOnWriteArrayList ................................................................... 91
5.3 Files bloquantes et patron producteur-consommateur .................................... 92
5.3.1 Exemple : indexation des disques ..................................................... 94
5.3.2 Confinement en série ......................................................................... 96
5.3.3 Classe Deque et vol de tâches ........................................................... 97
5.4 Méthodes bloquantes et interruptions ............................................................. 97
5.5 Synchronisateurs ............................................................................................. 99
5.5.1 Loquets .............................................................................................. 99
5.5.2 FutureTask ....................................................................................... 101
5.5.3 Sémaphores ....................................................................................... 103
5.5.4 Barrières ............................................................................................ 105
5.6 Construction d’un cache efficace et adaptable ................................................ 107
Résumé de la première partie........................................................................................... 113
Partie II
Structuration des applications concurrentes
6 Exécution des tâches..................................................................................................... 117
6.1 Exécution des tâches dans les threads ............................................................. 117
6.1.1 Exécution séquentielle des tâches ..................................................... 118
VI Table des matières
6.1.2 Création explicite de threads pour les tâches .................................... 119
6.1.3 Inconvénients d’une création illimitée de threads ............................. 120
6.2 Le framework Executor ................................................................................. 121
6.2.1 Exemple : serveur web utilisant Executor ....................................... 122
6.2.2 Politiques d’exécution ....................................................................... 123
6.2.3 Pools de threads ................................................................................ 124
6.2.4 Cycle de vie d’un Executor ............................................................. 125
6.2.5 Tâches différées et périodiques ......................................................... 127
6.3 Trouver un parallélisme exploitable ................................................................ 128
6.3.1 Exemple : rendu séquentiel d’une page ............................................ 129
6.3.2 Tâches partielles : Callable et Future ............................................ 129
6.3.3 Exemple : affichage d’une page avec Future ................................... 131
6.3.4 Limitations du parallélisme de tâches hétérogènes ........................... 132
6.3.5 CompletionService : quand Executor rencontre BlockingQueue 133
6.3.6 Exemple : affichage d’une page avec CompletionService ............. 134
6.3.7 Imposer des délais aux tâches ........................................................... 135
6.3.8 Exemple : portail de réservations ...................................................... 136
Résumé ....................................................................................................................... 137
7 Annulation et arrêt ....................................................................................................... 139
7.1 Annulation des tâches ..................................................................................... 140
7.1.1 Interruption ........................................................................................ 142
7.1.2 Politiques d’interruption ................................................................... 145
7.1.3 Répondre aux interruptions ............................................................... 146
7.1.4 Exemple : exécution avec délai ......................................................... 148
7.1.5 Annulation avec Future ................................................................... 150
7.1.6 Méthodes bloquantes non interruptibles ........................................... 151
7.1.7 Encapsulation d’une annulation non standard avec newTaskFor() 153
7.2 Arrêt d’un service reposant sur des threads .................................................... 154
7.2.1 Exemple : service de journalisation .................................................. 155
7.2.2 Méthodes d’arrêt de ExecutorService ........................................... 158
7.2.3 Pilules empoisonnées ........................................................................ 159
7.2.4 Exemple : un service d’exécution éphémère ..................................... 160
7.2.5 Limitations de shutdownNow() ........................................................ 161
7.3 Gestion de la fin anormale d’un thread ........................................................... 163
7.3.1 Gestionnaires d’exceptions non capturées ........................................ 165
7.4 Arrêt de la JVM ............................................................................................... 166
7.4.1 Méthodes d’interception d’un ordre d’arrêt ...................................... 166
7.4.2 Threads démons ................................................................................ 168
7.4.3 Finaliseurs ......................................................................................... 168
Résumé ....................................................................................................................... 169
Table des matières VII
8 Pools de threads ............................................................................................................ 171
8.1 Couplage implicite entre les tâches et les politiques d’exécution ................... 171
8.1.1 Interblocage par famine de thread ..................................................... 173
8.1.2 Tâches longues .................................................................................. 174
8.2 Taille des pools de threads .............................................................................. 174
8.3 Configuration de ThreadPoolExecutor ......................................................... 176
8.3.1 Création et suppression de threads .................................................... 176
8.3.2 Gestion des tâches en attente ............................................................ 177
8.3.3 Politiques de saturation ..................................................................... 179
8.3.4 Fabriques de threads .......................................................................... 181
8.3.5 Personnalisation de ThreadPoolExecutor après sa construction .... 183
8.4 Extension de ThreadPoolExecutor ............................................................... 183
8.4.1 Exemple : ajout de statistiques à un pool de threads ........................ 184
8.5 Parallélisation des algorithmes récursifs ......................................................... 185
8.5.1 Exemple : un framework de jeu de réflexion .................................... 187
Résumé ....................................................................................................................... 192
9 Applications graphiques .............................................................................................. 193
9.1 Pourquoi les interfaces graphiques sont-elles monothreads ? ............................ 193
9.1.1 Traitement séquentiel des événements .............................................. 195
9.1.2 Confinement aux threads avec Swing ............................................... 196
9.2 Tâches courtes de l’interface graphique .......................................................... 198
9.3 Tâches longues de l’interface graphique ......................................................... 199
9.3.1 Annulation ......................................................................................... 201
9.3.2 Indication de progression et de terminaison ..................................... 202
9.3.3 SwingWorker .................................................................................... 204
9.4 Modèles de données partagées ........................................................................ 204
9.4.1 Modèles de données thread-safe ....................................................... 205
9.4.2 Modèles de données séparés ............................................................. 205
9.5 Autres formes de sous-systèmes monothreads ................................................ 206
Résumé ....................................................................................................................... 206
Partie III
Vivacité, performances et tests
10 Éviter les problèmes de vivacité ................................................................................ 209
10.1 Interblocages (deadlock) ................................................................................ 209
10.1.1 Interblocages liés à l’ordre du verrouillage ....................................... 210
10.1.2 Interblocages dynamiques liés à l’ordre du verrouillage .................. 212
10.1.3 Interblocages entre objets coopératifs ............................................... 215
VIII Table des matières
10.1.4 Appels ouverts ................................................................................... 216
10.1.5 Interblocages liés aux ressources ...................................................... 218
10.2 Éviter et diagnostiquer les interblocages ......................................................... 219
10.2.1 Tentatives de verrouillage avec expiration ........................................ 219
10.2.2 Analyse des interblocages avec les traces des threads ...................... 220
10.3 Autres problèmes de vivacité .......................................................................... 221
10.3.1 Famine ............................................................................................... 222
10.3.2 Faible réactivité ................................................................................. 223
10.3.3 Livelock ............................................................................................. 223
Résumé ....................................................................................................................... 224
11 Performances et adaptabilité..................................................................................... 225
11.1 Penser aux performances ................................................................................. 225
11.1.1 Performances et adaptabilité ............................................................. 226
11.1.2 Compromis sur l’évaluation des performances ................................. 228
11.2 La loi d’Amdahl .............................................................................................. 230
11.2.1 Exemple : sérialisation cachée dans les frameworks ........................ 232
11.2.2 Application qualitative de la loi d’Amdahl ....................................... 233
11.3 Coûts liés aux threads ...................................................................................... 234
11.3.1 Changements de contexte .................................................................. 234
11.3.2 Synchronisation de la mémoire ......................................................... 235
11.3.3 Blocages ............................................................................................ 237
11.4 Réduction de la compétition pour les verrous ................................................. 237
11.4.1 Réduction de la portée des verrous ("entrer, sortir") ......................... 238
11.4.2 Réduire la granularité du verrouillage .............................................. 240
11.4.3 Découpage du verrouillage ............................................................... 242
11.4.4 Éviter les points chauds ..................................................................... 244
11.4.5 Alternatives aux verrous exclusifs .................................................... 245
11.4.6 Surveillance de l’utilisation du processeur ....................................... 245
11.4.7 Dire non aux pools d’objets .............................................................. 246
11.5 Exemple : comparaison des performances des Map ......................................... 247
11.6 Réduction du surcoût des changements de contexte ....................................... 249
Résumé ....................................................................................................................... 251
12 Tests des programmes concurrents........................................................................... 253
12.1 Tests de la justesse .......................................................................................... 254
12.1.1 Tests unitaires de base ....................................................................... 256
12.1.2 Tests des opérations bloquantes ........................................................ 256
12.1.3 Test de la sécurité vis-à-vis des threads ............................................ 258
12.1.4 Test de la gestion des ressources ....................................................... 263
12.1.5 Utilisation des fonctions de rappel .................................................... 264
12.1.6 Production d’entrelacements supplémentaires .................................. 265
Table des matières IX
12.2 Tests de performances ..................................................................................... 266
12.2.1 Extension de PutTakeTest pour ajouter un timing .......................... 266
12.2.2 Comparaison de plusieurs algorithmes ............................................. 269
12.2.3 Mesure de la réactivité ...................................................................... 270
12.3 Pièges des tests de performance ...................................................................... 272
12.3.1 Ramasse-miettes ................................................................................ 272
12.3.2 Compilation dynamique .................................................................... 272
12.3.3 Échantillonnage irréaliste de portions de code ................................. 274
12.3.4 Degrés de compétition irréalistes ...................................................... 274
12.3.5 Élimination du code mort .................................................................. 275
12.4 Approches de tests complémentaires .............................................................. 277
12.4.1 Relecture du code .............................................................................. 277
12.4.2 Outils d’analyse statiques .................................................................. 278
12.4.3 Techniques de tests orientées aspects ................................................ 279
12.4.4 Profileurs et outils de surveillance .................................................... 280
Résumé ....................................................................................................................... 280
Partie IV
Sujets avancés
13 Verrous explicites........................................................................................................ 283
13.1 Lock et ReentrantLock .................................................................................. 283
13.1.1 Prise de verrou scrutable et avec délai .............................................. 285
13.1.2 Prise de verrou interruptible .............................................................. 287
13.1.3 Verrouillage non structuré en bloc .................................................... 287
13.2 Remarques sur les performances ..................................................................... 288
13.3 Équité .............................................................................................................. 289
13.4 synchronized vs. ReentrantLock ................................................................ 291
13.5 Verrous en lecture-écriture .............................................................................. 292
Résumé ....................................................................................................................... 296
14 Construction de synchronisateurs personnalisés..................................................... 297
14.1 Gestion de la dépendance par rapport à l’état ................................................. 297
14.1.1 Exemple : propagation de l’échec de la précondition aux appelants 299
14.1.2 Exemple : blocage brutal par essai et mise en sommeil .................... 301
14.1.3 Les files d’attente de condition ......................................................... 303
14.2 Utilisation des files d’attente de condition ...................................................... 304
14.2.1 Le prédicat de condition .................................................................... 304
14.2.2 Réveil trop précoce ........................................................................... 306
X Table des matières
14.2.3 Signaux manqués .............................................................................. 307
14.2.4 Notification ....................................................................................... 308
14.2.5 Exemple : une classe "porte d’entrée" .............................................. 310
14.2.6 Problèmes de safety des sous-classes ................................................ 311
14.2.7 Encapsulation des files d’attente de condition .................................. 312
14.2.8 Protocoles d’entrée et de sortie ......................................................... 312
14.3 Objets conditions explicites ............................................................................ 313
14.4 Anatomie d’un synchronisateur ...................................................................... 315
14.5 AbstractQueuedSynchronizer ..................................................................... 317
14.5.1 Un loquet simple ............................................................................... 319
14.6 AQS dans les classes de java.util.concurrent ......................................... 320
14.6.1 ReentrantLock ................................................................................. 320
14.6.2 Semaphore et CountDownLatch ........................................................ 321
14.6.3 FutureTask ....................................................................................... 322
14.6.4 ReentrantReadWriteLock ............................................................... 322
Résumé ....................................................................................................................... 323
15 Variables atomiques et synchronisation non bloquante.......................................... 325
15.1 Inconvénients du verrouillage ......................................................................... 326
15.2 Support matériel de la concurrence ................................................................. 327
15.2.1 L’instruction Compare-and-swap ..................................................... 328
15.2.2 Compteur non bloquant ..................................................................... 329
15.2.3 Support de CAS dans la JVM ........................................................... 331
15.3 Classes de variables atomiques ....................................................................... 331
15.3.1 Variables atomiques comme "volatiles améliorées" ......................... 332
15.3.2 Comparaison des performances des verrous
et des variables atomiques ................................................................. 333
15.4 Algorithmes non bloquants ............................................................................. 336
15.4.1 Pile non bloquante ............................................................................. 337
15.4.2 File chaînée non bloquante ............................................................... 338
15.4.3 Modificateurs atomiques de champs ................................................. 342
15.4.4 Le problème ABA ............................................................................. 342
Résumé ....................................................................................................................... 343
16 Le modèle mémoire de Java....................................................................................... 345
16.1 Qu’est-ce qu’un modèle mémoire et pourquoi
en a-t-on besoin ? ............................................................................................ 345
16.1.1 Modèles mémoire des plates-formes ................................................. 346
16.1.2 Réorganisation .................................................................................. 347
16.1.3 Le modèle mémoire de Java en moins de cinq cents mots ............... 349
16.1.4 Tirer parti de la synchronisation ....................................................... 351
Table des matières XI
16.2 Publication ....................................................................................................... 353
16.2.1 Publication incorrecte ....................................................................... 353
16.2.2 Publication correcte ........................................................................... 354
16.2.3 Idiomes de publication correcte ........................................................ 355
16.2.4 Verrouillage contrôlé deux fois ......................................................... 356
16.3 Initialisation sûre ............................................................................................. 358
Résumé ....................................................................................................................... 359
Annexe Annotations pour la concurrence.................................................................. 361
A.1 Annotations de classes .................................................................................... 361
A.2 Annotations de champs et de méthodes .......................................................... 361
Bibliographie...................................................................................................................... 363
Index ................................................................................................................................... 365
Table des listings
Listing 1 : Mauvaise façon de trier une liste. Ne le faites pas. ........................................... XX
Listing 2 : Méthode peu optimale de trier une liste. ........................................................... XX
Listing 1.1 : Générateur de séquence non thread-safe. ....................................................... 7
Listing 1.2 : Générateur de séquence thread-safe. .............................................................. 8
Listing 2.1 : Une servlet sans état. ...................................................................................... 19
Listing 2.2 : Servlet comptant le nombre de requêtes sans la synchronisation
nécessaire. Ne le faites pas. ........................................................................... 20
Listing 2.3 : Situation de compétition dans une initialisation paresseuse. Ne le faites pas. 22
Listing 2.4 : Servlet comptant les requêtes avec AtomicLong. .......................................... 24
Listing 2.5 : Servlet tentant de mettre en cache son dernier résultat sans l’atomicité
adéquate. Ne le faites pas. .............................................................................. 25
Listing 2.6 : Servlet mettant en cache le dernier résultat, mais avec une très mauvaise
concurrence. Ne le faites pas. ........................................................................ 27
Listing 2.7 : Ce code se bloquerait si les verrous internes n’étaient pas réentrants. .......... 28
Listing 2.8 : Servlet mettant en cache la dernière requête et son résultat. ......................... 32
Listing 3.1 : Partage de données sans synchronisation. Ne le faites pas. ........................... 36
Listing 3.2 : Conteneur non thread-safe pour un entier modifiable. ................................... 38
Listing 3.3 : Conteneur thread-safe pour un entier modifiable. .......................................... 38
Listing 3.4 : Compter les moutons. .................................................................................... 41
Listing 3.5 : Publication d’un objet. ................................................................................... 42
Listing 3.6 : L’état modifiable interne à la classe peut s’échapper. Ne le faites pas. ......... 42
Listing 3.7 : Permet implicitement à la référence this de s’échapper. Ne le faites pas. ..... 43
Listing 3.8 : Utilisation d’une méthode fabrique pour empêcher la référence this
de s’échapper au cours de la construction de l’objet. .................................... 44
Listing 3.9 : Confinement des variables locales, de types primitifs ou de types références. 47
Listing 3.10 : Utilisation de ThreadLocal pour garantir le confinement au thread. .......... 48
Listing 3.11 : Classe non modifiable construite à partir d’objets modifiables sous-jacents. 50
Listing 3.12 : Conteneur non modifiable pour mettre en cache un nombre et ses facteurs. 52
Listing 3.13 : Mise en cache du dernier résultat à l’aide d’une référence volatile vers
un objet conteneur non modifiable. ............................................................. 52
Listing 3.14 : Publication d’un objet sans synchronisation appropriée. Ne le faites pas. .... 53
Listing 3.15 : Classe risquant un problème si elle n’est pas correctement publiée. ........... 54
XIV Table des listings
Listing 4.1 : Compteur mono-thread utilisant le patron moniteur de Java. ........................ 60
Listing 4.2 : Utilisation du confinement pour assurer la thread safety. .............................. 64
Listing 4.3 : Protection de l’état à l’aide d’un verrou privé. .............................................. 66
Listing 4.4 : Implémentation du gestionnaire de véhicule reposant sur un moniteur. ........ 68
Listing 4.5 : Classe Point modifiable ressemblant à java.awt.Point. ........................... 69
Listing 4.6 : Classe Point non modifiable utilisée par DelegatingVehicleTracker. .... 69
Listing 4.7 : Délégation de la thread safety à un objet ConcurrentHashMap. ................... 69
Listing 4.8 : Renvoi d’une copie statique de l’ensemble des emplacements au lieu
d’une copie "vivante". .................................................................................... 70
Listing 4.9 : Délégation de la thread à plusieurs variables d’état sous-jacentes. ............... 71
Listing 4.10 : Classe pour des intervalles numériques, qui ne protège pas suffisamment
ses invariants. Ne le faites pas. .................................................................... 71
Listing 4.11 : Classe point modifiable et thread-safe. ...................................................... 73
Listing 4.12 : Gestionnaire de véhicule qui publie en toute sécurité son état interne. ....... 74
Listing 4.13 : Extension de Vector pour disposer d’une méthode ajouter-si-absent. ....... 76
Listing 4.14 : Tentative non thread-safe d’implémenter ajouter-si-absent. Ne le faites pas. 76
Listing 4.15 : Implémentation d’ajouter-si-absent avec un verrouillage côté client. ......... 77
Listing 4.16 : Implémentation d’ajouter-si-absent en utilisant la composition. ................ 78
Listing 5.1 : Actions composées sur un Vector pouvant produire des résultats inattendus. 84
Listing 5.2 : Actions composées sur Vector utilisant un verrouillage côté client. ............ 85
Listing 5.3 : Itération pouvant déclencher ArrayIndexOutOfBoundsException. ............ 85
Listing 5.4 : Itération avec un verrouillage côté client. ...................................................... 86
Listing 5.5 : Parcours d’un objet List avec un Iterator. ................................................ 86
Listing 5.6 : Itération cachée dans la concaténation des chaînes. Ne le faites pas. ............ 88
Listing 5.7 : Interface ConcurrentMap. ............................................................................. 91
Listing 5.8 : Tâches producteur et consommateur dans une application d’indexation
des fichiers. .................................................................................................... 95
Listing 5.9 : Lancement de l’indexation. ............................................................................ 96
Listing 5.10 : Restauration de l’état d’interruption afin de ne pas absorber l’interruption. 99
Listing 5.11 : Utilisation de la classe CountDownLatch pour lancer et stopper
des threads et mesurer le temps d’exécution. .............................................. 101
Listing 5.12 : Utilisation de FutureTask pour précharger des données
dont on aura besoin plus tard. ...................................................................... 102
Listing 5.13 : Coercition d’un objet Throwable non contrôlé en RuntimeException. .... 103
Listing 5.14 : Utilisation d’un Semaphore pour borner une collection. ............................. 104
Listing 5.15 : Coordination des calculs avec CyclicBarrier pour une simulation de cellules. 106
Listing 5.16 : Première tentative de cache, utilisant HashMap et la synchronisation. ......... 107
Listing 5.17 : Remplacement de HashMap par ConcurrentHashMap. ................................ 109
Listing 5.18 : Enveloppe de mémoïsation utilisant FutureTask. ...................................... 110
Table des listings XV
Listing 5.19 : Implémentation finale de Memoizer. ............................................................ 111
Listing 5.20 : Servlet de factorisation mettant en cache ses résultats avec Memoizer. ...... 112
Listing 6.1 : Serveur web séquentiel. ................................................................................. 118
Listing 6.2 : Serveur web lançant un thread par requête. ................................................... 119
Listing 6.3 : Interface Executor. ........................................................................................ 121
Listing 6.4 : Serveur web utilisant un pool de threads. ...................................................... 122
Listing 6.5 : Executor lançant un nouveau thread pour chaque tâche. ............................. 123
Listing 6.6 : Executor exécutant les tâches de façon synchrone dans le thread appelant. 123
Listing 6.7 : Méthodes de ExecutorService pour le cycle de vie. ................................... 126
Listing 6.8 : Serveur web avec cycle de vie. ...................................................................... 126
Listing 6.9 : Classe illustrant le comportement confus de Timer. ...................................... 128
Listing 6.10 : Affichage séquentiel des éléments d’une page. ............................................ 129
Listing 6.11 : Interfaces Callable et Future. ................................................................... 130
Listing 6.12 : Implémentation par défaut de newTaskFor() dans ThreadPoolExecutor. 131
Listing 6.13 : Attente du téléchargement d’image avec Future. ....................................... 131
Listing 6.14 : La classe QueueingFuture utilisée par ExecutorCompletionService. ..... 134
Listing 6.15 : Utilisation de CompletionService pour afficher les éléments de la page
dès qu’ils sont disponibles. .......................................................................... 134
Listing 6.16 : Récupération d’une publicité dans un délai imparti. ................................... 135
Listing 6.17 : Obtention de tarifs dans un délai imparti. .................................................... 137
Listing 7.1 : Utilisation d’un champ volatile pour stocker l’état d’annulation. ............. 140
Listing 7.2 : Génération de nombres premiers pendant une seconde. ................................ 141
Listing 7.3 : Annulation non fiable pouvant bloquer les producteurs. Ne le faites pas. ..... 142
Listing 7.4 : Méthodes d’interruption de Thread. .............................................................. 143
Listing 7.5 : Utilisation d’une interruption pour l’annulation. ........................................... 144
Listing 7.6 : Propagation de InterruptedException aux appelants. ............................... 147
Listing 7.7 : Tâche non annulable qui restaure l’interruption avant de se terminer. .......... 147
Listing 7.8 : Planification d’une interruption sur un thread emprunté. Ne le faites pas. .... 149
Listing 7.9 : Interruption d’une tâche dans un thread dédié. .............................................. 149
Listing 7.10 : Annulation d’une tâche avec Future. .......................................................... 151
Listing 7.11 : Encapsulation des annulations non standard dans un thread
par redéfinition de interrupt(). ................................................................ 152
Listing 7.12 : Encapsulation des annulations non standard avec newTaskFor(). ............. 154
Listing 7.13 : Service de journalisation producteur-consommateur sans support de l’arrêt. 156
Listing 7.14 : Moyen non fiable d’ajouter l’arrêt au service de journalisation. ................. 157
Listing 7.15 : Ajout d’une annulation fiable à LogWriter. ................................................ 157
Listing 7.16 : Service de journalisation utilisant un ExecutorService. ........................... 158
Listing 7.17 : Arrêt d’un service avec une pilule empoisonnée. ......................................... 159
XVI Table des listings
Listing 7.18 : Thread producteur pour IndexingService. ................................................. 159
Listing 7.19 : Thread consommateur pour IndexingService. ......................................... 160
Listing 7.20 : Utilisation d’un Executor privé dont la durée de vie est limitée à un appel
de méthode. .................................................................................................. 160
Listing 7.21 : ExecutorService mémorisant les tâches annulées après l’arrêt. ............... 161
Listing 7.22 : Utilisation de TrackingExecutorService pour mémoriser les tâches
non terminées afin de les relancer plus tard. ................................................ 162
Listing 7.23 : Structure typique d’un thread d’un pool de threads. .................................... 164
Listing 7.24 : Interface UncaughtExceptionHandler. ...................................................... 165
Listing 7.25 : UncaughtExceptionHandler, qui inscrit l’exception dans le journal. ....... 165
Listing 7.26 : Enregistrement d’un hook d’arrêt pour arrêter le service de journalisation. 167
Listing 8.1 : Interblocage de tâches dans un Executor monothread. Ne le faites pas. ...... 173
Listing 8.2 : Constructeur général de ThreadPoolExecutor. ............................................ 176
Listing 8.3 : Création d’un pool de threads de taille fixe avec une file bornée
et la politique de saturation caller-runs. .................................................... 180
Listing 8.4 : Utilisation d’un Semaphore pour ralentir la soumission des tâches. ............. 180
Listing 8.5 : Interface ThreadFactory. .............................................................................. 181
Listing 8.6 : Fabrique de threads personnalisés. ................................................................. 182
Listing 8.7 : Classe de base pour les threads personnalisés. .............................................. 182
Listing 8.8 : Modification d’un Executor créé avec les méthodes fabriques standard. ..... 183
Listing 8.9 : Pool de threads étendu par une journalisation et une mesure du temps. ........ 184
Listing 8.10 : Transformation d’une exécution séquentielle en exécution parallèle. ......... 185
Listing 8.11 : Transformation d’une récursion terminale séquentielle en récursion parallèle. 186
Listing 8.12 : Attente des résultats calculés en parallèle. ................................................... 187
Listing 8.13 : Abstraction pour les jeux de type "taquin". .................................................. 187
Listing 8.14 : Nœud pour le framework de résolution des jeux de réflexion. .................... 187
Listing 8.15 : Résolveur séquentiel d’un puzzle. ............................................................... 188
Listing 8.16 : Version parallèle du résolveur de puzzle. ..................................................... 189
Listing 8.17 : Loquet de résultat partiel utilisé par ConcurrentPuzzleSolver. ............... 190
Listing 8.18 : Résolveur reconnaissant qu’il n’y a pas de solution. ................................... 191
Listing 9.1 : Implémentation de SwingUtilities à l’aide d’un Executor. ..................... 197
Listing 9.2 : Executor construit au-dessus de SwingUtilities. ....................................... 197
Listing 9.3 : Écouteur d’événement simple. ....................................................................... 198
Listing 9.4 : Liaison d’une tâche longue à un composant visuel. ...................................... 200
Listing 9.5 : Tâche longue avec effet visuel. ...................................................................... 200
Listing 9.6 : Annulation d’une tâche longue. ..................................................................... 201
Listing 9.7 : Classe de tâche en arrière-plan supportant l’annulation,
ainsi que la notification de terminaison et de progression. ............................ 202
Table des listings XVII
Listing 9.8 : Utilisation de BackgroundTask pour lancer une tâche longue et annulable. 203
Listing 10.1 : Interblocage simple lié à l’ordre du verrouillage. Ne le faites pas. ............. 211
Listing 10.2 : Interblocage dynamique lié à l’ordre du verrouillage. Ne le faites pas. ...... 212
Listing 10.3 : Induire un ordre de verrouillage pour éviter les interblocages. ................... 213
Listing 10.4 : Boucle provoquant un interblocage dans une situation normale. ................ 214
Listing 10.5 : Interblocage lié à l’ordre du verrouillage entre des objets coopératifs.
Ne le faites pas. ............................................................................................ 215
Listing 10.6 : Utilisation d’appels ouverts pour éviter l’interblocage entre
des objets coopératifs. .................................................................................. 217
Listing 10.7 : Portion d’une trace de thread après un interblocage. ................................... 220
Listing 11.1 : Accès séquentiel à une file d’attente. ........................................................... 231
Listing 11.2 : Synchronisation inutile. Ne le faites pas. ..................................................... 236
Listing 11.3 : Candidat à l’élision de verrou. ..................................................................... 236
Listing 11.4 : Détention d’un verrou plus longtemps que nécessaire. ................................ 239
Listing 11.5 : Réduction de la durée du verrouillage. ........................................................ 239
Listing 11.6 : Candidat au découpage du verrou. ............................................................... 241
Listing 11.7 : Modification de ServerStatus pour utiliser des verrous divisés. .............. 242
Listing 11.8 : Hachage utilisant le découpage du verrouillage. ......................................... 243
Listing 12.1 : Tampon borné utilisant la classe Semaphore. .............................................. 255
Listing 12.2 : Tests unitaires de base pour BoundedBuffer. .............................................. 256
Listing 12.3 : Test du blocage et de la réponse à une interruption. .................................... 257
Listing 12.4 : Générateur de nombre aléatoire de qualité moyenne mais suffisante
pour les tests. ............................................................................................... 260
Listing 12.5 : Programme de test producteur-consommateur pour BoundedBuffer. ........ 260
Listing 12.6 : Classes producteur et consommateur utilisées dans PutTakeTest. ............ 261
Listing 12.7 : Test des fuites de ressources. ....................................................................... 263
Listing 12.8 : Fabrique de threads pour tester ThreadPoolExecutor. .............................. 264
Listing 12.9 : Méthode de test pour vérifier l’expansion du pool de threads. .................... 265
Listing 12.10 : Utilisation de Thread.yield() pour produire plus d’entrelacements ...... 266
Listing 12.11 : Mesure du temps à l’aide d’une barrière. ................................................... 267
Listing 12.12 : Test avec mesure du temps à l’aide d’une barrière. ................................... 267
Listing 12.13 : Programme pilote pour TimedPutTakeTest. ............................................ 268
Listing 13.1 : Interface Lock. ............................................................................................. 283
Listing 13.2 : Protection de l’état d’un objet avec ReentrantLock. .................................. 284
Listing 13.3 : Utilisation de tryLock() pour éviter les interblocages dus à l’ordre
des verrouillages. ......................................................................................... 285
Listing 13.4 : Verrouillage avec temps imparti. .................................................................. 286
Listing 13.5 : Prise de verrou interruptible. ........................................................................ 287
XVIII Table des listings
Listing 13.6 : Interface ReadWriteLock. ........................................................................... 293
Listing 13.7 : Enveloppe d’un Map avec un verrou de lecture-écriture. .............................. 295
Listing 14.1 : Structure des actions bloquantes en fonction de l’état. ................................ 298
Listing 14.2 : Classe de base pour les implémentations de tampons bornés. ..................... 299
Listing 14.3 : Tampon borné qui se dérobe lorsque les préconditions ne sont pas vérifiées. 299
Listing 14.4 : Code client pour l’appel de GrumpyBoundedBuffer. .................................. 300
Listing 14.5 : Tampon borné avec blocage brutal. .............................................................. 301
Listing 14.6 : Tampon borné utilisant des files d’attente de condition. .............................. 304
Listing 14.7 : Forme canonique des méthodes dépendantes de l’état. ............................... 307
Listing 14.8 : Utilisation d’une notification conditionnelle dans BoundedBuffer.put(). 310
Listing 14.9 : Porte refermable à l’aide de wait() et notifyAll(). ................................ 310
Listing 14.10 : Interface Condition. ................................................................................ 313
Listing 14.11 : Tampon borné utilisant des variables conditions explicites. ...................... 314
Listing 14.12 : Semaphore implémenté à partir de Lock. .................................................. 315
Listing 14.13 : Formes canoniques de l’acquisition et de la libération avec AQS. ............ 318
Listing 14.14 : Loquet binaire utilisant AbstractQueuedSynchronizer. ........................ 319
Listing 14.15 : Implémentation de tryAcquire() pour un ReentrantLock non équitable. 321
Listing 14.16 : Les méthodes tryAcquireShared() et tryReleaseShared()
de Semaphore. ........................................................................................... 322
Listing 15.1 : Simulation de l’opération CAS. ................................................................... 328
Listing 15.2 : Compteur non bloquant utilisant l’instruction CAS. ................................... 329
Listing 15.3 : Préservation des invariants multivariables avec CAS. ................................. 333
Listing 15.4 : Générateur de nombres pseudo-aléatoires avec ReentrantLock. ............... 334
Listing 15.5 : Générateur de nombres pseudo-aléatoires avec AtomicInteger. ............... 334
Listing 15.6 : Pile non bloquante utilisant l’algorithme de Treiber (Treiber, 1986). ......... 337
Listing 15.7 : Insertion dans l’algorithme non bloquant de Michael-Scott
(Michael et Scott, 1996). ............................................................................. 340
Listing 15.8 : Utilisation de modificateurs atomiques de champs dans
ConcurrentLinkedQueue. .......................................................................... 342
Listing 16.1 : Programme mal synchronisé pouvant produire des résultats surprenants.
Ne le faites pas. ............................................................................................ 348
Listing 16.2 : Classe interne de FutureTask illustrant une mise à profit de la synchronisation. 351
Listing 16.3 : Initialisation paresseuse incorrecte. Ne le faites pas. ................................... 353
Listing 16.4 : Initialisation paresseuse thread-safe. ........................................................... 355
Listing 16.5 : Initialisation impatiente. .............................................................................. 356
Listing 16.6 : Idiome de la classe conteneur de l’initialisation paresseuse. ....................... 356
Listing 16.7 : Antipatron du verrouillage vérifié deux fois. Ne le faites pas. ..................... 357
Listing 16.8 : Initialisation sûre pour les objets non modifiables. ...................................... 358
Préface
À l’heure où ce livre est écrit, les machines de gamme moyenne utilisent désormais des
processeurs multicœurs. En même temps, et ce n’est pas une coïncidence, les rapports
de bogues signalent de plus en plus de problèmes liés aux threads. Dans un article
récent posté sur le site des développeurs de NetBeans, l’un des développeurs principaux
indique qu’une même classe a été corrigée plus de quatorze fois pour remédier à ce
genre de problème. Dion Almaer, ancien éditeur de TheServerSide, a récemment écrit
dans son blog (après une session de débogage harassante qui a fini par révéler un bogue
lié aux threads) que ce type de bogue est si courant dans les programmes Java que ceux-ci
ne fonctionnent souvent que "par accident".
Le développement, le test et le débogage des programmes multithreads peut se révéler
très difficile car, évidemment, les problèmes de concurrence se manifestent de façon
imprévisible. Ils apparaissent généralement au pire moment – lorsque le programme est
en production et doit gérer une lourde charge de travail.
L’une des difficultés de la programmation concurrente en Java consiste à distinguer la
concurrence offerte par la plate-forme et la façon dont les développeurs doivent appré-
hender cette concurrence dans leurs programmes. Le langage fournit des mécanismes
de bas niveau, comme la synchronisation et l’attente de conditions, qui doivent être
utilisés correctement pour implémenter des protocoles ou des politiques au niveau des
applications. Sans ces politiques, il est vraiment très facile de créer des programmes qui
se compileront et sembleront fonctionner alors qu’ils sont bogués. De nombreux ouvrages
excellents consacrés à la programmation concurrente manquent ainsi leur but en se
consacrant presque exclusivement aux mécanismes de bas niveau et aux API au lieu de
s’intéresser aux politiques et aux patrons de conception.
Java 5.0 constitue une étape majeure vers le développement d’applications concurren-
tes en Java car il fournit à la fois des composants de haut niveau et des mécanismes de
bas niveau supplémentaires facilitant la construction des applications concurrentes à la
fois pour les débutants et les experts. Les auteurs sont des membres essentiels du JCP
Expert Group1, qui a créé ces outils ; outre la description de leur comportement et de
1. N.d.T. : JCP signifie Java Community Process. Il s’agit d’un processus de développement et
d’amélioration de Java ouvert à toutes les bonnes volontés. Les propositions émises sont appelées JSR
(Java Specification Request) et leur mise en place est encadrée par un groupe d’experts (Expert Group).
XX Programmation concurrente en Java
leurs fonctionnalités, nous présenterons les cas d’utilisation qui ont motivé leur ajout
aux bibliothèques de la plate-forme.
Notre but est de fournir aux lecteurs un ensemble de règles de conception et de modèles
mentaux qui facilitent – et rendent plus agréable – le développement de classes et
d’applications concurrentes en Java.
Nous espérons que vous apprécierez Programmation concurrente en Java.
Brian Goetz
Williston, VT
Mars 2006
Préface à l’édition française
Lors de la première édition de ce livre, nous avions écrit que "les processeurs multi-
cœurs commencent à être suffisamment bon marché pour apparaître dans les systèmes
de milieu de gamme". Deux ans plus tard, nous pouvons constater que cette tendance
s’est poursuivie, voire accélérée.
Même les portables et les machines de bureau d’entrée de gamme disposent maintenant
de processeurs multicœurs, tandis que les machines de haut de gamme voient leur
nombre de cœurs grandir chaque année et que les fabricants de CPU ont clairement
indiqué qu’ils s’attendaient à ce que le nombre de cœurs progresse de façon exponen-
tielle au cours des prochaines années. Du coup, il devient difficile de trouver des systèmes
monoprocesseurs.
Cette tendance du matériel pose des problèmes non négligeables aux développeurs logi-
ciels. Il ne suffit plus d’exécuter des programmes existants sur de nouveaux processeurs
pour qu’ils aillent plus vite. La loi de Moore continue de développer plus de transistors
chaque année, mais elle nous offre désormais plus de cœurs que de cœurs plus rapides.
Si nous voulons tirer parti des avantages de la puissance des nouveaux processeurs, nos
programmes doivent être écrits pour supporter les environnements concurrents, ce qui
représente un défi à la fois en termes d’architecture, de programmation et de tests. Le
but de ce livre est de répondre à ces défis en offrant des techniques, des patrons et des
outils pour analyser les programmes concurrents et pour encapsuler la complexité des
interactions concurrentes.
Comprendre la concurrence est devenu plus que jamais nécessaire pour les développeurs
Java.
Brian Goetz
Williston, VT
Janvier 2008
Présentation de l’ouvrage
Structure de l’ouvrage
Pour éviter la confusion entre les mécanismes de bas niveau de Java et les politiques de
conception nécessaires, nous présenterons un ensemble de règles simplifié permettant
d’écrire des programmes concurrents. En lisant ces règles, les experts pourront dire :
"Hum, ce n’est pas entièrement vrai : la classe C est thread-safe bien qu’elle viole la
règle R !" Écrire des programmes corrects qui ne respectent pas nos règles est bien sûr
possible, mais à condition de connaître parfaitement les mécanismes de bas niveau du
modèle mémoire de Java, or nous voulons justement que les développeurs puissent
écrire des programmes concurrents sans avoir besoin de maîtriser tous ces détails. Nos
règles simplifiées permettent de produire des programmes concurrents corrects et faciles
à maintenir.
Nous supposons que le lecteur connaît déjà un peu les mécanismes de base de la
programmation concurrente en Java. Programmation concurrente en Java n’est pas une
introduction à la programmation concurrente – pour cela, consultez le chapitre consacré
à ce sujet dans un ouvrage qui fait autorité, comme The Java Programming Language
(Arnold et al., 2005). Ce n’est pas non plus un ouvrage de référence sur la concurrence en
général – pour cela, lisez Concurrent Programming in Java (Lea, 2000). Nous préférons
ici offrir des règles de conceptions pratiques pour aider les développeurs à créer des
classes concurrentes, sûres et efficaces. Lorsque cela sera nécessaire, nous ferons référence
aux sections appropriées de The Java Programming Language, Concurrent Programming
in Java, The Java Language Specification (Gosling et al., 2005) et Effective Java (Bloch,
2001) en utilisant les conventions [JPL n.m], [CPJ n.m], [JLS n.m] et [EJ Item n].
Après l’introduction (Chapitre 1), ce livre est découpé en quatre parties.
Les bases. La première partie (Chapitres 2 à 5) s’intéresse aux concepts fondamentaux de
la concurrence et des threads, ainsi qu’à la façon de composer des classes "thread-safe" 1
à partir des composants fournis par la bibliothèque de classes. Une "carte de référence"
résume les règles les plus importantes présentées dans cette partie.
1. N.d.T : Dans ce livre nous garderons certains termes anglais car ils n’ont pas d’équivalents reconnus
en français. C’est le cas de "thread-safe", qui est une propriété indiquant qu’un code a été conçu pour
se comporter correctement lorsqu’on y accède par plusieurs threads simultanément.
XXIV Programmation concurrente en Java
Les Chapitres 2 (Thread safety) et 3 (Partage des objets) présentent les concepts
fondamentaux de cet ouvrage. Quasiment toutes les règles liées aux problèmes de
concurrence, à la construction de classes thread-safe et à la vérification du bon fonc-
tionnement de ces classes sont introduites dans ces deux chapitres. Si vous préférez
la "pratique" à la "théorie", vous pourriez être tenté de passer directement à la
deuxième partie du livre, mais assurez-vous de lire ces chapitres avant d’écrire du
code concurrent !
Le Chapitre 4 (Composition d’objets) explique comment composer des classes
thread-safe pour former des classes thread-safe plus importantes. Le Chapitre 5
(Briques de base) décrit les briques de base de la programmation concurrente – les
collections et les synchronisateurs thread-safe – fournies par les bibliothèques de la
plate-forme.
Structuration des applications parallèles. La deuxième partie (Chapitres 6 à 9) explique
comment utiliser les threads pour améliorer le rendement ou le temps de réponse des
applications concurrentes. Le Chapitre 6 (Exécution des tâches) montre comment iden-
tifier les tâches parallélisables et les exécuter. Le Chapitre 7 (Annulation et arrêt) expli-
que comment demander aux tâches et aux threads de se terminer avant leur échéance
normale ; la façon dont les programmes gèrent l’annulation et la terminaison est souvent
l’un des facteurs permettant de différencier les applications concurrentes vraiment
robustes de celles qui se contentent de fonctionner. Le Chapitre 8 (Pools de threads)
présente quelques-unes des techniques les plus avancées de l’exécution des tâches. Le
Chapitre 9 (Applications graphiques) s’intéresse aux techniques permettant d’améliorer
le temps de réponse des sous-systèmes monothreads.
Vivacité, performances et tests. La troisième partie (Chapitres 10 à 12) s’occupe
de vérifier que les programmes concurrents font bien ce que l’on veut qu’ils fassent,
tout en ayant des performances acceptables. Le Chapitre 10 (Éviter les problèmes de
vivacité) explique comment éviter les problèmes de vivacité qui peuvent empêcher
les programmes d’avancer. Le Chapitre 11 (Performances et adaptabilité) présente
les techniques permettant d’améliorer les performances et l’adaptabilité du code
concurrent. Le Chapitre 12 (Tests des programmes concurrents) décrit les techni-
ques de test du code concurrent, qui permettent de vérifier qu’il est à la fois correct
et performant.
Sujets avancés. La quatrième et dernière partie (Chapitres 13 à 16) présente des sujets
qui n’intéresseront probablement que les développeurs expérimentés : les verrous
explicites, les variables atomiques, les algorithmes non bloquants et le développement
de synchronisateurs personnalisés.
Présentation de l’ouvrage XXV
Exemples de code
Bien que de nombreux concepts généraux exposés dans ce livre s’appliquent aux
versions de Java antérieures à Java 5.0, voire aux environnements non Java, la plupart
des exemples de code (et tout ce qui concerne le modèle mémoire de Java) supposent
que vous utilisiez Java 5.0 ou une version plus récente. En outre, certains exemples
utilisent des fonctionnalités qui ne sont apparues qu’à partir de Java 6.
Les exemples de code ont été résumés afin de réduire leur taille et de mettre en évidence les
parties importantes. Leurs versions complètes, ainsi que d’autres exemples, sont disponi-
bles sur le site web www.pearson.fr, à la page consacrée à ce livre.
Ces exemples sont classés en trois catégories : les "bons", les "moyens" et les "mauvais".
Les bons exemples illustrent les techniques conseillées. Les mauvais sont les exemples
qu’il ne faut surtout pas suivre et sont signalés par l’icône "Mr. Yuk" (cette icône est une
marque déposée de l’hôpital des enfants de Pittsburgh, qui nous a autorisés à l’utiliser),
qui indique qu’il s’agit d’un code "toxique", comme dans le Listing 1. Les exemples
"moyens" illustrent des techniques qui ne sont pas nécessairement mauvaises mais qui
sont fragiles ou peu efficaces ; ils sont signalés par l’icône "Peut mieux faire", comme
dans le Listing 2.
Listing 1 : Mauvaise façon de trier une liste. Ne le faites pas.
public <T extends Comparable<? super T>> void sort(List<T> list) {
// Ne renvoie jamais la mauvaise réponse !
System.exit(0);
}
Vous pourriez vous interroger sur l’intérêt de donner de "mauvais" exemples car,
après tout, un livre ne devrait expliquer que les bonnes méthodes, pas les mauvaises.
Cependant, ces exemples ont deux buts : ils illustrent les pièges classiques et, ce qui
est le plus important, ils montrent comment faire pour vérifier qu’un programme est
thread-safe – et la meilleure méthode consiste à présenter les situations où ce n’est
pas le cas.
Listing 2 : Méthode peu optimale de trier une liste.
public <T extends Comparable<? super T>> void sort(List<T> list) {
for (int i=0; i<1000000; i++)
neRienFaire();
Collections.sort(list);
}
XXVI Programmation concurrente en Java
Remerciements
Ce livre est issu du développement du paquetage java.util.concurrent, qui a été créé
par le JSR 166 pour être inclus dans Java 5.0. De nombreuses personnes ont contribué à
ce JSR ; nous remercions tout particulièrement Martin Buchholz pour le travail qu’il a
effectué afin d’intégrer le code au JDK, ainsi que tous les lecteurs de la liste de diffusion
concurrency-interest, qui ont émis des suggestions sur la proposition initiale des API.
Cet ouvrage a été considérablement amélioré par les suggestions et l’aide d’une petite
armée de relecteurs, de conseillers, de majorettes et de critiques en fauteuil. Nous
voudrions remercier Dion Almaer, Tracy Bialik, Cindy Bloch, Martin Buchholz, Paul
Christmann, Cliff Click, Stuart Halloway, David Hovemeyer, Jason Hunter, Michael
Hunter, Jeremy Hylton, Heinz Kabutz, Robert Kuhar, Ramnivas Laddad, Jared Levy,
Nicole Lewis, Victor Luchangco, Jeremy Manson, Paul Martin, Berna Massingill, Michael
Maurer, Ted Neward, Kirk Pepperdine, Bill Pugh, Sam Pullara, Russ Rufer, Bill Scherer,
Jeffrey Siegal, Bruce Tate, Gil Tene, Paul Tyma et les membres du Silicon Valley Patterns
Group, qui, par leurs nombreuses conversations techniques intéressantes, ont contribué
à améliorer ce livre.
Nous remercions tout spécialement Cliff Biffie, Barry Hayes, Dawid Kurzyniec, Angelika
Langer, Doron Rajwan et Bill Venners, qui ont relu l’ensemble du manuscrit en détail,
trouvé des bogues dans les exemples de code et suggéré de nombreuses améliorations.
Merci à Katrina Avery pour son travail d’édition et à Rosemary Simpson, qui a produit
l’index alors que les délais impartis n’étaient pas raisonnables. Merci également à Ami
Dewar pour ses illustrations.
Nous voulons aussi remercier toute l’équipe d’Addison-Wesley, qui nous a aidés à faire
de ce livre une réalité. Ann Sellers a lancé le projet et Greg Doench l’a mené jusqu’à
son terme ; Elizabeth Ryan l’a guidé à travers tout le processus de production.
Merci également aux milliers de développeurs qui ont contribué indirectement à l’exis-
tence des logiciels utilisés pour créer ce livre : TeX, LaTeX, Adobe Acrobat, pic, grap,
Adobe Illustrator, Perl, Apache Ant, IntelliJIDEA, GNU emacs, Subversion, TortoiseSVN
et, bien sûr, la plate-forme Java et les bibliothèques de classes.
1
Introduction
Si l’écriture de programmes corrects est un exercice difficile, l’écriture de programmes
concurrents corrects l’est encore plus. En effet, par rapport à un programme séquentiel,
beaucoup plus de choses peuvent mal tourner dans un programme concurrent. Pourquoi
nous intéressons-nous alors à la concurrence des programmes ? Les threads sont une
fonctionnalité incontournable du langage Java et permettent de simplifier le dévelop-
pement de systèmes complexes en transformant du code asynchrone compliqué en un code
plus court et plus simple. En outre, les threads sont le moyen le plus direct d’exploiter
la puissance des systèmes multiprocesseurs. À mesure que le nombre de processeurs
augmentera, l’exploitation de la concurrence prendra de plus en plus d’importance.
1.1 Bref historique de la programmation concurrente
Aux premiers temps de l’informatique, les ordinateurs n’avaient pas de système d’exploi-
tation ; ils exécutaient du début à la fin un unique programme qui avait directement
accès à toutes les ressources de la machine. Non seulement l’écriture de ces programmes
était difficile mais l’exécution d’un seul programme à la fois était un gâchis en termes
de ressources, qui étaient, à l’époque, rares et chères.
Les systèmes d’exploitation ont ensuite évolué pour permettre à plusieurs programmes
de s’exécuter en même temps, dans des processus différents. Un processus peut être
considéré comme une exécution indépendante d’un programme à laquelle le système
d’exploitation alloue des ressources comme de la mémoire, des descripteurs de fichiers
et des droits d’accès. Au besoin, les processus peuvent communiquer les uns avec les
autres via plusieurs méthodes de communication assez grossières : sockets, signaux,
mémoire partagée, sémaphores et fichiers. Plusieurs facteurs déterminants ont conduit
au développement des systèmes d’exploitation, permettant à plusieurs programmes de
s’exécuter simultanément :
2 Programmation concurrente en Java
m Utilisation des ressources. Les programmes doivent parfois attendre des événements
externes, comme une entrée ou une sortie de données. Un programme qui attend ne
faisant rien d’utile, il est plus efficace d’utiliser ce temps pour permettre à un autre
programme de s’exécuter.
m Équité. Les différents utilisateurs et programmes pouvant prétendre aux mêmes droits
sur les ressources de la machine, il est préférable de leur permettre de partager cet
ordinateur en tranches de temps suffisamment fines, plutôt que laisser un seul
programme s’exécuter jusqu’à son terme avant d’en lancer un autre.
m Commodité. Il est souvent plus simple et plus judicieux d’écrire plusieurs programmes
effectuant chacun une tâche unique et de les combiner ensemble en fonction des
besoins, plutôt qu’écrire un seul programme qui réalisera toutes les tâches.
Dans les premiers systèmes à temps partagé, chaque processus était une machine Von
Neumann virtuelle : il possédait un espace mémoire pour y stocker à la fois ses instructions
et ses données, il exécutait séquentiellement les instructions en fonction de la sémantique
du langage machine et il interagissait avec le monde extérieur via le système d’exploi-
tation au moyen d’un ensemble de primitives d’entrées/sorties. Pour chaque instruction
exécutée, il existait une "instruction suivante" clairement définie et le programme se
déroulait selon les règles du jeu d’instructions. Quasiment tous les langages de program-
mation actuels suivent encore ce modèle séquentiel, dans lequel la spécification du langage
définit clairement "ce qui vient après" l’exécution d’une certaine action.
Ce modèle de programmation séquentiel est intuitif et naturel car il modélise l’activité
humaine : on effectue une tâche à la fois, l’une à la suite de l’autre – le plus souvent. On
se réveille le matin, on enfile une robe de chambre, on descend les escaliers et on
prépare le café. Comme dans les langages de programmation, chacune de ces actions du
monde réel est une abstraction d’une suite d’actions plus détaillées – on prend un filtre,
on dose le café, on vérifie qu’il y a suffisamment d’eau dans la cafetière, s’il n’y en a
pas, on la remplit, on allume la cafetière, on attend que l’eau chauffe, etc. La dernière
étape – attendre que l’eau chauffe – implique également un événement asynchrone.
Pendant que l’eau chauffe, on a le choix – attendre devant la cafetière ou effectuer une
autre tâche comme faire griller du pain (une autre tâche asynchrone) ou lire le journal
tout en sachant qu’il faudra bientôt s’occuper de la cafetière. Les fabricants de cafetières
et de grille-pain, sachant que leurs produits sont souvent utilisés de façon asynchrone,
ont fait en sorte que ces appareils émettent un signal lorsqu’ils ont effectué leur tâche.
Trouver le bon équilibre entre séquentialité et asynchronisme est souvent la marque des
personnes efficaces – c’est la même chose pour les programmes.
Les raisons (utilisation des ressources, équité et commodité) qui ont motivé le dévelop-
pement des processus ont également motivé celui des threads. Les threads permettent à
plusieurs flux du déroulement d’un programme de coexister dans le même processus.
Bien qu’ils partagent les ressources globales de ce processus, comme la mémoire et les
Chapitre 1 Introduction 3
descripteurs de fichiers, chaque thread possède son propre compteur de programme, sa
propre pile et ses propres variables locales. Les threads offrent également une décomposi-
tion naturelle pour exploiter le parallélisme matériel sur les systèmes multiprocesseurs
car les différents threads d’un même programme peuvent s’exécuter simultanément sur
des processeurs différents.
Les threads sont parfois appelés processus légers et les systèmes d’exploitation modernes
considèrent les threads, et non les processus, comme unités de base pour l’accès au
processeur. En l’absence de coordination explicite, les threads s’exécutent en même temps
et de façon asynchrone les uns par rapport aux autres. Comme ils partagent le même
espace d’adressage, tous les threads d’un processus ont accès aux mêmes variables et
allouent des objets sur le même tas : cela leur permet de partager les données de façon
plus subtile qu’avec les mécanismes de communication interprocessus. Cependant, en
l’absence d’une synchronisation explicite pour arbitrer l’accès à ces données partagées,
un thread peut modifier des variables qu’un autre thread est justement en train d’utiliser,
ce qui aura un effet imprévisible.
1.2 Avantages des threads
Utilisés correctement, les threads permettent de réduire les coûts de développement et
de maintenance tout en améliorant les performances des applications complexes. Ils
facilitent la modélisation du fonctionnement et des échanges humains en transformant
les tâches asynchrones en opérations qui seront généralement séquentielles. Grâce à
eux, un code compliqué peut devenir un code clair, facile à écrire, à relire et à maintenir.
Dans les applications graphiques, les threads permettent d’améliorer la réactivité de
l’interface utilisateur, tandis que, dans les applications serveur, ils optimisent l’utilisation
des ressources et la rapidité des réponses. Ils simplifient également l’implémentation de
la machine virtuelle Java (JVM) – le ramasse-miettes s’exécute généralement dans un
ou plusieurs threads qui lui sont dédiés. La plupart des applications Java un tant soit peu
complexes utilisent des threads.
1.2.1 Exploitation de plusieurs processeurs
Auparavant, les systèmes multiprocesseurs étaient rares et chers et n’étaient réservés
qu’aux gros centres de calculs et aux traitements scientifiques. Aujourd’hui, leur prix a
considérablement baissé et on en trouve partout, même sur les serveurs bas de gamme
et les stations de travail ordinaires. Cette tendance ne pourra que s’accélérer : comme il
devient difficile d’augmenter les fréquences d’horloge, les fabricants préfèrent placer
plus de processeurs sur le même circuit. Tous les constructeurs de microprocesseurs se
sont lancés dans cette voie et nous commençons à voir apparaître des machines dotées
d’un nombre sans cesse croissant de processeurs.
4 Programmation concurrente en Java
Le thread étant l’unité d’allocation du processeur, un programme monothread ne peut
s’exécuter que sur un seul processeur à la fois. Sur un système à deux processeurs, un
tel programme perd donc la moitié des ressources CPU disponibles ; sur un système
à cent processeurs, il en perdrait 99 %. Les programmes multithreads, en revanche,
peuvent s’exécuter en parallèle sur plusieurs processeurs. S’ils sont correctement
conçus, ces programmes peuvent donc améliorer leurs performances en utilisant plus
efficacement les ressources disponibles.
L’utilisation de plusieurs threads permet également d’améliorer le rendement d’un
programme, même sur un système monoprocesseur. Avec un programme monothread, le
processeur restera inactif pendant les opérations d’E/S synchrones ; avec un programme
multithread, en revanche, un autre thread peut s’exécuter pendant que le premier attend
la fin de l’opération d’E/S, permettant ainsi à l’application de continuer à progresser
pendant le blocage dû aux E/S (c’est comme lire le journal en attendant que l’eau du
café chauffe, au lieu d’attendre que cette eau soit chaude pour lire le journal).
1.2.2 Simplicité de la modélisation
Il est souvent plus simple de gérer son temps lorsque l’on n’a qu’un seul type de tâche
à effectuer (corriger une dizaine de bogues, par exemple) que quand on en a plusieurs
(corriger les bogues, interroger les candidats au poste d’administrateur système, finir le
rapport d’évaluation de notre équipe et créer les transparents pour la présentation de la
semaine prochaine). Lorsque l’on ne doit réaliser qu’un seul type de tâche, on peut
commencer par le sommet de la pile et travailler jusqu’à ce que la pile soit vide ; il n’est
pas nécessaire de réfléchir à ce qu’il faudra faire ensuite. En revanche, la gestion de
priorités et de dates d’échéance différentes et le basculement d’une tâche vers une autre
exigent généralement un travail supplémentaire.
Il en va de même pour les logiciels : un programme n’effectuant séquentiellement
qu’un seul type de tâche est plus simple à écrire, moins sujet aux erreurs et plus facile à
tester qu’un autre qui gère en même temps différents types de traitements. En affectant
un thread à chaque type de tâche ou à chaque élément d’une simulation, on obtient
l’illusion de la séquentialité et on isole les traitements des détails de la planification, des
opérations entrelacées, des E/S asynchrones et des attentes de ressources. Un flux d’exécu-
tion compliqué peut alors être décomposé en un certain nombre de flux synchrones plus
simples, s’exécutant chacun dans un thread distinct et n’interagissant avec les autres
qu’en certains points de synchronisation précis.
Cet avantage est souvent exploité par des frameworks comme les servlets ou RMI
(Remote Method Invocation). Ceux-ci gèrent la gestion des requêtes, la création des
threads et l’équilibre de la charge en répartissant les parties du traitement des requêtes
vers le composant approprié à un point adéquat du flux. Les programmeurs qui écrivent
les servlets n’ont pas besoin de s’occuper du nombre de requêtes qui seront traitées
simultanément et n’ont pas à savoir si les flux d’entrée ou de sortie seront bloquants :
Chapitre 1 Introduction 5
lorsqu’une méthode d’une servlet est appelée pour répondre à une requête web, elle peut
traiter cette requête de façon synchrone, comme si elle était un programme monothread.
Cela permet de simplifier le développement des composants et de réduire le temps
d’apprentissage de ces frameworks.
1.2.3 Gestion simplifiée des événements asynchrones
Une application serveur qui accepte des connexions de plusieurs clients distants peut
être plus simple à développer lorsque chaque connexion est gérée par un thread qui lui
est dédié et qu’elle peut utiliser des E/S synchrones.
Lorsqu’une application lit une socket qui ne contient aucune donnée, la lecture se bloque
jusqu’à ce que des données arrivent. Dans une application monothread, cela signifie que
non seulement le traitement de la requête correspondante se fige, mais que celui de
toutes les autres est également bloqué. Pour éviter ce problème, les applications serveur
monothreads sont obligées d’utiliser des opérations d’E/S non bloquantes, ce qui est bien
plus compliqué et plus sujet aux erreurs que les opérations d’E/S synchrones. Si chaque
requête possède son propre thread, en revanche, le blocage n’affecte pas le traitement
des autres requêtes.
Historiquement, les systèmes d’exploitation imposaient des limites assez basses au nombre
de threads qu’un processus pouvait créer : de l’ordre de quelques centaines (voire moins).
Pour compenser cette faiblesse, ces systèmes ont donc mis au point des outils efficaces pour
gérer des E/S multiplexées – les appels select et poll d’Unix, par exemple. Pour accéder
à ces outils, les bibliothèques de classes Java se sont vues dotées d’un ensemble de
paquetages (java.nio) leur permettant de gérer les E/S non bloquantes. Cependant,
certains systèmes d’exploitation acceptent désormais un nombre bien plus grand de
threads, ce qui rend le modèle "un thread par client" utilisable, même pour un grand
nombre de clients1.
1.2.4 Interfaces utilisateur plus réactives
Auparavant, les applications graphiques étaient monothreads, ce qui impliquait soit
d’interroger fréquemment le code qui gérait les événements d’entrée (ce qui est pénible
et indiscret), soit d’exécuter indirectement tout le code de l’application dans une "boucle
principale de gestion des événements". Si le code appelé à partir de cette boucle met
trop de temps à se terminer, l’interface utilisateur semble "se figer" jusqu’à ce que le
code ait fini de s’exécuter, car les autres événements de l’interface ne peuvent pas être
traités tant que le contrôle n’est pas revenu dans la boucle principale. Les frameworks
1. Le paquetage des threads NPTL, qui est maintenant intégré à la plupart des distributions Linux, a été
conçu pour gérer des centaines de milliers de threads. Les E/S non bloquantes ont leurs avantages, mais
une meilleure gestion des threads par le système signifie que les situations où elles seront nécessaires
deviennent plus rares.
6 Programmation concurrente en Java
graphiques modernes, comme les toolkits AWT ou Swing, remplacent cette boucle par un
thread de répartition des événements (EDT, event dispatch thread). Lorsqu’un événement
utilisateur comme l’appui d’un bouton survient, un thread des événements appelle les
gestionnaires d’événements définis par l’application. La plupart des frameworks graphiques
étant des sous-systèmes monothreads, la boucle des événements est toujours présente,
mais elle s’exécute dans son propre thread, sous le contrôle du toolkit, plutôt que dans
l’application.
Si le thread des événements n’exécute que des tâches courtes, l’interface reste réactive
puisque ce thread est toujours en mesure de traiter assez rapidement les actions de l’utili-
sateur. En revanche, s’il contient une tâche qui dure un certain temps, une vérification
orthographique d’un document ou la récupération d’une ressource sur Internet, par
exemple, la réactivité de l’interface s’en ressentira : si l’utilisateur effectue une action
pendant que cette tâche s’exécute, il se passera un temps assez long avant que le thread
des événements puisse la traiter, voire simplement en accuser réception. Pour corser le
tout, non seulement l’interface ne répondra plus mais il sera impossible d’annuler la tâche
qui pose problème, même s’il y a un bouton "Annuler", puisque le thread des événements
est occupé et ne pourra pas traiter l’événement associé à ce bouton tant qu’il n’a pas
terminé la tâche interminable ! Si, en revanche, ce long traitement s’exécute dans un
thread séparé, le thread des événements reste disponible pour traiter les actions de
l’utilisateur, ce qui rend l’interface plus réactive.
1.3 Risques des threads
Le support des threads intégré à Java est une épée à double tranchant. Bien qu’il simplifie
le développement des applications concurrentes en fournissant tout ce qu’il faut au
niveau du langage et des bibliothèques, ainsi qu’un modèle mémoire formel et portable
(c’est ce modèle formel qui rend possible le développement des applications concurrentes
"write-once, run-anywhere" en Java), il place également la barre un peu plus haut pour
les développeurs en les incitant à utiliser des threads. Lorsque les threads étaient plus
ésotériques, la programmation concurrente était un sujet "avancé" ; désormais, tout bon
développeur doit connaître les problèmes liés aux threads.
1.3.1 Risques concernant la "thread safety"
Ce type de problème peut être étonnamment subtil car, en l’absence d’une synchronisation
adaptée, l’ordre des opérations entre les différents threads est imprévisible et parfois
surprenant. La classe UnsafeSequence du Listing 1.1, censée produire une suite de
valeurs entières uniques, est une illustration simple de l’effet inattendu de l’entrelacement
des actions entre différents threads. Elle se comporte correctement dans un environnement
monothread.
Chapitre 1 Introduction 7
Listing 1.1 : Générateur de séquence non thread-safe.
@NotThreadSafe
public class UnsafeSequence {
private int value;
/** Renvoie une valeur unique. */
public int getNext() {
return value++;
}
}
Figure 1.1
Exécution A value 9 9+1 10 value = 10
malheureuse de
UnsafeSequence B value 9 9+1 10 value = 10
.getNext().
Le problème de UnsafeSequence est qu’avec un peu de malchance deux threads pour-
raient appeler getNext() et recevoir la même valeur. La Figure 1.1 montre comment
cette situation peut arriver. Bien que la notation value++ puisse sembler désigner une
seule opération, elle en représente en réalité trois : lecture de la variable value, incré-
mentation de sa valeur et stockage de cette nouvelle valeur dans value. Les opérations
des différents threads pouvant s’entrelacer de façon arbitraire lors de l’exécution, deux
threads peuvent lire cette variable en même temps, récupérer la même valeur et l’incré-
menter tous les deux. Le résultat est que le même nombre sera donc renvoyé par des
appels différents dans des threads distincts.1
Les diagrammes comme celui de la Figure 1.1 décrivent les entrelacements possibles des
exécutions de threads différents. Dans ces diagrammes, le temps s’écoule de la gauche
vers la droite et chaque ligne représente l’activité d’un thread particulier. Ces diagram-
mes d’entrelacement décrivent généralement le pire des cas possibles 1 et sont conçus
pour montrer le danger qu’il y a de supposer que les choses se passeront dans un ordre
particulier.
UnsafeSequence utilise l’annotation non standard @NotThreadSafe, que nous utiliserons
dans ce livre pour documenter les propriétés de concurrence des classes et de leurs membres
(nous utiliserons également @ThreadSafe et @Immutable, décrites dans l’annexe A). Ces
annotations sont utiles à tous ceux qui manipuleront la classe : les utilisateurs d’une
classe annotée par @ThreadSafe, par exemple, sauront qu’ils peuvent l’utiliser en toute
sécurité dans un environnement multithread, les développeurs sauront qu’ils doivent
préserver cette propriété, et les outils d’analyse pourront identifier les éventuelles
erreurs de codage.
1. En fait, comme nous le verrons au Chapitre 3, le pire des cas peut être encore pire que celui présenté
dans ces diagrammes, à cause d’un réarrangement possible des opérations.
8 Programmation concurrente en Java
UnsafeSequence illustre un danger classique de la concurrence, appelé situation de
compétition (race condition). Ici, le fait que getNext() renvoie ou non une valeur
unique lorsqu’elle est appelée à partir de threads différents dépend de l’entrelacement
des opérations lors de l’exécution – ce qui n’est pas souhaitable.
Les threads partageant le même espace mémoire et s’exécutant simultanément peuvent
accéder ou modifier des variables que d’autres threads utilisent peut-être aussi. C’est
très pratique car cela facilite beaucoup le partage des données par rapport à d’autres
mécanismes de communication interthread, mais cela présente également un risque non
négligeable : les threads peuvent être perturbés par des données modifiées de façon
inattendue. Permettre à plusieurs threads d’accéder aux mêmes variables et de les modi-
fier introduit un élément de non-séquentialité dans un modèle de programmation qui est
pourtant séquentiel, ce qui peut être troublant et difficile à appréhender. Pour que le
comportement d’un programme multithread soit prévisible, l’accès aux variables partagées
doit donc être correctement arbitré afin que les threads n’interfèrent pas les uns avec les
autres. Heureusement, Java fournit des mécanismes de synchronisation permettant de
coordonner ces accès.
Afin d’éviter l’interaction malheureuse de la Figure 1.1, nous pouvons corriger Unsafe
Sequence en synchronisant getNext(), comme le montre le Listing 1.21. Le fonction-
nement de ce mécanisme sera décrit en détail aux Chapitres 2 et 3.
Listing 1.2 : Générateur de séquence thread-safe.
@ThreadSafe
public class Sequence {
@GuardedBy("this") private int Value;
public synchronized int getNext() {
return nextValue++;
}
}
En l’absence de synchronisation, le compilateur, le matériel et l’exécution peuvent prendre
quelques libertés concernant le timing et l’ordonnancement des actions, comme mettre
les variables en cache dans des registres ou des caches locaux du processeur où elles
seront temporairement (voire définitivement) invisibles aux autres threads. Bien que ces
astuces permettent d’obtenir de meilleures performances et soient généralement souhai-
tables, elles obligent le développeur à savoir précisément où se trouvent les données
qui sont partagées entre les threads, afin que ces optimisations ne détériorent pas le
comportement (le Chapitre 16 présentera les détails croustillants sur l’ordonnancement
garanti par la JVM et sur la façon dont la synchronisation influe sur ces garanties mais,
si vous suivez les règles des Chapitres 2 et 3, vous pouvez vous passer de ces détails de
bas niveau).
1. @GuardedBy est décrite dans la section 2.4 ; elle documente la politique de synchronisation pour
Sequence.
Chapitre 1 Introduction 9
1.3.2 Risques sur la vivacité
Il est essentiel de veiller à ce que le code soit thread-safe lorsque l’on développe du code
concurrent. Cette "thread safety" est incontournable et n’est pas réservée aux programmes
multithreads – les programmes monothreads doivent également s’en préoccuper – mais
l’utilisation des threads introduit des risques supplémentaires qui n’existent pas dans
les programmes monothreads. De même, l’utilisation de plusieurs threads introduit des
risques supplémentaires sur la vivacité qui n’existent pas lorsqu’il n’y a qu’un seul thread.
Alors que "safety" signifie "rien de mauvais ne peut se produire", la vivacité représente
le but complémentaire, "quelque chose de bon finira par arriver". Une panne de vivacité
survient lorsqu’une activité se trouve dans un état tel qu’elle ne peut plus progresser.
Une des formes de cette panne pouvant intervenir dans les programmes séquentiels est
la fameuse boucle sans fin, où le code qui suit la boucle ne sera jamais exécuté. L’utili-
sation des threads introduit de nouveaux risques pour cette vivacité : si le thread A, par
exemple, attend une ressource détenue de façon exclusive par le thread B et que B ne la
libère jamais, A sera bloqué pour toujours. Le Chapitre 10 décrit les différentes formes
de pannes de vivacité et explique comment les éviter. Parmi ces pannes, citons les inter-
blocages (dreadlocks) (section 10.1), la famine (section 10.3.1) et les livelocks. Comme
la plupart des bogues de concurrence, ceux qui provoquent des pannes de vivacité
peuvent être difficiles à repérer car ils dépendent du timing relatif des événements dans
les différents threads et ne se manifestent donc pas toujours pendant les phases de
développement et de tests.
1.3.3 Risques sur les performances
Les performances sont liées à la vivacité. Alors que cette dernière signifie que quelque
chose finira par arriver, ce "finira par" peut ne pas suffire – on souhaite souvent que les
bonnes choses arrivent vite. Les problèmes de performance incluent un vaste domaine
de problèmes, dont les mauvais temps de réponse, une réactivité qui laisse à désirer, une
consommation excessive des ressources ou une mauvaise adaptabilité. Comme avec la
"safety" et la vivacité, les programmes multithreads sont sujets à tous les problèmes de
performance des programmes monothreads, mais ils souffrent également de ceux qui
sont introduits par l’utilisation des threads.
Pour les applications concurrentes bien conçues, l’utilisation des threads apporte un net
gain de performance, mais les threads ont également un certain coût en terme d’exécution.
Les changements de contexte – lorsque l’ordonnanceur suspend temporairement le thread
actif pour qu’un autre thread puisse s’exécuter – sont plus fréquents dans les applica-
tions utilisant de nombreux threads et ont des coûts non négligeables : sauvegarde et
restauration du contexte d’exécution, perte de localité et temps CPU passé à ordonnancer
les threads plutôt qu’à les exécuter. En outre, lorsque des threads partagent des données, ils
doivent utiliser des mécanismes de synchronisation qui peuvent empêcher le compilateur
d’effectuer des optimisations, il faut qu’ils vident ou invalident les caches mémoire et
10 Programmation concurrente en Java
qu’ils créent un trafic synchronisé sur le bus mémoire partagé. Tous ces aspects se
payent en termes de performance ; le Chapitre 11 présentera les techniques permettant
d’analyser et de réduire ces coûts.
1.4 Les threads sont partout
Même si votre programme ne crée jamais explicitement de thread, les frameworks
peuvent en créer pour vous et le code appelé à partir de ces threads doit être thread-safe.
Cet aspect peut représenter une charge non négligeable pour les développeurs lorsqu’ils
conçoivent et implémentent leurs applications car développer des classes thread-safe
nécessite plus d’attention et d’analyse que développer des classes qui ne le sont pas.
Toutes les applications Java utilisent des threads. Lorsque la JVM se lance, elle crée des
threads pour ses tâches de nettoyage (ramasse-miettes, finalisation) et un thread principal
pour exécuter la méthode main(). Les frameworks graphiques AWT (Abstract Window
Toolkit) et Swing créent des threads pour gérer les événements de l’interface utilisateur ;
Timer crée des threads pour exécuter les tâches différées ; les frameworks composants,
comme les servlets et RMI, créent des pools de threads et invoquent les méthodes
composant dans ces threads.
Si vous utilisez ces outils – comme le font de nombreux développeurs –, vous devez
prendre l’habitude de la concurrence et de la thread safety car ces frameworks créent
des threads à partir desquels ils appellent vos composants. Il serait agréable de penser que
la concurrence est une fonctionnalité "facultative" ou "avancée" du langage mais, en
réalité, quasiment toutes les applications Java sont multithreads et ces frameworks ne vous
dispensent pas de la nécessité de coordonner correctement l’accès à l’état de l’application.
Lorsqu’un framework ajoute de la concurrence dans une application, il est générale-
ment impossible de restreindre la concurrence du code du framework car, de par leur
nature, les frameworks créent des fonctions de rappel vers les composants de l’applica-
tion, qui, à leur tour, accèdent à l’état de l’application. De même, le besoin d’un code
thread-safe ne se cantonne pas aux composants appelés par le framework – il s’étend à
tout le code qui accède à l’état du programme. Ce besoin est donc contagieux.
Les frameworks introduisent la concurrence dans les applications en appelant les compo-
sants des applications à partir de leurs threads. Les composants accèdent invariablement
à l’état de l’application, ce qui nécessite donc que tous les chemins du code accédant à
cet état soient thread-safe.
Dans tous les outils que nous décrivons ci-après, le code de l’application sera appelé à
partir de threads qui ne sont pas gérés par l’application. Bien que le besoin de thread
safety puisse commencer avec ces outils, il se termine rarement là ; il a plutôt tendance
à se propager dans l’application.
Chapitre 1 Introduction 11
Timer. Timer est un mécanisme permettant de planifier l’exécution de tâches à une date
future, soit une seule fois, soit périodiquement. L’introduction d’un Timer peut compli-
quer un programme séquentiel car les TimerTask s’exécutent dans un thread géré par le
Timer, pas par l’application. Si un TimerTask accède à des données également utilisées
par d’autres threads de l’application, non seulement le TimerTask doit le faire de façon
thread-safe, mais toutes les autres classes qui accèdent à ces données doivent faire de
même. Souvent, le moyen le plus simple consiste à s’assurer que les objets auxquels
accède un TimerTask sont eux-mêmes thread-safe, ce qui permet d’encapsuler cette
thread safety dans les objets partagés.
Servlets et JavaServer Pages (JSPs). Le framework des servlets a été conçu pour
prendre en charge toute l’infrastructure de déploiement d’une application web et pour
répartir les requêtes provenant de clients HTTP distants. Une requête qui arrive au
serveur est dirigée, éventuellement via une chaîne de filtres, vers la servlet ou la JSP
appropriée. Chaque servlet représente un composant de l’application ; sur les sites de
grande taille, plusieurs clients peuvent demander en même temps les services de la
même servlet. D’ailleurs, la spécification des servlets exige qu’une servlet puisse être
appelée simultanément à partir de threads différents : en d’autres termes, les servlets
doivent être thread-safe. Même si vous pouviez garantir qu’une servlet ne sera appelée
que par un seul thread à la fois, vous devriez quand même vous préoccuper de la thread
safety lorsque vous construisez une application web. En effet, les servlets accèdent
souvent à des informations partagées par d’autres servlets, par exemple les objets globaux
de l’application (ceux qui sont stockés dans le ServletContext) ou les objets de la
session (ceux qui sont stockés dans le HttpSession de chaque client). Une servlet accédant
à des objets partagés par d’autres servlets ou par les requêtes doit donc coordonner
correctement l’accès à ces objets puisque plusieurs requêtes peuvent y accéder simulta-
nément, à partir de threads distincts. Les servlets et les JSP, ainsi que les filtres des
servlets et les objets stockés dans des conteneurs comme ServletContext et HttpSession,
doivent donc être thread-safe.
Appels de méthodes distantes. RMI permet d’appeler des méthodes sur des objets qui
s’exécutent sur une autre JVM. Lorsque l’on appelle une méthode distante avec RMI,
les paramètres d’appel sont empaquetés (sérialisés) dans un flux d’octets qui est envoyé
via le réseau à la JVM distante qui les extrait (désérialise) avant de les passer à la
méthode.
Lorsque le code RMI appelle l’objet distant, on ne peut pas savoir dans quel thread cet
appel aura lieu ; il est clair que c’est non pas dans un thread que l’on a créé mais dans
un thread géré par RMI. Combien de threads crée RMI ? Est-ce que la même méthode sur
le même objet distant pourrait être appelée simultanément dans plusieurs threads RMI 1 ?
1. La réponse est oui, bien que ce ne soit pas clairement annoncé dans la documentation Javadoc. Vous
devez lire les spécifications de RMI.
12 Programmation concurrente en Java
Un objet distant doit se protéger contre deux risques liés aux threads : il doit correcte-
ment coordonner l’accès à l’état partagé avec les autres objets et l’accès à l’état de
l’objet distant lui-même (puisque le même objet peut être appelé simultanément dans
plusieurs threads). Comme les servlets, les objets RMI doivent donc être prévus pour
être appelés simultanément et doivent donc être thread-safe.
Swing et AWT. Par essence, les applications graphiques sont asynchrones car les utili-
sateurs peuvent sélectionner un élément de menu ou presser un bouton à n’importe quel
moment et s’attendre à ce que l’application réponde rapidement, même si elle est au
beau milieu d’un traitement. Pour gérer ce problème, Swing et AWT créent un thread
distinct pour prendre en charge les événements produits par l’utilisateur et mettre à jour
la vue qui lui sera présentée.
Les composants Swing, comme JTable, ne sont pas thread-safe, mais Swing les protège
en confinant dans le thread des événements tous les accès à ces composants. Si une
application veut manipuler l’interface graphique depuis l’extérieur de ce thread, elle
doit faire en sorte que son code s’exécute dans ce thread.
Lorsque l’utilisateur interagit avec l’interface, un gestionnaire d’événement est appelé
pour effectuer l’opération demandée. Si ce gestionnaire doit accéder à un état de
l’application qui est aussi utilisé par d’autres threads (le document édité, par exemple),
le gestionnaire et l’autre code qui accède à cet état doivent le faire de façon thread-safe.
I
Les bases
2
Thread safety
Assez étonnamment, la programmation concurrente ne concerne pas beaucoup plus les
threads ou les verrous qu’un ingénieur des travaux publics ne manipule des rivets ou
des poutrelles d’acier. Cela dit, la construction de ponts qui ne s’écroulent pas implique
évidemment une utilisation correcte de très nombreux rivets et poutrelles, tout comme
la construction de programmes concurrents exige une utilisation correcte des threads et
des verrous, mais ce sont simplement des mécanismes – des moyens d’arriver à ses fins.
Essentiellement, l’écriture d’un code thread-safe consiste à gérer l’accès à un état,
notamment à un état partagé et modifiable.
De façon informelle, l’état d’un objet est formé de ses données, qui sont stockées dans
des variables d’état comme les attributs d’instance ou de classe. L’état d’un objet peut
contenir les attributs d’autres objets : l’état d’un HashMap, par exemple, est en partie
stocké dans l’objet lui-même, mais également dans les nombreux objets Map.Entry.
L’état d’un objet comprend toutes les données qui peuvent affecter son comportement
extérieur.
Partagé signifie qu’on peut accéder à la variable par plusieurs threads ; modifiable indi-
que que la valeur de cette variable peut varier au cours de son existence. Bien que nous
puissions parler de la thread safety comme si elle concernait le code, ce que nous tentons
réellement de réaliser consiste à protéger les données contre les accès concurrents
indésirables.
La nécessité qu’un objet ait besoin d’être thread-safe dépend du fait qu’on puisse y
accéder à partir de threads différents. Cela dépend donc de l’utilisation de l’objet dans
un programme, pas de ce qu’il fait. Créer un objet thread-safe nécessite d’utiliser une
synchronisation pour coordonner les accès à son état modifiable ; ne pas le faire peut
perturber les données ou impliquer d’autres conséquences néfastes.
À chaque fois que plusieurs threads accèdent à une variable d’état donnée et que l’un
d’entre eux est susceptible de la modifier, tous ces threads doivent coordonner leurs
16 Les bases Partie I
accès via une synchronisation. En Java, le mécanisme principal de cette synchronisa-
tion est le mot-clé synchronized, qui fournit un verrou exclusif, mais le terme de
"synchronisation" comprend également l’utilisation de variables volatile, de verrous
explicites et de variables atomiques.
Ne faites pas l’erreur de penser qu’il existe des situations "spéciales" pour lesquelles
cette règle ne s’applique pas. Un programme qui n’effectue pas de synchronisation alors
qu’elle est nécessaire peut sembler fonctionner, passer les tests et se comporter norma-
lement pendant des années, ce qui ne l’empêche pas d’être incorrect et de pouvoir
échouer à tout moment.
Si plusieurs threads accèdent à la même variable d’état modifiable sans utiliser de
synchronisation adéquate, le programme est incorrect. Il existe trois moyens de corriger
ce problème :
• ne pas partager la variable d’état entre les threads ;
• rendre la variable d’état non modifiable ;
• utiliser la synchronisation à chaque fois que l’on accède à la variable d’état.
Si vous n’avez pas prévu les accès concurrents lors de la conception de votre classe,
certaines de ces approches pourront demander des modifications substantielles : corriger
le problème peut ne pas être aussi simple qu’il n’y paraît. Il est bien plus facile de
concevoir dès le départ une classe qui est thread-safe que de lui ajouter plus tard cette
thread safety.
Dans un gros programme, il peut être difficile de savoir si plusieurs threads sont suscep-
tibles d’accéder à une variable donnée. Heureusement, les techniques orientées objets
qui permettent d’écrire des classes bien organisées et faciles à maintenir – comme
l’encapsulation et l’abstraction des données – peuvent également vous aider à créer des
classes thread-safe. Plus le code qui doit accéder à une variable particulière est réduit,
plus il est facile de vérifier qu’il utilise une synchronisation appropriée et de réfléchir
aux conditions d’accès à cette variable. Le langage Java ne vous force pas à encapsuler
l’état – il est tout à fait possible de stocker cet état dans des membres publics (voire des
membres de classe publics) ou de fournir une référence vers un objet interne –, mais
plus l’état du programme est encapsulé, plus il est facile de le rendre thread-safe et
d’aider les développeurs à le conserver comme tel.
Lorsque l’on conçoit des classes thread-safe, les techniques orientées objet – encapsula-
tion, imutabilité et spécification claire des invariants – sont d’une aide inestimable.
Chapitre 2 Thread safety 17
Parfois, les techniques de conception orientées objet ne permettent pas de représenter
les besoins du monde réel ; dans ce cas, il peut être nécessaire de trouver un compromis
pour des raisons de performance ou de compatibilité ascendante avec le code antérieur.
Parfois, l’abstraction et l’encapsulation ne font pas bon ménage avec les performances
– bien que ce soit plus rare, contrairement à ce que pensent de nombreux développeurs –,
mais il est toujours préférable d’écrire d’abord un bon code avant de le rendre rapide.
Même alors, n’optimisez le code que si cela est nécessaire et si vos mesures ont montré
que cette optimisation fera une différence dans des conditions réalistes 1.
Si vous décidez de briser l’encapsulation, tout n’est quand même pas perdu. Votre
programme pourra quand même être thread-safe : ce sera juste beaucoup plus dur. En
outre, cette thread safety sera plus fragile en augmentant non seulement le coût et le
risque du développement, mais également le coût et le risque de la maintenance. Le
Chapitre 4 précisera les conditions sous lesquelles on peut relâcher sans problème
l’encapsulation des variables d’état.
Jusqu’à maintenant, nous avons utilisé presque indifféremment les termes "classe thread-
safe" et "programme thread-safe". Un programme thread-safe est-il un programme qui
n’est constitué que de classes thread-safe ? Pas nécessairement – un programme formé
uniquement de classes thread-safe peut ne pas l’être lui-même et un programme thread-
safe peut contenir des classes qui ne le sont pas. Les problèmes liés à la composition
des classes thread-safe seront également présentés au Chapitre 4. Quoi qu’il en soit, le
concept de classe thread-safe n’a de sens que si la classe encapsule son propre état. La
thread safety peut être un terme qui s’applique au code, mais il concerne l’état et ne
peut s’appliquer qu’à tout un corps de code qui encapsule son état, que ce soit un objet
ou tout un programme.
2.1 Qu’est-ce que la thread safety ?
Définir la thread safety est étonnamment difficile. Les tentatives les plus formelles sont
si compliquées qu’elles sont peu utiles ou compréhensibles, les autres ne sont que des
descriptions informelles qui semblent tourner en rond. Une recherche rapide sur Google
produit un grand nombre de "définitions" comme celles-ci :
m […] Peut être appelée à partir de plusieurs threads du programme sans qu’il y ait
d’interactions indésirables entre les threads.
m […] Peut être appelée par plusieurs threads simultanément sans nécessiter d’autre
action de la part de l’appelant.
1. Dans du code concurrent, cette pratique est d’autant plus souhaitable que les bogues liés à la
concurrence sont difficiles à reproduire et à détecter. Le bénéfice d’un petit gain de performance sur
certaines parties du code qui ne sont pas souvent utilisées peut très bien être occulté par le risque que
le programme échoue sur le terrain.
18 Les bases Partie I
Avec ce genre de définition, il n’est pas étonnant que ce terme soit confus ! Elles
ressemblent étrangement à "une classe est thread-safe si elle peut être utilisée sans
problème par plusieurs threads". On ne peut pas vraiment critiquer ce type d’affirmation,
mais cela ne nous aide pas beaucoup non plus. Que signifie "safe", tout d’abord ?
La notion de "correct" est au cœur de toute définition raisonnable de thread safety. Si
notre définition est floue, c’est parce que nous n’avons pas donné une définition claire
de cette notion.
Une classe est correcte si elle se conforme à sa spécification, et une bonne spécification
définit les invariants qui contraignent l’état d’un objet et les postconditions qui décrivent
les effets de ses opérations. Comme on écrit rarement les spécifications adéquates pour
nos classes, comment savoir qu’elles sont correctes ? Nous ne le pouvons pas, mais cela
ne nous empêche pas de les utiliser quand même une fois que nous sommes sûrs que "le
code fonctionne". Pour nombre d’entre nous, cette "confiance dans le code" se rapproche
beaucoup de la notion de correct et nous supposerons simplement qu’un code monothread
correct est quelque chose que "nous croyons quand nous le voyons". Maintenant que
nous avons donné une définition optimiste de "correct", nous pouvons définir la thread
safety de façon un peu moins circulaire : une classe est thread-safe si elle continue de se
comporter correctement lorsqu’on l’utilise à partir de plusieurs threads.
Une classe est thread-safe si elle se comporte correctement lorsqu’on l’utilise à partir de
plusieurs threads, quel que soit l’ordonnancement ou l’entrelacement de l’exécution
de ces threads et sans synchronisation ni autre coordination supplémentaire de la part
du code appelant.
Tout programme monothread étant également un programme multithread valide, il ne
peut pas être thread-safe s’il n’est même pas correct dans un environnement monothread 1.
Si un objet est correctement implémenté, aucune séquence d’opérations – appels à des
méthodes publiques et lecture ou écriture des champs publics – ne devrait pouvoir
violer ses invariants ou ses postconditions. Aucun ensemble d’opérations exécutées en
séquence ou parallèlement sur des instances d’une classe thread-safe ne peut placer
une instance dans un état invalide.
Les classes thread-safe encapsulent toute la synchronisation nécessaire pour que les
clients n’aient pas besoin de fournir la leur.
1. Si cette utilisation un peu floue de "correct" vous ennuie, vous pouvez considérer qu’une classe
thread-safe est une classe qui n’est pas plus incorrecte dans un environnement concurrent que dans un
environnement monothread.
Chapitre 2 Thread safety 19
2.1.1 Exemple : une servlet sans état
Au Chapitre 1, nous avons énuméré un certain nombre de frameworks qui créent des
threads et appellent vos composants à partir de ceux-ci en vous laissant la responsabilité
de créer des composants thread-safe. Très souvent, on doit créer des classes thread-safe,
non parce que l’on souhaite utiliser directement des threads, mais parce que l’on veut
bénéficier d’un framework comme celui des servlets. Nous allons développer un exemple
simple – un service de factorisation reposant sur une servlet – que nous étendrons petit
à petit tout en préservant sa thread safety.
Le Listing 2.1 montre le code de cette servlet. Elle extrait de la requête le nombre à
factoriser, le met en facteur et ajoute le résultat à la réponse.
Listing 2.1 : Une servlet sans état.
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
StatelessFactorizer, comme le plupart des servlets, est sans état : elle ne possède
aucun champ et ne fait référence à aucun champ d’autres classes. L’état transitoire pour
un calcul particulier n’existe que dans les variables locales stockées sur la pile du
thread, qui ne sont accessibles que par le thread qui s’exécute. Un thread accédant à une
StatelessFactorizer ne peut pas influer sur le résultat d’un autre thread accédant à
cette même StatelessFactorizer : les deux threads ne partagent pas d’état, comme s’ils
accédaient à des instances différentes. Les actions d’un thread accédant à un objet sans
état ne pouvant rendre incorrectes les opérations dans les autres threads, les objets sans état
sont thread-safe.
Les objets sans état sont toujours thread-safe.
Le fait que la plupart des servlets puissent être implémentées sans état réduit beaucoup
le souci d’en faire des servlets thread-safe. Ce n’est que lorsque les servlets veulent
mémoriser des informations d’une requête à l’autre que cette thread safety devient un
problème.
2.2 Atomicité
Que se passe-t-il lorsqu’on ajoute une information d’état à un objet sans état ? Supposons
par exemple que nous voulions ajouter un "compteur de visites" pour comptabiliser le
nombre de requêtes traitées par notre servlet. Une approche évidente consiste à ajouter
20 Les bases Partie I
un champ de type long à la servlet et de l’incrémenter à chaque requête, comme on le
fait dans la classe UnsafeCountingFactorizer du Listing 2.2.
Listing 2.2 : Servlet comptant le nombre de requêtes sans la synchronisation nécessaire. Ne
le faites pas.
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(resp, factors);
}
}
Malheureusement, UnsafeCountingFactorizer n’est pas thread-safe, même si elle
fonctionnerait parfaitement dans un environnement monothread. En effet, comme Unsafe
Sequence du Chapitre 1, cette classe est susceptible de perdre des mises à jour. Bien
que l’opération d’incrémentation ++count puisse sembler être une action simple à cause
de sa syntaxe compacte, elle n’est pas atomique, ce qui signifie qu’elle ne s’exécute pas
comme une unique opération indivisible. C’est, au contraire, un raccourci d’écriture
pour une suite de trois opérations : obtention de la valeur courante, ajout de un à cette
valeur et stockage de la nouvelle valeur à la place de l’ancienne. C’est donc un exemple
d’opération lire-modifier-écrire dans laquelle l’état final dépend de l’état précédent.
La Figure 1.1 du Chapitre 1 a montré ce qui pouvait se passer lorsque deux threads
essayaient d’incrémenter un compteur simultanément. Si ce compteur vaut initialement
9, un timing malheureux pourrait faire que les deux threads lisent la variable, constatent
qu’elle vaut 9, lui ajoutent un et fixent donc tous les deux le compteur à 10, ce qui n’est
certainement pas ce que l’on attend. On a perdu une incrémentation, et le compteur de
visites vaut maintenant un de moins que ce qu’il devrait valoir.
Vous pourriez penser que, pour un service web, on peut se satisfaire d’un compteur de
visites légèrement imprécis, et c’est effectivement parfois le cas. Mais, si ce compteur
sert à produire des séquences d’identifiants uniques pour des objets et qu’il renvoie la
même valeur en réponse à plusieurs appels, cela risque de poser de sérieux problèmes
d’intégrité des données1. En programmation concurrente, la possibilité d’obtenir des
résultats incorrects à cause d’un timing malheureux est si importante qu’on lui a donné
un nom : situation de compétition (race condition).
1. L’approche utilisée par UnsafeSequence et UnsafeCountingFactorizer souffre d’autres problèmes
importants, dont la possibilité d’avoir des données obsolètes (voir la section 3.1.1).
Chapitre 2 Thread safety 21
2.2.1 Situations de compétition
UnsafeCountingFactorizer a plusieurs situations de compétition qui rendent ses résultats
non fiables. Une situation de compétition apparaît lorsque l’exactitude d’un calcul dépend
de l’ordonnancement ou de l’entrelacement des différents threads lors de l’exécution ;
en d’autres termes lorsque l’obtention d’une réponse correcte dépend de la chance 1. La
situation de compétition la plus fréquente est vérifier-puis-agir, où une observation
potentiellement obsolète sert à prendre une décision sur ce qu’il faut faire ensuite.
Nous rencontrons souvent des situations de compétition dans la vie de tous les jours.
Supposons que vous ayez prévu de rencontrer un ami après midi dans un café de l’avenue
Crampel. Arrivé là, vous vous rendez compte qu’il y a deux cafés dans cette avenue et
vous n’êtes pas sûr de celui où vous vous êtes donné rendez-vous. À midi dix, votre ami
n’est pas dans le café A et vous allez donc dans le café B pour voir s’il s’y trouve, or il
n’y est pas non plus. Il reste alors peu de possibilités : votre ami est en retard et n’est
dans aucun des cafés ; votre ami est arrivé au café A après que vous l’avez quitté ou
votre ami était dans le café B, vous cherche et est maintenant en route vers le café A.
Supposons le pire des scénarios, qui est la dernière de ces trois solutions. Il est maintenant
midi quinze, vous êtes allés tous les deux dans les deux cafés et vous vous demandez
tous les deux si vous vous êtes manqués. Que faire maintenant ? Retourner dans l’autre
café ? Combien de fois allez-vous aller et venir ? À moins de vous être mis d’accord sur
un protocole, vous risquez de passer la journée à parcourir l’avenue Crampel, aigri et en
manque de caféine.
Le problème avec l’approche "je vais juste descendre la rue et voir s’il est dans l’autre
café" est que, pendant que vous marchez dans la rue, votre ami peut s’être déplacé. Vous
regardez dans le café A, constatez qu’"il n’est pas là" et vous continuez à le chercher.
Vous pourriez faire de même avec le café B, mais pas en même temps. Il faut quelques
minutes pour aller d’un café à l’autre et, pendant ce temps, l’état du système peut avoir
changé.
Cet exemple des cafés illustre une situation de compétition puisque l’obtention du
résultat voulu (rencontrer votre ami) dépend du timing relatif des événements (l’instant
où chacun de vous arrive dans l’un ou l’autre café, le temps d’attente avant de partir
dans l’autre café, etc.). L’observation que votre ami n’est pas dans le café A devient
1. Le terme race condition est souvent confondu avec celui de data race, qui intervient lorsque l’on
n’utilise pas de synchronisation pour coordonner tous les accès à un champ partagé non constant. À
chaque fois qu’un thread écrit dans une variable qui pourrait ensuite être lue par un autre thread ou lit
une variable qui pourrait avoir été écrite par un autre thread, on risque un data race si les deux threads
ne sont pas synchronisés. Un code contenant des data races n’a aucune sémantique définie dans le
modèle mémoire de Java. Toutes les race conditions ne sont pas des data races et toutes les data races
ne sont pas des race conditions, mais toutes les deux font échouer les programmes concurrents de
façon non prévisible. UnsafeCountingFactorizer contient à la fois des race conditions et des data
races. Voir le Chapitre 16 pour plus d’informations sur les data races.
22 Les bases Partie I
potentiellement obsolète dès que vous en ressortez puisqu’il pourrait y être entré par la
porte de derrière sans que vous le sachiez. C’est cette obsolescence des observations qui
caractérise la plupart des situations de compétition – l’utilisation d’une observation
potentiellement obsolète pour prendre une décision ou effectuer un calcul. Ce type de
situation de compétition s’appelle tester-puis-agir : vous observez que quelque chose
est vrai (le fichier X n’existe pas), puis vous prenez une décision en fonction de cette
observation (créer X) mais, en fait, l’observation a pu devenir obsolète entre le moment
où vous l’avez observée et celui où vous avez agi (quelqu’un d’autre a pu créer X entre-
temps), ce qui pose un problème (une exception inattendue, des données écrasées, un
fichier abîmé).
2.2.2 Exemple : situations de compétition dans une initialisation
paresseuse
L’initialisation paresseuse est un idiome classique de l’utilisation de tester-puis-agir.
Le but d’une initialisation paresseuse consiste à différer l’initialisation d’un objet
jusqu’à ce que l’on en ait réellement besoin tout en garantissant qu’il ne sera initialisé
qu’une seule fois. La classe LazyInitRace du Listing 2.3 illustre cet idiome. La méthode
getInstance() commence par tester si l’objet ExpensiveObject a déjà été initialisé,
auquel cas elle renvoie l’instance existante ; sinon elle crée une nouvelle instance qu’elle
renvoie après avoir mémorisé sa référence pour que les futurs appels n’aient pas à
reproduire ce code coûteux.
Listing 2.3 : Situation de compétition dans une initialisation paresseuse. Ne le faites pas.
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null)
instance = new ExpensiveObject();
return instance;
}
}
LazyInitRace contient des situations de compétition qui peuvent perturber son fonction-
nement. Supposons par exemple que les threads A et B exécutent getInstance(). A
constate que instance vaut null et instancie un nouvel objet ExpensiveObject. B teste
également instance, or le résultat de ce test dépend du timing, qui n’est pas prévisible
puisqu’il est fonction des caprices de l’ordonnancement et du temps que met A pour
instancier ExpensiveObject et initialiser le champ instance. Si celui-ci vaut null
lorsque B le teste, les deux appels à getInstance() peuvent produire deux résultats
différents, bien que cette méthode soit censée toujours renvoyer la même instance.
Le comptage des visites dans UnsafeCountingFactorizer contient une autre sorte de
situation de compétition. Les opérations lire-modifier-écrire, ce qui est le cas de l’incré-
mentation d’un compteur, définissent une transformation de l’état d’un objet à partir de
Chapitre 2 Thread safety 23
son état antérieur. Pour incrémenter un compteur, il faut connaître sa valeur précédente
et s’assurer que personne d’autre ne modifie ou n’utilise cette valeur pendant que l’on
est en train de la modifier.
Comme la plupart des problèmes de concurrence, les situations de compétition ne provo-
quent pas toujours de panne : pour cela, il faut également un mauvais timing. Cependant,
les situations de compétition peuvent poser de sérieux problèmes. Si LazyInitRace est
utilisée pour instancier un enregistrement de compte, par exemple, le fait qu’elle ne
renvoie pas la même instance lorsqu’on l’appelle plusieurs fois pourrait provoquer la
perte d’inscriptions ou les différentes activités pourraient avoir des vues incohérentes
de l’ensemble des inscrits. Si UnsafeSequence est utilisée pour produire des identifiants
uniques, deux objets distincts pourraient recevoir le même identifiant, violant ainsi les
contraintes d’intégrité concernant l’identité des objets.
2.2.3 Actions composées
LazyInitRace et UnsafeCountingFactorizer contenaient toutes les deux une séquence
d’opérations qui aurait dû être atomique, c’est-à-dire indivisible, par rapport aux autres
opérations sur le même état. Pour éviter les situations de compétition, on doit disposer
d’un moyen d’empêcher d’autres threads d’utiliser une variable que l’on est en train de
modifier afin de pouvoir garantir que ces threads ne pourront observer ou modifier l’état
qu’avant ou après, mais pas en même temps que nous.
Les opérations A et B sont atomiques l’une pour l’autre si, du point de vue du thread qui
exécute A, lorsqu’un autre thread exécute B, l’opération est exécutée dans son intégralité
ou pas du tout. Une opération atomique l’est donc par rapport à toutes les opérations,
y compris elle-même, qui manipulent le même état.
Si l’opération d’incrémentation de UnsafeSequence avait été atomique, la situation de
compétition illustrée par la Figure 1.1 n’aurait pas pu avoir lieu et chaque exécution
de cette opération aurait incrémenté le compteur de un exactement, comme on l’attendait.
Pour garantir la thread safety, les opérations tester-puis-agir (comme l’initialisation
paresseuse) et lire-modifier-écrire (comme l’incrémentation) doivent toujours être
atomiques. Ces deux types d’opérations sont des actions composées, c’est-à-dire des
séquences d’opérations qui doivent être exécutées de façon atomique afin de rester
thread-safe. Dans la section suivante, nous étudierons les verrous, un mécanisme intégré
à Java permettant de garantir l’atomicité mais, pour l’instant, nous allons résoudre notre
problème d’une autre façon en utilisant une classe thread-safe existante, comme dans le
Listing 2.4.
24 Les bases Partie I
Listing 2.4 : Servlet comptant les requêtes avec AtomicLong.
@ThreadSafe
public class CountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0);
public long getCount() { return count.get(); }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}
}
Le paquetage java.util.concurrent.atomic contient des classes de variables atomiques
permettant d’effectuer des changements d’état atomiques sur les nombres et les références
d’objets. En remplaçant le type long du compteur par AtomicLong, nous garantissons
donc que tous les accès à l’état du compteur seront atomiques1. L’état de la servlet étant
l’état du compteur qui est désormais thread-safe, notre servlet le devient également.
Nous avons pu ajouter un compteur à notre servlet de factorisation et maintenir la
thread safety en utilisant une classe thread-safe existante, AtomicLong, pour gérer l’état
du compteur. Lorsque l’on ajoute un unique élément d’état à une classe sans état, cette
classe sera thread-safe si l’état est entièrement géré par un objet thread-safe. Cependant,
comme nous le verrons dans la prochaine section, passer d’une seule variable d’état à
plusieurs n’est pas forcément aussi simple que passer de zéro à un.
À chaque fois que cela est possible, utilisez des objets thread-safe existants, comme
AtomicLong, pour gérer l’état de votre classe. Il est en effet plus facile de réfléchir aux
états possibles et aux transitions d’état des objets thread-safe existants que de le faire
pour des variables d’état quelconques ; en outre, cela facilite la maintenance et la véri-
fication de la thread safety.
2.3 Verrous
Nous avons pu ajouter une variable d’état à notre servlet tout en maintenant la thread
safety car nous avons utilisé un objet thread-safe pour gérer tout l’état de la servlet. Mais
que se passe-t-il si l’on souhaite ajouter d’autres éléments d’état ? Peut-on se contenter
d’ajouter d’autres variables d’état thread-safe ? Imaginons que nous voulions améliorer
les performances de notre servlet en mettant en cache le dernier résultat calculé, juste
au cas où deux requêtes consécutives demanderaient à factoriser le même nombre (ce
n’est sûrement pas une bonne stratégie de cache ; nous en présenterons une meilleure
1. Pour incrémenter le compteur, CountingFactorizer appelle incrementAndGet(), qui renvoie
également la valeur incrémentée. Ici, cette valeur est ignorée.
Chapitre 2 Thread safety 25
dans la section 5.6). Pour implémenter ce cache, nous devons mémoriser à la fois le
nombre factorisé et ses facteurs.
Comme nous avons utilisé la classe AtomicLong pour gérer l’état du compteur de façon
thread-safe, nous pourrions peut-être utiliser sa cousine, AtomicReference1, pour gérer
le dernier nombre et ses facteurs. La classe UnsafeCachingFactorizer du Listing 2.5
implémente cette tentative.
Listing 2.5 : Servlet tentant de mettre en cache son dernier résultat sans l’atomicité adéquate.
Ne le faites pas.
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber
= new AtomicReference<BigInteger>();
private final AtomicReference<BigInteger[]> lastFactors
= new AtomicReference<BigInteger[]>();
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get()))
encodeIntoResponse(resp, lastFactors.get());
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}
Malheureusement, cette approche ne fonctionne pas. Bien que, individuellement, les
références atomiques soient thread-safe, UnsafeCachingFactorizer contient des situa-
tions de compétition qui peuvent lui faire produire une mauvaise réponse.
La définition de thread-safe implique que les invariants soient préservés quel que soit le
timing ou l’entrelacement des opérations entre les threads. Un des invariants de Unsafe
CachingFactorizer est que le produit des facteurs mis en cache dans lastFactors est
égal à la valeur mise en cache dans lastNumber ; la servlet ne sera correcte que si cet
invariant est toujours respecté. Lorsqu’un invariant implique plusieurs variables, celles-ci
ne sont pas indépendantes : la valeur de l’une contraint la ou les valeurs des autres. Par
conséquent, lorsqu’on modifie l’une de ces variables, il faut également modifier les
autres dans la même opération atomique.
Ici, avec un timing malheureux, UnsafeCachingFactorizer peut violer cet invariant.
Avec des références atomiques, nous ne pouvons pas modifier en même temps lastNumber
et lastFactors, bien que chaque appel à set() soit atomique ; il reste une fenêtre
pendant laquelle une référence est modifiée alors que l’autre ne l’est pas encore et, dans
1. Tout comme AtomicLong est une classe thread-safe qui encapsule un long, AtomicReference est
une classe thread-safe qui encapsule une référence d’objet. Les variables atomiques et leurs avantages
seront présentés au Chapitre 15.
26 Les bases Partie I
cet intervalle, d’autres threads pourraient constater que l’invariant n’est pas vérifié. De
même, les deux valeurs ne peuvent pas être lues simultanément : entre le moment où le
thread A lit les deux valeurs, le thread B peut les avoir modifiées et, là aussi, A peut
observer que l’invariant n’est pas vérifié.
Pour préserver la cohérence d’un état, vous devez modifier toutes les variables de cet
état dans une unique opération atomique.
2.3.1 Verrous internes
Java fournit un mécanisme intégré pour assurer l’atomicité : les blocs synchronized (la
visibilité est également un autre aspect essentiel des verrous et des autres mécanismes
de synchronisation ; elle sera présentée au Chapitre 3). Un bloc synchronized est
formé de deux parties : une référence à un objet qui servira de verrou et le bloc de code
qui sera protégé par ce verrou. Une méthode synchronized est un raccourci pour un
bloc synchronized qui s’étend à tout le corps de cette méthode et dont le verrou est
l’objet sur lequel la méthode est invoquée (les méthodes synchronized statiques utili-
sent l’objet Class comme verrou).
synchronized(verrou) {
// Lit ou modifie l’état partagé protégé par le verrou
}
Tout objet Java peut implicitement servir de verrou pour les besoins d’une synchronisa-
tion ; ces verrous intégrés sont appelés verrous internes ou moniteurs. Le thread qui
s’exécute ferme automatiquement le verrou avant d’entrer dans un bloc synchronized
et le relâche automatiquement à la sortie de ce bloc, qu’il en soit sorti normalement ou
à cause d’une exception. La seule façon de fermer un verrou interne consiste à entrer
dans un bloc ou une méthode synchronized protégés par ce verrou.
Les verrous internes de Java se comportent comme des mutex (verrous d’exclusion
mutuelle), ce qui signifie qu’un seul thread peut fermer le verrou. Lorsque le thread A
tente de fermer un verrou qui a été verrouillé par le thread B, A doit attendre, ou se
bloquer, jusqu’à ce que B le relâche. Si B ne relâche jamais le verrou, A est bloqué à
jamais.
Un bloc de code protégé par un verrou donné ne pouvant être exécuté que par un seul
thread à la fois, les blocs synchronized protégés par ce verrou s’exécutent donc de
façon atomique les uns par rapport aux autres. Dans le contexte de la concurrence,
atomique signifie la même chose que dans les transactions – un groupe d’instructions
semble s’exécuter comme une unité simple et indivisible. Aucun thread exécutant un
bloc synchronized ne peut observer un autre thread au milieu d’un bloc synchronized
protégé par le même verrou.
Chapitre 2 Thread safety 27
Le mécanisme de synchronisation simplifie la restauration de la thread safety de la
servlet de factorisation. Le Listing 2.6 synchronise la méthode service() afin qu’un seul
thread puisse entrer dans le service à un instant donné. SynchronizedFactorizer est
donc désormais thread-safe. Cependant, cette approche est assez extrême puisqu’elle
interdit l’utilisation simultanée de la servlet par plusieurs clients, ce qui induira des
temps de réponse inacceptables. Ce problème, qui est un problème de performances,
pas un problème de thread safety, sera traité dans la section 2.5.
Listing 2.6 : Servlet mettant en cache le dernier résultat, mais avec une très mauvaise
concurrence. Ne le faites pas.
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
public synchronized void service(ServletRequest req,
ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber))
encodeIntoResponse (resp, lastFactors);
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}
}
2.3.2 Réentrance
Lorsqu’un thread demande un verrou qui est déjà verrouillé par un autre thread, le
thread demandeur est bloqué. Les verrous internes étant réentrants, si un thread tente de
prendre un verrou qu’il détient déjà, la requête réussit. La réentrance signifie que les
verrous sont acquis par thread et non par appel1. Elle est implémentée en associant à
chaque verrou un compteur d’acquisition et un thread propriétaire. Lorsque le compteur
passe à zéro, le verrou est considéré comme libre. Si un thread acquiert un verrou
venant d’être libéré, la JVM enregistre le propriétaire et fixe le compteur d’acquisition
à un. Si le même thread prend à nouveau le verrou, le compteur est incrémenté et,
lorsqu’il libère le verrou, le compteur est décrémenté. Quand le compteur atteint zéro,
le verrou est relâché.
La réentrance facilite l’encapsulation du comportement des verrous et simplifie par
conséquent le développement de code concurrent orienté objet. Sans verrous réentrants,
le code très naturel du Listing 2.7, dans lequel une sous-classe redéfinit une méthode
synchronized puis appelle la méthode de sa superclasse, provoquerait un deadlock. Les
1. Ce qui est différent du comportement par défaut des mutex pthreads (POSIX threads), qui sont
accordés à la demande.
28 Les bases Partie I
méthodes doSomething() de Widget et LoggingWidget étant toutes les deux synchro-
nized, chacune tente d’obtenir le verrou sur le Widget avant de continuer. Si les verrous
internes n’étaient pas réentrants, l’appel à super.doSomething() ne pourrait jamais
obtenir le verrou puisque ce dernier serait considéré comme déjà pris : le thread serait
bloqué en permanence en attente d’un verrou qu’il ne pourra jamais obtenir. Dans des
situations comme celles-ci, la réentrance nous protège des interblocages.
Listing 2.7 : Ce code se bloquerait si les verrous internes n’étaient pas réentrants.
public class Widget {
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
2.4 Protection de l’état avec les verrous
Les verrous autorisant un accès sérialisé1 au code qu’ils protègent, nous pouvons les
utiliser pour construire des protocoles garantissant un accès exclusif à l’état partagé. Le
respect de ces protocoles permet alors de garantir la cohérence de cet état.
Les actions composées sur l’état partagé, comme l’incrémentation d’un compteur de
visites (lire-modifier-écrire) ou l’initialisation paresseuse (tester-puis-agir) peuvent ainsi
être rendues atomiques pour éviter les situations de compétition. La détention d’un
verrou pour toute la durée d’une action composée rend cette action atomique. Cepen-
dant, il ne suffit pas d’envelopper l’action composée dans un bloc synchronized : si la
synchronisation sert à coordonner l’accès à une variable, elle est nécessaire partout où
cette variable est utilisée. En outre, lorsque l’on utilise des verrous pour coordonner
l’accès à une variable, c’est le même verrou qui doit être utilisé à chaque accès à cette
variable.
Une erreur fréquente consiste à supposer que la synchronisation n’est utile que lorsque
l’on écrit dans des variables partagées ; ce n’est pas vrai (la section 3.1 expliquera plus
clairement pourquoi).
1. L’accès sérialisé à un objet n’a rien à voir avec la sérialisation d’un objet (sa transformation en flux
d’octets) : un accès sérialisé signifie que les threads doivent prendre leur tour avant d’avoir l’exclusivité
de l’objet au lieu d’y accéder de manière concurrente.
Chapitre 2 Thread safety 29
À chaque fois qu’une variable d’état modifiable est susceptible d’être accédée par
plusieurs threads, tous les accès à cette variable doivent s’effectuer sous la protection du
même verrou. En ce cas, on dit que la variable est protégée par ce verrou.
Dans la classe SynchronizedFactorizer du Listing 2.6, lastNumber et lastFactors sont
protégées par le verrou interne de l’objet servlet, ce qui est documenté par l’annotation
@GuardedBy.
Il n’existe aucune relation inhérente entre le verrou interne d’un objet et son état ; les
champs d’un objet peuvent très bien ne pas être protégés par son verrou interne, bien
que ce soit une convention de verrouillage tout à fait valide, utilisée par de nombreuses
classes. Posséder le verrou associé à un objet n’empêche pas les autres threads d’accéder
à cet objet – la seule chose que cela empêche est qu’un autre thread prenne le même
verrou. Le fait que tout objet dispose d’un verrou interne est simplement un mécanisme
pratique qui vous évite de devoir créer explicitement des objets verrous1. C’est à vous de
construire les protocoles de verrouillage ou les politiques de synchronisation permet-
tant d’accéder en toute sécurité à l’état partagé et c’est à vous de les utiliser de manière
cohérente tout au long de votre programme.
Toute variable partagée et modifiable devrait être protégée par un et un seul verrou, et
vous devez indiquer clairement aux développeurs qui maintiennent votre code le verrou
dont il s’agit.
Une convention de verrouillage classique consiste à encapsuler tout l’état modifiable
dans un objet et de le protéger des accès concurrents en synchronisant tout le code qui
accède à cet état à l’aide du verrou interne de l’objet. Ce patron de conception est utilisé
dans de nombreuses classes thread-safe, comme Vector et les autres classes collections
synchronisées. En ce cas, toutes les variables de l’état d’un objet sont protégées par le
verrou interne de l’objet. Cependant, ce patron n’est absolument pas spécial et ni la
compilation ni l’exécution n’impose de patron de verrouillage2. En outre, il est relative-
ment facile de tromper accidentellement ce protocole en ajoutant une nouvelle méthode
ou un code quelconque en oubliant d’utiliser la synchronisation.
Toutes les données n’ont pas besoin d’être protégées par des verrous – ceux-ci ne sont
nécessaires que pour les données modifiables auxquelles on accédera à partir de plusieurs
threads. Au Chapitre 1, nous avons expliqué comment l’ajout d’un simple événement
1. Rétrospectivement, ce choix de conception n’est probablement pas le meilleur qui soit : non seule-
ment il peut être trompeur, mais il force les implémentations de la JVM à faire des compromis entre
la taille des objets et les performances des verrous.
2. Les outils d’audit du code, comme FindBugs, peuvent détecter les cas où on accède à une variable
souvent, mais pas toujours, via un verrou, ce qui peut indiquer un bogue.
30 Les bases Partie I
asynchrone comme un TimerTask pouvait imposer une thread safety qui devait se propager
dans le programme, notamment si l’état de ce programme était mal encapsulé. Prenons,
par exemple, un programme monothread qui traite un gros volume de données ; les
programmes monothreads n’ont pas besoin de synchronisation puisqu’il n’y a pas de
données partagées entre des threads. Imaginons maintenant que nous voulions ajouter
une fonctionnalité permettant de créer périodiquement des instantanés de sa progression
afin de ne pas devoir tout recommencer si le programme échoue ou doit être arrêté.
Nous pourrions pour cela choisir d’utiliser un TimerTask qui se lancerait toutes les dix
minutes et sauvegarderait l’état du programme dans un fichier.
Ce TimerTask étant appelé à partir d’un autre thread (géré par Timer), on accède désor-
mais aux données impliquées dans l’instantané par deux threads : celui du programme
principal et celui du Timer. Ceci signifie que non seulement le code du TimerTask doit
utiliser la synchronisation lorsqu’il accède à l’état du programme, mais que tout le code
du programme qui touche à ces données doit faire de même. Ce qui n’exigeait pas de
synchronisation auparavant doit maintenant l’utiliser.
Lorsqu’une variable est protégée par un verrou – ce qui signifie que tous les accès à
cette variable ne pourront se faire que si l’on détient le verrou –, on garantit qu’un seul
thread pourra accéder à celle-ci à un instant donné. Lorsqu’une classe a des invariants
impliquant plusieurs variables d’état, il faut également que chaque variable participant à
l’invariant soit protégée par le même verrou car on peut ainsi modifier toutes ces variables
en une seule opération atomique et donc préserver l’invariant. La classe Synchronized
Factorizer met cette règle en pratique : le nombre et les facteurs mis en cache sont
protégés par le verrou interne de l’objet servlet.
À chaque fois qu’un invariant implique plusieurs variables, elles doivent toutes être
protégées par le même verrou.
Si la synchronisation est un remède contre les situations de compétition, pourquoi ne
pas simplement déclarer toutes les méthodes comme synchronized ? En fait, une appli-
cation irréfléchie de synchronized pourrait fournir une synchronisation soit trop
importante, soit insuffisante. Se contenter de synchroniser chaque méthode, comme le
fait Vector, ne suffit pas à rendre atomiques les actions composées d’un Vector :
if (!vector.contains(element))
vector.add(element);
Cette tentative d’opération mettre-si-absent contient une situation de compétition, bien
que contains() et add() soient atomiques. Alors que les méthodes synchronized peuvent
rendre atomiques des opérations individuelles, il faut ajouter un verrouillage lorsque
plusieurs opérations sont combinées pour créer une action composée (la section 4.4
présente quelques techniques permettant d’ajouter en toute sécurité des opérations
atomiques supplémentaires à des objets thread-safe). En même temps, synchroniser
Chapitre 2 Thread safety 31
chaque méthode peut poser des problèmes de vivacité ou de performances, comme nous
l’avons vu avec SynchronizedFactorizer.
2.5 Vivacité et performances
Dans UnsafeCachingFactorizer, nous avons ajouté une mise en cache à notre servlet
de factorisation dans l’espoir d’améliorer ses performances. Cette mise en cache a
nécessité un état partagé qui, à son tour, a exigé une synchronisation pour maintenir son
intégrité. Cependant, la façon dont nous avons utilisé la synchronisation dans Synchro-
nizedFactorizer fait que cette classe est peu efficace. La politique de synchronisation
de SynchronizedFactorizer consiste à protéger chaque variable d’état à l’aide du verrou
interne de l’objet servlet ; nous l’avons implémentée en synchronisant l’intégralité de la
méthode service(). Cette approche simple et grossière a suffi à restaurer la thread
safety, mais elle coûte cher.
Figure 2.1
factor
Concurrence A L U
n
inefficace pour
Synchronized factor
Factorizer. B L U
m
factor
C L U
m
La méthode service() étant synchronisée, un seul thread peut l’exécuter à la fois, ce
qui va à l’encontre du but recherché par le framework des servlets – qu’elles puissent
traiter plusieurs requêtes simultanément – et peut frustrer les utilisateurs lorsque la
charge est importante. En effet, si la servlet est occupée à factoriser un grand nombre,
les autres clients devront attendre la fin de ce traitement avant qu’elle puisse lancer une
nouvelle factorisation. Si le système a plusieurs CPU, les processeurs peuvent rester
inactifs bien que la charge soit élevée. Dans tous les cas, même des requêtes courtes
comme celles pour la valeur qui est dans le cache peuvent prendre un temps anormalement
long si elles doivent attendre qu’un long calcul se soit terminé.
La Figure 2.1 montre ce qui se passe lorsque plusieurs requêtes arrivent sur la servlet de
factorisation synchronisée : elles sont placées en file d’attente et traitées séquentiellement.
Cette application web met en évidence une mauvaise concurrence : le nombre d’appels
simultanés est limité non par la disponibilité des ressources de calcul mais par la structure
de l’application elle-même. Heureusement, on peut assez simplement améliorer sa concur-
rence tout en la gardant thread-safe en réduisant l’étendue du bloc synchronized. Vous
devez faire attention à ne pas trop le réduire quand même ; une opération qui doit être
atomique doit rester dans le même bloc synchronized. Cependant, ici il est raisonnable
32 Les bases Partie I
d’essayer d’exclure du bloc les opérations longues qui n’affectent pas l’état partagé,
afin que les autres threads ne soient pas empêchés d’accéder à cet état pendant que ces
opérations s’exécutent.
La classe CachedFactorizer du Listing 2.8 restructure la servlet pour utiliser deux blocs
synchronized distincts, chacun se limitant à une petite section de code. L’un protège la
séquence tester-puis-agir, qui teste si l’on peut se contenter de renvoyer le résultat qui
est en cache, l’autre protège la mise à jour du nombre et des facteurs en cache. En
cadeau, nous avons réintroduit le compteur de visites et ajouté un compteur de hits pour
le cache, qui sont mis à jour dans le bloc synchronized initial (ces compteurs constituant
également un état modifiable et partagé, leurs accès doivent être synchronisés). Les
portions du code placées à l’extérieur des blocs synchronized manipulent exclusivement
des variables locales (stockées dans la pile), qui ne sont pas partagées entre les threads
et qui ne demandent donc pas de synchronisation.
Listing 2.8 : Servlet mettant en cache la dernière requête et son résultat.
@ThreadSafe
public class CachedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
@GuardedBy("this") private long hits;
@GuardedBy("this") private long cacheHits;
public synchronized long getHits() { return hits; }
public synchronized double getCacheHitRatio() {
return (double) cacheHits / (double) hits;
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
synchronized(this) {
++hits;
if (i.equals(lastNumber)) {
++cacheHits;
factors = lastFactors.clone();
}
}
if (factors == null) {
factors = factor(i);
synchronized(this) {
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(resp, factors);
}
}
CachedFactorizer n’utilise plus AtomicLong pour le compteur de visite : nous sommes
revenus à un champ de type long. Nous aurions pu utiliser AtomicLong, mais cela avait
moins d’intérêt que dans CountingFactorizer : les variables atomiques sont pratiques
lorsque l’on a besoin d’effectuer des opérations atomiques sur une seule variable mais,
Chapitre 2 Thread safety 33
comme nous utilisons déjà des blocs synchronized pour construire des opérations atomi-
ques, l’utilisation de deux mécanismes de synchronisation différents serait troublante et
n’offrirait aucun avantage en terme de performance et de thread safety.
La restructuration de CachedFactorizer est un équilibre entre simplicité (synchronisation
de toute la méthode) et concurrence (synchronisation du plus petit code possible). La
fermeture et le relâchement d’un verrou ayant un certain coût, il est préférable de ne pas
trop découper les blocs synchronized (en mettant par exemple ++hits dans son propre
bloc synchronized), même si cela ne compromettrait pas l’atomicité. CachedFactorizer
ferme le verrou lorsqu’il accède aux variables d’état et pendant l’exécution des actions
composées, mais il le libère avant d’exécuter l’opération de factorisation, qui peut
prendre un certain temps. Cette approche permet donc de rester thread-safe sans trop
affecter la concurrence ; le code contenu dans chaque bloc synchronized est "suffisam-
ment court".
Décider de la taille des blocs synchronized implique parfois de trouver un équilibre
entre la thread safety (qui ne doit pas être compromise), la simplicité et les performances.
Parfois, ces deux derniers critères sont à l’opposé l’un de l’autre bien que, comme le montre
CachedFactorizer, il est généralement possible de trouver un équilibre acceptable.
Il y a souvent des tensions entre simplicité et performances. Lorsque vous implémentez
une politique de synchronisation, résistez à la tentation de sacrifier prématurément la
simplicité (en risquant de compromettre la thread safety) au bénéfice des performances.
À chaque fois que vous utilisez des verrous, vous devez savoir ce que fait le code du
bloc et s’il est susceptible de mettre un certain temps pour s’exécuter. Fermer un verrou
pendant longtemps, soit parce que l’on effectue un gros calcul, soit parce que l’on
exécute une opération qui peut être bloquante, introduit potentiellement des problèmes
de vivacité ou de performances.
Évitez de maintenir un verrou pendant les longs traitements ou les opérations qui
risquent de durer longtemps, comme les E/S sur le réseau ou la console.
3
Partage des objets
Au début du Chapitre 2, nous avons écrit que l’écriture de programmes concurrents
corrects consistait essentiellement à gérer l’accès à l’état modifiable et partagé. Ce
chapitre a expliqué comment la synchronisation permet d’empêcher que plusieurs threads
accèdent aux mêmes données en même temps et a examiné les techniques permettant de
partager et de publier des objets qui pourront être manipulés simultanément par
plusieurs threads. L’ensemble de ces techniques pose les bases de la construction de
classes thread-safe et d’une structuration correcte des applications concurrentes à l’aide
des classes de java.util.concurrent.
Nous avons également vu comment les blocs et les méthodes synchronized permettent
d’assurer que les opérations s’exécutent de façon atomique, mais une erreur fréquente
consiste à penser que synchronized concerne uniquement l’atomicité ou la délimitation
des "sections critiques". La synchronisation a un autre aspect important et subtil : la
visibilité mémoire. Nous voulons non seulement empêcher un thread de modifier l’état
d’un objet pendant qu’un autre thread l’utilise, mais également garantir que lorsqu’un
thread modifie l’état d’un objet, les autres pourront voir les changements effectués. Or,
sans synchronisation, ceci peut ne pas arriver. Vous pouvez garantir que les objets
seront publiés correctement soit en utilisant une synchronisation explicite, soit en tirant
parti de la synchronisation intégrée aux classes de la bibliothèque.
3.1 Visibilité
La visibilité est un problème subtil parce que ce qui peut mal se passer n’est pas
évident. Dans un environnement monothread, si l’on écrit une valeur dans une variable
et qu’on lise ensuite cette variable sans qu’elle ait été modifiée entre-temps, on s’attend
à retrouver la même valeur, ce qui semble naturel. Cela peut être difficile à accepter
mais, quand les lectures et les écritures ont lieu dans des threads différents, ce n’est pas
le cas. En général, il n’y a aucune garantie que le thread qui lit verra une valeur écrite
36 Les bases Partie I
par un autre thread. Pour assurer la visibilité des écritures mémoire entre les threads, il
faut utiliser la synchronisation.
La classe NoVisibility du Listing 3.1 illustre ce qui peut se passer lorsque des threads
partagent des données sans synchronisation. Deux threads, le thread principal et le
thread lecteur, accèdent aux variables partagées ready et number. Le thread principal
lance le thread lecteur puis initialise number à 42 et ready à true. Le thread lecteur
boucle jusqu’à voir ready à true, puis affiche number. Bien qu’il puisse sembler évident
que NoVisibility affichera 42, il est possible qu’elle affiche zéro, voire qu’elle ne se
termine jamais ! Comme cette classe n’utilise pas de synchronisation adéquate, il n’y a
aucune garantie que les valeurs de ready et number écrites par le thread principal soient
visibles par le thread lecteur.
Listing 3.1 : Partage de données sans synchronisation. Ne le faites pas.
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
NoVisibility pourrait boucler sans fin parce que le thread lecteur pourrait ne jamais
voir la nouvelle valeur de ready. Ce qui est plus étrange encore est que NoVisibility
pourrait afficher zéro car le thread lecteur pourrait voir la nouvelle valeur de ready
avant l’écriture dans number, en vertu d’un phénomène appelé réarrangement. Il n’y a
aucune garantie que les opérations dans un thread seront exécutées dans l’ordre du
programme du moment que ce réarrangement n’est pas détecté dans ce thread – même
s’il est constaté par les autres1. Lorsque le thread principal écrit d’abord dans number,
puis dans ready sans synchronisation, le thread lecteur pourrait constater que les choses
se sont produites dans l’ordre inverse – ou pas du tout.
1. On pourrait penser qu’il s’agit d’une mauvaise conception, mais cela permet à la JVM de profiter
au maximum des performances des systèmes multiprocesseurs modernes. En l’absence de synchroni-
sation, par exemple, le modèle mémoire de Java permet au compilateur de réordonner les opérations
et de placer les valeurs en cache dans des registres ; il permet également aux CPU de réordonner les
opérations et de placer les valeurs dans des caches spécifiques du processeur. Le Chapitre 16 donnera
plus de détails.
Chapitre 3 Partage des objets 37
En l’absence de synchronisation, le compilateur, le processeur et l’environnement d’exécu-
tion peuvent s’amuser bizarrement avec l’ordre dans lequel les opérations semblent
s’exécuter. Essayer de trouver l’ordre selon lequel les opérations sur la mémoire "doivent"
se passer dans les programmes multithreads mal synchronisés sera presque certainement
voué à l’échec.
NoVisibility est presque aussi simple qu’un programme concurrent peut l’être – deux
threads et deux variables partagées –, mais on peut très bien ne pas savoir ce qu’il fait ni
même s’il se terminera. Réfléchir sur des programmes concurrents mal synchronisés est
vraiment très difficile.
Tout cela peut et devrait vous effrayer. Heureusement, il existe un moyen simple
d’éviter tous ces problèmes complexes : utilisez toujours une synchronisation correcte
à chaque fois que des données sont partagées par des threads.
3.1.1 Données obsolètes
NoVisibility a montré l’une des raisons pour lesquelles les programmes insuffisamment
synchronisés peuvent produire des résultats surprenants : les données obsolètes. Lorsque
le thread lecteur examine ready, il peut voir une valeur non à jour. À moins d’utiliser la
synchronisation à chaque fois que l’on accède à une variable, on peut lire une valeur
obsolète de cette variable. Pire encore, cette obsolescence ne fonctionne pas en tout ou
rien : un thread peut voir une valeur à jour pour une variable et une valeur obsolète pour
une autre, qui a pourtant été modifiée avant.
Quand une nourriture n’est plus fraîche, elle reste généralement comestible – elle est
juste moins bonne – mais les données obsolètes peuvent être plus dangereuses. Alors
qu’un compteur de visites obsolète pour une application web peut ne pas être trop
méchant1, les valeurs obsolètes peuvent provoquer de sévères problèmes de thread
safety ou de vivacité. Dans la classe NoVisibility, elles provoquaient l’affichage d’une
valeur erronée ou empêchait le programme de se terminer, mais la situation peut se
compliquer encore plus avec des valeurs obsolètes de références d’objets, comme les liens
dans une liste chaînée. Les données obsolètes peuvent provoquer de graves erreurs,
difficiles à comprendre, comme des exceptions inattendues, des structures de données
corrompues, des calculs imprécis et des boucles infinies.
La classe MutableInteger du Listing 3.2 n’est pas thread-safe car on accède au champ
value par get() et set() sans synchronisation. Parmi les autres risques qu’elle encourt,
1. Lire des données sans synchronisation est analogue à l’utilisation du niveau d’isolation READ
_UNCOMMITTED dans une base de données lorsque l’on veut sacrifier la précision aux performances.
Cependant, dans le cas de lectures non synchronisées, on sacrifie un plus grand degré de précision
puisque la valeur visible d’une variable partagée peut être arbitrairement obsolète.
38 Les bases Partie I
elle peut être victime des données obsolètes : si un thread appelle set(), les autres
threads appelant get() pourront, ou non, constater cette modification.
Nous pouvons rendre MutableInteger thread-safe en synchronisant les méthodes de
lecture et d’écriture comme dans la classe SynchronizedInteger du Listing 3.3. Ne
synchroniser que la méthode d’écriture ne serait pas suffisant : les threads appelant get()
pourraient toujours voir des valeurs obsolètes.
Listing 3.2 : Conteneur non thread-safe pour un entier modifiable.
@NotThreadSafe
public class MutableInteger {
private int value;
public int get() { return value; }
public void set(int value) { this.value = value; }
}
Listing 3.3 : Conteneur thread-safe pour un entier modifiable.
@ThreadSafe
public class SynchronizedInteger {
@GuardedBy("this") private int value;
public synchronized int get() { return value; }
public synchronized void set(int value) { this.value = value; }
}
3.1.2 Opérations 64 bits non atomiques
Un thread lisant une variable sans synchronisation peut obtenir une valeur obsolète
mais il lira au moins une valeur qui a été placée là par un autre thread, plutôt qu’une
valeur aléatoire. Cette garantie est appelée safety magique (out-of-thin-air safety).
La safety magique s’applique à toutes les variables, à une exception près : les variables
numériques sur 64 bits (double et long), qui ne sont pas déclarées volatile (voir la
section 3.1.4). En effet, le modèle mémoire de Java exige que les opérations de lecture
et d’écriture soient atomiques, or les variables long et double sur 64 bits sont lues ou
écrites à l’aide de deux opérations sur 32 bits. Si les lectures et les écritures ont lieu
dans des threads différents, il est donc possible de lire un long non volatile et d’obtenir
les 32 bits de poids fort d’une valeur et les 32 bits de poids faible d’une autre 1. Par
conséquent, même si vous ne vous souciez pas des valeurs obsolètes, il n’est pas
prudent d’utiliser des variables modifiables de type long ou double dans les program-
mes multithreads, sauf si elle sont déclarées volatile ou protégées par un verrou.
1. Lorsque la spécification de la machine virtuelle Java a été écrite, la plupart des processeurs du
marché ne disposaient pas d’opérations arithmétiques atomiques efficaces sur 64 bits.
Chapitre 3 Partage des objets 39
3.1.3 Verrous et visibilité
Comme le montre la Figure 3.1, le verrouillage interne peut servir à garantir qu’un
thread verra les effets d’un autre thread de façon prévisible. Lorsque le thread A exécute
un bloc synchronized et qu’ensuite le thread B entre dans un bloc synchronized
protégé par le même verrou, les valeurs des variables visibles par A avant de libérer le
verrou seront visibles par B lorsqu’il aura pris le verrou. En d’autres termes, tout ce
qu’a fait A avant ou dans un bloc synchronized est visible par B lorsqu’il exécute un
bloc synchronized protégé par le même verrou. Sans synchronisation, cette garantie
n’existe pas.
Thread A
y=1
verrouille M
x=1
Tout ce qui a eu lieu Thread B
avant le déverrouillage de M...
déverrouille M
... est visible après verrouille M
un verrouillage de M
i=x
déverrouille M
j=y
Figure 3.1
Visibilité garantie pour la synchronisation.
Nous pouvons maintenant donner l’autre raison de la règle qui exige que tous les
threads se synchronisent sur le même verrou lorsqu’ils accèdent à une variable partagée
modifiable – garantir que les valeurs écrites par un thread soient visibles par les autres.
Sinon un thread lisant une variable sans détenir le verrou approprié risque de voir une
valeur obsolète.
40 Les bases Partie I
Le verrouillage ne sert pas qu’à l’exclusion mutuelle ; il est également utilisé pour la
visibilité de la mémoire. Pour garantir que tous les threads voient les valeurs les plus
récentes des variables modifiables partagées, les threads de lecture et d’écriture doivent
se synchroniser sur le même verrou.
3.1.4 Variables volatiles
Le langage Java fournit également une alternative, une forme plus faible de synchroni-
sation : les variables volatiles. Celles-ci permettent de s’assurer que les modifications
apportées à une variable seront systématiquement répercutées à tous les autres threads.
Lorsqu’un champ est déclaré volatile, le compilateur et l’environnement d’exécution
sont prévenus que cette variable est partagée et que les opérations sur celle-ci ne doivent
pas être réarrangées avec d’autres opérations sur la mémoire. Les variables volatiles ne
sont pas placées dans des registres ou autres caches qui les masqueraient aux autres
processeurs ; la lecture d’une variable volatile renvoie donc toujours la dernière valeur
qui y a été écrite par un thread quelconque.
Un bon moyen de se représenter les variables volatiles consiste à imaginer qu’elles se
comportent à peu près comme la classe SynchronizedInteger du Listing 3.3, en
remplaçant les opérations de lecture et d’écriture sur la variable par des appels à get()
et set()1. Cependant, l’accès à une variable volatile n’utilise aucun verrouillage et ne
peut donc pas bloquer le thread qui s’exécute : c’est un mécanisme de synchronisation
plus léger que synchronized2.
La visibilité des variables volatiles va au-delà de la valeur de la variable. Lorsqu’un
thread A écrit dans une variable volatile et qu’un thread B la lit ensuite, les valeurs de
toutes les variables qui étaient visibles pour A avant d’écrire dans la variable volatile
deviennent visibles pour B après sa lecture de cette variable. Du point de vue de la visi-
bilité mémoire, l’écriture dans une variable volatile revient donc à sortir d’un bloc
synchronized et sa lecture revient à entrer dans un bloc synchronized. Cependant,
nous déconseillons de trop se fier aux variables volatiles pour la visibilité d’un état
quelconque ; le code qui utilise des variables volatiles dans ce but est plus fragile et plus
difficile à comprendre qu’un code qui utilise des verrous.
N’utilisez les variables volatiles que pour simplifier l’implémentation et la vérification de
votre politique de synchronisation ; évitez-les si la vérification du code exige des calculs
subtils sur la visibilité. Une bonne utilisation de ces variables consiste à assurer la visibi-
lité de leur propre état, celui auquel se réfèrent les objets, ou pour indiquer qu’un
événement important (comme une initialisation ou une fermeture) s’est passé.
1. Cette analogie n’est pas exacte car la visibilité mémoire de SynchronizedInteger est, en réalité,
un peu plus forte que celle des variables volatiles, comme on l’explique au Chapitre 16.
2. Sur la plupart des processeurs actuels, les lectures volatiles sont à peine un peu plus coûteuses que
les lectures non volatiles.
Chapitre 3 Partage des objets 41
Le Listing 3.4 illustre une utilisation typique des variables volatiles : le test d’un indicateur
pour savoir quand sortir d’une boucle. Ici, notre thread à l’apparence humaine essaie de
dormir après avoir compté des moutons. Pour que cet exemple fonctionne, l’indicateur
asleep doit être déclaré volatile ; sinon le thread pourrait ne pas remarquer qu’il a été
positionné par un autre thread1. Nous aurions pu utiliser à la place un verrou pour
garantir la visibilité des modifications apportées à asleep, mais le code serait alors
devenu plus lourd.
Listing 3.4 : Compter les moutons.
volatile boolean asleep;
...
while (!asleep)
countSomeSheep ();
Les variables volatiles sont pratiques, mais elles ont leurs limites. Le plus souvent, on les
utilise pour terminer ou interrompre une exécution, ou pour les indicateurs d’état comme
asleep dans le Listing 3.4. Elles peuvent être utilisées dans d’autres types d’opérations
sur l’état mais, en ce cas, il faut être plus prudent. La sémantique de volatile, par
exemple, n’est pas suffisamment forte pour rendre une incrémentation (comme count++)
atomique, sauf si vous pouvez garantir que la variable n’est modifiée que par un seul
thread (comme on l’explique au Chapitre 15, les variables atomiques permettent d’effec-
tuer des opérations lire-modifier-écrire et peuvent souvent être utilisées comme des
"meilleures variables volatiles").
Alors que les verrous peuvent garantir à la fois la visibilité et l’atomicité, les variables
volatiles ne peuvent assurer que la visibilité.
Vous ne pouvez utiliser des variables volatiles que lorsque tous les critères suivants sont
vérifiés :
m Les écritures dans la variable ne dépendent pas de sa valeur actuelle ou vous pouvez
garantir que sa valeur ne sera toujours modifiée que par un seul thread.
m La variable ne participe pas aux invariants avec d’autres variables d’état.
1. Pour les applications serveur, assurez-vous de toujours utiliser l’option –server de la JVM lorsque
vous l’appelez pendant les phases de développement et de tests. En mode serveur, la JVM effectue plus
d’optimisations qu’en mode client : elle extrait les invariants de boucle, par exemple. Un code qui peut
sembler fonctionner dans l’environnement de développement (client JVM) peut donc ne plus fonctionner
dans l’environnement de production (serveur JVM). Dans le Listing 3.4, si nous avions par exemple
"oublié" de déclarer la variable asleep comme volatile, le serveur JVM aurait pu sortir le test de la
boucle (qui serait donc devenue une boucle sans fin), alors que le client JVM ne l’aurait pas fait. Or une
boucle infinie apparaissant au cours du développement est bien moins coûteuse que si elle n’apparaissait
qu’à la mise en production.
42 Les bases Partie I
m Lorsque l’on accède à la variable, le verrouillage n’est pas nécessaire pour d’autres
raisons.
3.2 Publication et fuite
Publier un objet signifie le rendre disponible au code qui est en dehors de sa portée
courante ; stocker une référence vers lui où un autre code pourra le trouver, par exemple,
le renvoyer à partir d’une méthode non privée ou le passer à une méthode d’une autre
classe. Dans de nombreuses situations, on veut garantir que les objets et leurs détails
internes ne seront pas publiés. Dans d’autres, on veut publier un objet pour une utilisation
générale, mais le faire de façon thread-safe peut nécessiter une synchronisation. Publier
les variables de l’état interne d’un objet peut compromettre l’encapsulation et compli-
quer la préservation des invariants ; publier des objets avant qu’ils ne soient totalement
construits peut compromettre la thread safety. On dit qu’un objet qui est publié alors
qu’il n’aurait pas dû l’être s’est échappé. La section 3.5 présente les idiomes d’une
publication correcte mais, pour l’instant, nous allons étudier comment un objet peut
s’échapper.
La forme la plus évidente de la publication consiste à stocker une référence dans un
champ statique public, où n’importe quelle classe et n’importe quel thread peut la voir,
comme dans le Listing 3.5. Dans cet exemple, la méthode initialize() instancie un
nouvel objet HashSet et le publie en stockant sa référence dans knownSecrets.
Listing 3.5 : Publication d’un objet.
public static Set<Secret> knownSecrets;
public void initialize() {
knownSecrets = new HashSet<Secret>();
}
Publier un objet peut indirectement en publier d’autres. Si vous ajoutez un Secret à
l’ensemble knownSecrets qui est publié, vous avez également publié ce Secret puisque
n’importe quel code peut parcourir cet ensemble et obtenir une référence sur le nouveau
Secret. De même, renvoyer une référence à partir d’une méthode non privée publie
également l’objet renvoyé. Dans le Listing 3.6, la classe UnsafeStates publie le
tableau des abréviations des états américains, qui est pourtant privé.
Listing 3.6 : L’état modifiable interne à la classe peut s’échapper. Ne le faites pas.
class UnsafeStates {
private String[] states = new String[] {
"AK", "AL" ...
};
public String[] getStates() { return states; }
}
Chapitre 3 Partage des objets 43
Publier states de cette façon pose un problème puisque n’importe quel appelant peut
modifier son contenu. Ici, le tableau states s’est échappé de sa portée initiale car ce qui
était censé être un état privé a été rendu public.
Publier un objet publie également tous les objets qu’il référence dans ses champs non
privés. Plus généralement, tout objet accessible à partir d’un objet publié en suivant une
chaîne de champs références et d’appels de méthodes non privés est également publié.
Du point de vue d’une classe C, une méthode étrangère est une méthode dont le
comportement n’est pas totalement spécifié par C. Cela comprend donc les méthodes
des autres classes ainsi que les méthodes redéfinissables (donc ni private ni final) de
C elle-même. Passer un objet à une méthode étrangère doit également être considéré
comme une publication de cet objet. En effet, comme on ne peut pas savoir quel code
sera réellement appelé, on ne sait pas si la méthode étrangère ne publiera pas cet objet
ou si elle gardera une référence vers celui-ci, qui pourrait être utilisée plus tard à partir
d’un autre thread.
Qu’un autre thread utilise une référence publiée n’est pas vraiment le problème : ce qui
importe est qu’il existe un risque de mauvaise utilisation1. Une fois qu’un objet s’est
échappé, vous devez supposer qu’une autre classe ou un autre thread peut, volontaire-
ment ou non, le détourner. C’est un argument irréfutable en faveur de l’encapsulation
puisque celle-ci permet de tester si les programmes sont corrects et complique la violation
accidentelle des contraintes de conception.
Un dernier mécanisme par lequel un objet ou son état interne peuvent être publiés
consiste à publier une instance d’une classe interne, comme dans la classe ThisEscape du
Listing 3.7. Quand ThisEscape publie le EventListener, elle publie implicitement aussi
l’instance ThisEscape car les instances des classes internes contiennent une référence
cachée vers l’instance qui les englobe.
Listing 3.7 : Permet implicitement à la référence this de s’échapper. Ne le faites pas.
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener (
new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
}
}
1. Si quelqu’un vole votre mot de passe et le poste sur le forum alt.free-passwords, cette information
s’est échappée : que quelqu’un l’ait ou non utilisée pour vous causer du tort, votre compte a quand
même été compromis. Publier une référence expose au même risque.
44 Les bases Partie I
3.2.1 Pratiques de construction sûres
ThisEscape illustre un cas particulier important de la fuite d’un objet – lorsque la réfé-
rence this s’échappe lors de sa construction. Quand l’instance EventListener interne
est publiée, l’instance ThisEscape englobante l’est aussi. Mais un objet n’est dans un
état cohérent qu’après la fin de l’exécution de son constructeur : publier un objet à partir
de son constructeur peut donc publier un objet qui n’a pas été totalement construit et ceci
est vrai même si la publication s’effectue dans la dernière instruction du constructeur.
Si la référence this s’échappe au cours de la construction, l’objet est considéré comme
mal construit1.
Faites en sorte que la référence this ne s’échappe pas au cours de la construction.
Une erreur fréquente pouvant conduire à la fuite de this au cours de la construction
consiste à lancer un thread à partir du constructeur. Lorsqu’un objet crée un thread dans
son constructeur, il partage presque toujours sa référence this avec le nouveau thread,
soit explicitement (en le passant au constructeur du thread) soit implicitement (parce que
le Thread ou le Runnable est une classe interne de l’objet). Le nouveau thread pourrait
alors voir l’objet englobant avant que ce dernier ne soit totalement construit. Cela ne
pose aucun problème de créer un thread dans un constructeur, mais il est préférable
de ne pas lancer immédiatement ce thread : fournissez plutôt une méthode start() ou
initialize() qui permettra de le lancer (le Chapitre 7 détaillera les problèmes liés au
cycle de vie des services). L’appel d’une méthode d’instance redéfinissable (ni private ni
final) à partir du constructeur peut également donner à this l’occasion de s’échapper.
Si vous voulez enregistrer un récepteur d’événement ou lancer un thread à partir d’un
constructeur, vous pouvez vous en sortir en utilisant un constructeur privé et une
méthode fabrique publique, comme dans la classe SafeListener du Listing 3.8.
Listing 3.8 : Utilisation d’une méthode fabrique pour empêcher la référence this
de s’échapper au cours de la construction de l’objet.
public class SafeListener {
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}
1. Plus précisément, la référence this ne devrait pas s’échapper du thread avant la fin du constructeur.
Elle peut être stockée quelque part par le constructeur tant qu’elle n’est pas utilisée par un autre thread
jusqu’à la fin de la construction. La classe SafeListener du Listing 3.8 utilise cette technique.
Chapitre 3 Partage des objets 45
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener (safe.listener);
return safe;
}
}
3.3 Confinement des objets
L’accès à des données partagées et modifiables nécessite d’utiliser la synchronisation.
Un bon moyen d’éviter cette obligation consiste à ne pas partager : si on n’accède aux
données que par un seul thread, il n’y a pas besoin de synchronisation. Cette technique,
appelée confinement, est l’un des moyens les plus simples qui soient pour assurer la thread
safety. Lorsqu’un objet est confiné dans un thread, son utilisation sera automatiquement
thread-safe, même si l’objet confiné ne l’est pas lui-même [CPJ 2.3.2].
Swing utilise beaucoup le confinement. Ses composants visuels et les objets du modèle
de données ne sont pas thread-safe, mais on obtient cette propriété en les confinant au
thread des événements de Swing. Pour utiliser Swing correctement, le code qui s’exécute
dans les threads autres que celui des événements ne devrait pas accéder à ces objets
(pour faciliter cette pratique, Swing fournit le mécanisme invokeLater(), qui permet de
planifier l’exécution d’un objet Runnable dans le thread des événements). De nombreuses
erreurs de concurrence dans les applications Swing proviennent d’une mauvaise utilisation
de ces objets confinés à partir d’un autre thread.
Une autre application classique du confinement est l’utilisation des pools d’objets
Connection de JDBC (Java Database Connectivity). La spécification de JDBC n’exige pas
que les objets Connection soient thread-safe1. Dans les applications serveur classiques,
un thread prend une connexion dans le pool, l’utilise pour traiter une seule requête et la
retourne au pool. La plupart des requêtes, comme les requêtes de servlets ou les appels
EJB (Enterprise JavaBeans), étant appelées de façon synchrone par un unique thread et
le pool ne délivrant pas la même connexion à un autre thread tant qu’elle ne s’est pas
terminée, ce patron de gestion des connexions confine implicitement l’objet Connection
à ce thread pour la durée de la requête.
Tout comme le langage ne possède pas de mécanisme pour imposer qu’une variable soit
protégée par un verrou, il n’a aucun moyen de confiner un objet dans un thread. Le
confinement est un élément de conception du programme qui doit être imposé par son
implémentation. Le langage et les bibliothèques de base fournissent des mécanismes
permettant de faciliter la gestion de ce confinement – les variables locales et la classe
1. Les implémentations du pool de connexion fournies par les serveurs d’applications sont thread-
safe ; comme on accède nécessairement aux pools de connexion à partir de plusieurs threads, une
implémentation non thread-safe n’aurait aucun intérêt.
46 Les bases Partie I
ThreadLocal – mais il est quand même de la responsabilité du programmeur de s’assurer
que les objets confinés à un thread ne s’en échappent pas.
3.3.1 Confinement ad hoc
Le confinement ad hoc intervient lorsque la responsabilité de gérer le confinement
incombe entièrement à l’implémentation. Il peut donc être fragile car aucune des fonc-
tionnalités du langage, tels les modificateurs de visibilité des membres ou les variables
locales, ne facilite le confinement de l’objet au thread concerné. En fait, les références
à des objets confinés, comme les composants visuels ou les modèles de données dans
les applications graphiques, sont souvent contenues dans des champs publics.
La décision d’utiliser le confinement découle souvent de la décision d’implémenter un
sous-système particulier (une interface graphique, par exemple) comme un sous-système
monothread. Ces sous-systèmes ont parfois l’avantage de la simplicité, ce qui compense
la fragilité du confinement ad hoc1.
Un cas spécial de confinement concerne les variables volatiles. Vous pouvez sans problème
exécuter des opérations lire-modifier-écrire sur des variables volatiles partagées du
moment que vous garantissez que la variable volatile n’est écrite qu’à partir d’un seul
thread. En ce cas, vous confinez la modification dans un seul thread pour éviter les
situations de compétition, et les garanties de visibilité des variables volatiles assurent
que les autres threads verront la dernière valeur de la variable.
À cause de sa fragilité, le confinement ad hoc doit être utilisé avec parcimonie ; si
possible, préférez-lui plutôt une des formes plus fortes du confinement (confinement
dans la pile ou ThreadLocal).
3.3.2 Confinement dans la pile
Le confinement dans la pile est un cas spécial de confinement dans lequel on ne peut
accéder à un objet qu’au travers de variables locales. Tout comme l’encapsulation aide
à préserver les invariants, les variables locales permettent de simplifier le confinement
des objets à un thread. En effet, les variables locales sont intrinsèquement confinées au
thread en cours d’exécution ; elles n’existent que sur la pile de ce thread, qui n’est pas
accessible aux autres. Le confinement dans la pile (également appelé utilisation interne
au thread ou locale au thread et qu’il ne faut pas confondre avec la classe ThreadLocal de
la bibliothèque standard) est plus simple à maintenir et moins fragile que le confinement
ad hoc.
Dans le cas de variables locales de types primitifs, comme numPairs dans la méthode
loadTheArk() du Listing 3.9, il est impossible de violer le confinement sur la pile.
1. Une autre raison de rendre un sous-système monothread est d’éviter les interblocages. C’est
d’ailleurs l’une des principales raisons pour lesquelles les frameworks graphiques sont monothreads.
Les sous-systèmes monothreads seront présentés au Chapitre 9.
Chapitre 3 Partage des objets 47
Comme il n’existe aucun moyen d’obtenir une référence vers une variable de type
primitif, la sémantique du langage garantit que les variables locales de ce type seront
toujours confinées dans la pile.
Listing 3.9 : Confinement des variables locales, de types primitifs ou de types références.
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals est confiné dans la méthode, ne le laissez pas s’échapper!
animals = new TreeSet<Animal>(new SpeciesGenderComparator ());
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate (a))
candidate = a;
else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
Maintenir un confinement dans la pile pour des références d’objets nécessite un petit peu
plus de travail de la part du programmeur, afin de s’assurer que le référent ne s’échappe
pas. Dans loadTheArk(), on instancie un objet TreeSet et on stocke sa référence dans
animals. À ce stade, il n’existe qu’une seule référence vers l’ensemble, contenue dans une
variable locale et donc confinée au thread en cours d’exécution. Cependant, si l’on publiait
une référence à cet ensemble (ou à l’un de ses composants internes), le confinement
serait violé et les animaux pourraient s’échapper.
L’utilisation d’un objet non thread-safe dans un contexte "interne à un thread" est quand
même thread-safe. Cependant, vous devez être prudent : l’exigence que l’objet soit
confiné au thread ou le fait de savoir que l’objet confiné n’est pas thread-safe n’existe
souvent que dans la tête du développeur. Si l’hypothèse que l’utilisation est interne au
thread n’est pas clairement documentée, les développeurs ultérieurs du code pourraient,
par erreur, laisser l’objet s’échapper.
3.3.3 ThreadLocal
Un moyen plus formel de maintenir le confinement consiste à utiliser la classe Thread
Local, qui permet d’associer à un objet une valeur propre à un thread. ThreadLocal
fournit des méthodes accesseurs get() et set() qui maintiennent une copie distincte de
la valeur pour chaque thread qui l’utilise : un appel à get() renvoie donc la valeur la
plus récente passée à set() à partir du thread en cours d’exécution.
Les variables locales au thread servent souvent à empêcher le partage dans les conceptions
qui reposent sur des singletons modifiables ou sur des variables globales. Une application
48 Les bases Partie I
monothread, par exemple, pourrait gérer une connexion globale vers une base de
données, initialisée au démarrage, afin d’éviter de passer un objet Connection à chaque
appel de méthode. Les connexions JDBC pouvant ne pas être thread-safe, une application
multithread qui utilise une connexion globale sans coordination supplémentaire n’est
pas thread-safe non plus. En utilisant un objet ThreadLocal pour stocker cette connexion,
comme dans la classe ConnectionHolder du Listing 3.10, chaque thread disposera de
sa propre connexion.
Listing 3.10 : Utilisation de ThreadLocal pour garantir le confinement au thread.
private static ThreadLocal<Connection> connectionHolder
= new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection (DB_URL);
}
};
public static Connection getConnection() {
return connectionHolder.get();
}
Cette technique peut également être utilisée lorsqu’une opération fréquente a besoin
d’un objet temporaire comme un tampon et que l’on souhaite éviter de réallouer cet objet
temporaire à chaque appel. Avant Java 5.0, par exemple, Integer.toString() utilisait
un ThreadLocal pour stocker le tampon de 12 octets utilisé pour formater son résultat au
lieu d’utiliser un tampon statique partagé (qui aurait nécessité un verrou) ou d’allouer un
nouveau tampon à chaque appel1.
Lorsqu’un thread appelle ThreadLocal.get() pour la première fois, initialValue
est lue pour fournir la valeur initiale pour ce thread. Conceptuellement, vous pouvez
considérer qu’un ThreadLocal<T> contient un Map<Thread, T> qui stocke les valeurs
spécifiques au thread, bien qu’il ne soit pas implémenté de cette façon. Les valeurs spéci-
fiques au thread sont stockées dans l’objet Thread lui-même ; lorsqu’il se termine, ces
valeurs peuvent être supprimées par le ramasse-miettes.
Si vous portez une application monothread vers un environnement multithread, vous
pouvez préserver la thread safety en convertissant les variables globales en objets Thread
Local si la sémantique de ces variables le permet ; un cache au niveau de l’application ne
serait pas aussi utile s’il était transformé en plusieurs caches locaux aux threads.
Les implémentations des frameworks applicatifs font largement appel à ThreadLocal.
Les conteneurs J2EE, par exemple, associent un contexte de transaction à un thread
1. Cette technique n’apporte probablement pas un gain en terme de performances, sauf si l’opération
est exécutée très souvent ou que l’allocation est exagérément coûteuse. En Java 5.0, elle a été remplacée
par l’approche plus évidente qui consiste à allouer un nouveau tampon à chaque appel, ce qui semble
indiquer que, pour quelque chose d’aussi banal qu’un tampon temporaire, cela ne permettait pas
d’améliorer les performances.
Chapitre 3 Partage des objets 49
d’exécution pour la durée d’un appel EJB, ce qui peut aisément s’implémenter à l’aide
d’un ThreadLocal contenant le contexte de la transaction : lorsque le code du framework
a besoin de savoir quelle est la transaction qui s’exécute, il récupère le contexte à partir
de ce ThreadLocal. C’est pratique puisque cela réduit le besoin de passer les informa-
tions sur le contexte d’exécution à chaque méthode, mais cela lie au framework tout
code utilisant ce mécanisme.
Il est assez simple d’abuser de ThreadLocal en considérant sa propriété de confinement
comme un laisser-passer pour utiliser des variables globales ou comme un moyen de
créer des paramètres de méthodes "cachés". Comme les variables globales, les variables
locales aux threads peuvent contrarier la réutilisabilité du code et introduire des liens
cachés entre les classes ; pour toutes ces raisons, elles doivent être utilisées avec
discernement.
3.4 Objets non modifiables
L’autre moyen d’éviter la synchronisation consiste à utiliser des objets non modifiables
[EJ Item 13]. Quasiment tous les risques concernant l’atomicité et la visibilité que nous
avons décrits, comme la récupération de valeurs obsolètes, la perte de mises à jour ou
l’observation d’un objet dans un état incohérent, sont liés au fait que plusieurs threads
tentent d’accéder simultanément au même état modifiable. Si l’état d’un objet ne peut
pas être modifié, tous ces risques et ces complications disparaissent.
Un objet non modifiable est un objet dont l’état ne peut pas être modifié après sa
construction. Par essence, les objets non modifiables sont thread-safe ; leurs invariants sont
établis par le constructeur et, si leur état ne peut pas être modifié, ces invariants seront
toujours vérifiés.
Les objets non modifiables sont toujours thread-safe.
Les objets non modifiables sont simples. Ils ne peuvent être que dans un seul état, qui est
soigneusement contrôlé par le constructeur. L’une des parties les plus difficiles de la
conception d’un programme consiste à prendre en compte tous les états possibles des
objets complexes ; pour l’état des objets non modifiables, cette étape est triviale.
Les objets non modifiables sont également plus sûrs. Il est dangereux de passer un objet
modifiable à un code non vérifié, ou de le publier à un endroit où un code suspect peut
le trouver – ce code pourrait modifier son état ou, pire, garder une référence vers lui et
modifier son état plus tard, à partir d’un autre thread. Les objets non modifiables, en
revanche, ne peuvent pas être altérés de cette manière par du code malicieux ou bogué
et peuvent donc être partagés en toute sécurité ou publiés librement, sans qu’il y ait
besoin de créer des copies défensives [EJ Item 24].
50 Les bases Partie I
Ni la spécification du langage Java ni le modèle mémoire de Java ne définissent formel-
lement cette "immuabilité", mais elle n’est pas équivalente à simplement déclarer tous
les champs d’un objet comme final. Un objet ayant cette caractéristique pourrait
quand même être modifiable puisque les champs final peuvent contenir des références
vers des objets modifiables. 1
Un objet est non modifiable si :
• son état ne peut pas être modifié après sa construction ;
• tous ses champs sont final 1 ;
• il est correctement construit (sa référence this ne s’échappe pas au cours de la
construction).
En interne, les objets non modifiables peuvent quand même utiliser des objets modifiables
pour gérer leur état, comme l’illustre la classe ThreeStooges du Listing 3.11. Bien que
le Set qui stocke les noms soit modifiable, la conception de ThreeStooges rend impos-
sible la modification de cet ensemble après la construction. La référence stooges étant
final, on accède à tout l’état de l’objet via un champ final. La dernière exigence, une
construction correcte, est aisément vérifiée puisque le constructeur ne fait rien qui
pourrait faire que la référence this devienne accessible à un code autre que celui du
constructeur et de celui qui l’appelle.
Listing 3.11 : Classe non modifiable construite à partir d’objets modifiables sous-jacents.
@Immutable
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<String>();
public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
}
1. Techniquement, tous les champs d’un objet non modifiable peuvent ne pas être final – String en
est un exemple –, mais cela implique de prendre en compte les situations de compétition bénignes, ce
qui nécessite une très bonne compréhension du modèle mémoire de Java. Pour les curieux, String
effectue un calcul paresseux du code de hachage lors du premier appel de hashCode() et place le résultat
en cache dans un champ non final ; cela ne fonctionne que parce que ce champ ne peut prendre
qu’une seule valeur, qui sera la même à chaque calcul puisqu’elle est déterminée à partir d’un état qui
ne peut pas être modifié. N’essayez pas de faire la même chose.
Chapitre 3 Partage des objets 51
L’état d’un programme changeant constamment, on pourrait être tenté de croire que les
objets non modifiables ont peu d’intérêt, mais ce n’est pas le cas. Il y a une différence
entre un objet non modifiable et une référence non modifiable vers celui-ci. Lorsque
l’état d’un programme est stocké dans des objets modifiables, ceux-ci peuvent quand
même être "remplacés" par une nouvelle instance contenant le nouvel état ; la section
suivante donne un exemple de cette technique1.
3.4.1 Champs final
Le mot-clé final, une version limitée du mécanisme const de C++, permet de construire
des objets non modifiables. Les champs final ne peuvent pas être modifiés (bien que les
objets auxquels ils font référence puissent l’être), mais ils ont également une sémantique
spéciale dans le modèle mémoire de Java. C’est l’utilisation des champs final qui rend
possible la garantie d’une initialisation sûre (voir la section 3.5.2) qui permet d’accéder
aux objets non modifiables et de les partager sans synchronisation.
Même si un objet est modifiable, rendre certains de ses champs final peut quand même
simplifier la compréhension de son état puisqu’en limitant la possibilité de modification
d’un objet on restreint également l’ensemble de ses états possibles. Un objet qui est
"presque entièrement modifiable" mais qui possède une ou deux variables d’états modi-
fiables est plus simple qu’un objet qui a de nombreuses variables modifiables. Déclarer
des champs final permet également d’indiquer aux développeurs qui maintiennent le
code que ces champs ne sont pas censés être modifiés.
Tout comme il est conseillé de rendre tous les champs privés, sauf s’ils ont besoin d’une
visibilité supérieure [EJ Item 12], il est conseillé de rendre tous les champs final, sauf s’il
doivent pouvoir être modifiés.
3.4.2 Exemple : utilisation de volatile pour publier des objets
non modifiables
Dans la classe UnsafeCachingFactorizer du Listing 2.5, nous avons essayé d’utiliser
deux AtomicReferences pour stocker le dernier nombre factorisé et les derniers facteurs
trouvés, mais ce n’était pas thread-safe puisque nous ne pouvions pas obtenir ou modifier
ces deux valeurs de façon atomique. Pour la même raison, l’utilisation de variables
volatiles pour ces valeurs ne serait pas thread-safe non plus. Cependant, les objets
non modifiables peuvent parfois fournir une forme faible de l’atomicité. La servlet de
1. De nombreux développeurs craignent que cette approche pose des problèmes de performance, mais
ces craintes sont généralement non justifiées. L’allocation est moins coûteuse qu’on ne le croit et les
objets non modifiables offrent des avantages supplémentaires en termes de performances, comme la
réduction du besoin de verrous ou de copies défensives, ainsi qu’un impact réduit sur le ramasse-miettes
générationnel.
52 Les bases Partie I
factorisation effectue deux opérations qui doivent être atomiques : la mise à jour du
résultat en cache et, éventuellement, la récupération des facteurs en cache si le nombre
en cache correspond au nombre soumis à la requête. À chaque fois qu’un groupe de
données liées les unes aux autres doit être manipulé de façon atomique, pensez à créer
une classe non modifiable pour les regrouper, comme OneValueCache1 du Listing 3.12.
Listing 3.12 : Conteneur non modifiable pour mettre en cache un nombre et ses facteurs.
@Immutable
class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BigInteger i, BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copyOf(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
Les situations de compétition lors de l’accès ou de la modification de plusieurs variables
liées les unes aux autres peuvent être éliminées en utilisant un objet non modifiable pour
contenir toutes ces variables. Si cet objet était modifiable, il faudrait utiliser des verrous
pour garantir l’atomicité ; avec un objet non modifiable, un thread qui prend une réfé-
rence sur cet objet n’a pas besoin de se soucier si un autre thread modifiera son état. Si
les variables doivent être modifiées, on crée un nouvel objet mais les éventuels threads
qui manipulent l’ancien objet le verront quand même dans un état cohérent.
La classe VolatileCachedFactorizer du Listing 3.13 utilise un objet OneValueCache
pour stocker le nombre et les facteurs en cache. Lorsqu’un thread initialise le champ
cache volatile pour qu’il contienne une référence à un nouvel objet OneValueCache, les
nouvelles valeurs en cache deviennent immédiatement visibles aux autres threads.
Listing 3.13 : Mise en cache du dernier résultat à l’aide d’une référence volatile vers
un objet conteneur non modifiable.
@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
private volatile OneValueCache cache = new OneValueCache (null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
1. OneValueCache ne serait pas non modifiable sans les appels à copyOf() dans le constructeur et
la méthode d’accès. Arrays.copyOf() n’existe que depuis Java 6 mais clone() devrait également
fonctionner.
Chapitre 3 Partage des objets 53
if (factors == null) {
factors = factor(i);
cache = new OneValueCache (i, factors);
}
encodeIntoResponse (resp, factors);
}
}
Les opérations liées au cache ne peuvent pas interférer les unes avec les autres car
OneValueCache n’est pas modifiable et parce qu’on accède au champ cache qu’une seule
fois dans chaque partie du code. Cette combinaison d’un objet conteneur non modifiable
pour plusieurs variables d’état liées par un invariant et d’une référence volatile pour
assurer sa visibilité rend VolatileCachedFactorizer thread-safe, bien qu’elle n’utilise
pas de verrouillage explicite.
3.5 Publication sûre
Pour l’instant, nous avons fait en sorte de garantir qu’un objet ne sera pas publié
lorsqu’il est censé être confiné à un thread ou interne à un autre objet. Cependant, on
veut parfois partager des objets entre les threads et, dans ce cas, il faut le faire en toute
sécurité. Malheureusement, se contenter de stocker une référence dans un champ public,
comme dans le Listing 3.14, ne suffit pas à publier cet objet de façon satisfaisante.
Listing 3.14 : Publication d’un objet sans synchronisation appropriée. Ne le faites pas.
// Publication non sûre
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
Vous pourriez être surpris des conséquences de cet exemple qui semble pourtant anodin.
À cause des problèmes de visibilité, l’objet Holder pourrait apparaître à un autre thread
sous un état incohérent, bien que ses invariants aient été correctement établis par son
constructeur ! Cette publication incorrecte pourrait permettre à un autre thread d’observer
un objet partiellement construit.
3.5.1 Publication incorrecte : quand les bons objets deviennent mauvais
Vous ne pouvez pas vous fier à des objets qui n’ont été que partiellement construits. Un
thread pourrait voir l’objet dans un état incohérent et voir plus tard son état changer
brusquement, bien qu’il n’ait pas été modifié depuis sa publication. En fait, si le Holder
du Listing 3.15 est publié selon la publication incorrecte du Listing 3.14 et qu’un thread
autre que celui qui publie appelle assertSanity(), celle-ci pourrait lever l’exception
AssertionError1 !
1. Le problème, ici, est non pas la classe Holder elle-même, mais le fait que l’objet Holder ne soit pas
correctement publié. Cependant, on pourrait immuniser Holder contre une publication incorrecte en
déclarant le champ n final, ce qui rendrait Holder non modifiable (voir la section 3.5.2).
54 Les bases Partie I
Listing 3.15 : Classe risquant un problème si elle n’est pas correctement publiée.
public class Holder {
private int n;
public Holder(int n) { this.n = n; }
public void assertSanity() {
if (n != n)
throw new AssertionError("This statement is false.");
}
}
Comme on n’a pas utilisé de synchronisation pour rendre Holder visible aux autres
threads, Holder n’a pas été correctement publiée. Il y a deux choses qui peuvent mal se
passer avec les objets mal publiés. Les autres threads pourraient voir une valeur obsolète
dans le champ holder et donc une référence null ou une autre valeur ancienne, bien
qu’une valeur ait été placée dans holder ; et, ce qui est bien pire, les autres threads
pourraient voir une valeur à jour pour la référence à holder, mais des valeurs obsolètes
pour l’état de l’objet Holder1. Pour rendre les choses encore moins prévisibles, un
thread peut voir une valeur obsolète la première fois qu’il lit un champ, puis une valeur
plus à jour lors de la lecture suivante, ce qui explique pourquoi assertSanity() peut
lever AssertionError.
Au risque de nous répéter, des phénomènes très étranges peuvent survenir lorsque des
données sont partagées entre des threads sans synchronisation appropriée.
3.5.2 Objets non modifiables et initialisation sûre
Les objets non modifiables ayant tant d’importance, le modèle mémoire de Java garantit
une initialisation sûre pour les partager. Comme nous l’avons vu, le fait qu’une réfé-
rence d’objet devienne visible pour un autre thread ne signifie pas nécessairement que
l’état de cet objet sera visible au thread client. Pour garantir une vue cohérente de l’état
de l’objet, il faut utiliser la synchronisation.
On peut en revanche accéder en toute sécurité à un objet non modifiable même si l’on
n’utilise pas de synchronisation pour publier sa référence. Pour que cette garantie
d’initialisation sûre puisse s’appliquer, il faut que toutes les exigences portant sur les
objets non modifiables soient vérifiées : état non modifiable, tous les champs déclarés
comme final et construction correcte (si la classe Holder du Listing avait été non
modifiable, assertSanity() n’aurait pas pu lancer AssertionError, même si l’objet
Holder était publié incorrectement).
1. Bien qu’il puisse sembler que les valeurs des champs initialisées dans un constructeur soient les
premières écrites dans ces champs et qu’il n’y ait donc pas de valeurs "anciennes" qui puissent être
considérées comme des valeurs obsolètes, le constructeur de Object initialise d’abord tous les champs
avec des valeurs par défaut avant que les constructeurs des sous-classes ne s’exécutent. Il est donc
possible de voir la valeur par défaut d’un champ comme valeur obsolète.
Chapitre 3 Partage des objets 55
Les objets non modifiables peuvent être utilisés en toute sécurité par n’importe quel
thread sans synchronisation supplémentaire, même si l’on n’a pas utilisé de synchronisation
pour les publier.
Cette garantie s’étend aux valeurs de tous les champs final des objets correctement
construits ; on peut accéder à ces champs en toute sécurité sans synchronisation supplé-
mentaire. Cependant, si ces champs font référence à des objets modifiables, une
synchronisation sera quand même nécessaire pour accéder à l’état des objets référencés.
3.5.3 Idiomes de publication correcte
Les objets modifiables doivent être publiés correctement, ce qui implique généralement
une synchronisation entre le thread qui publie et le thread qui consomme. Pour l’instant,
attachons-nous à vérifier que le thread consommateur puisse voir l’objet dans l’état où il
est publié ; nous nous occuperons plus tard de la visibilité des modifications apportées
après la publication.
Pour publier un objet correctement, il faut rendre visibles simultanément aux autres
threads à la fois la référence à cet objet et son état. Un objet correctement construit
peut être publié de façon sûre en respectant l’une des conditions suivantes :
• initialiser une référence d’objet à l’aide d’un initialisateur statique ;
• stocker une référence à cet objet dans un champ volatile ou de type Atomic
Reference ;
• stocker une référence à cet objet dans un champ final d’un objet correctement
construit ;
• stocker une référence à cet objet dans un champ protégé par un verrou.
Avec la synchronisation interne des collections thread-safe, comme Vector ou
synchronizedList, le placement d’un objet dans ces collections vérifie la dernière de
ces conditions. Si le thread A place l’objet X dans une collection thread-safe et que le
thread B le récupère ensuite, il est garanti que B verra l’état de X tel que A l’a laissé,
même si le code qui manipule X n’utilise pas de synchronisation explicite. Les collec-
tions thread-safe offrent les garanties de publication correcte suivantes, même si la
documentation Javadoc est peu claire sur ce sujet :
m Le placement d’une clé ou d’une valeur dans un objet Hashtable, synchronizedMap
ou ConcurrentMap est publié correctement pour tout thread qui la récupère dans la
Map (que ce soit directement ou via un itérateur).
m Le placement d’un élément dans un objet Vector, CopyOnWriteArrayList, CopyOn
WriteArraySet, synchronizedList ou synchronizedSet le publie correctement
pour tout thread qui le récupère dans la collection.
56 Les bases Partie I
m Le placement d’un élément dans un objet BlockingQueue ou ConcurrentLinkedQueue
le publie correctement pour tout thread qui le récupère de la file.
D’autres mécanismes de la bibliothèque des classes (comme Future et Exchanger)
constituent également une publication correcte ; nous les identifierons comme tels lorsque
nous les présenterons.
L’utilisation d’un initialisateur statique est souvent le moyen le plus simple et le plus
sûr de publier des objets pouvant être construits de façon statique :
public static Holder holder = new Holder(42);
Les initialisateurs statiques sont exécutés par la JVM au moment où la classe est initialisée ;
à cause de la synchronisation interne de la JVM, ce mécanisme garantit un publication
correcte de tous les objets initialisés de cette manière [JLS 12.4.2].
3.5.4 Objets non modifiables dans les faits
Une publication correcte suffit pour que d’autres threads accèdent en toute sécurité aux
objets qui ne seront pas modifiés sans synchronisation supplémentaire après leur publi-
cation. Les mécanismes de publication sûre garantissent tous que l’état publié d’un
objet sera visible à tous les threads qui y accèdent dès que sa référence est visible ; si cet
état n’est pas amené à changer, cela suffit à assurer que tout accès à cet objet est thread-
safe.
Les objets qui, techniquement, sont modifiables mais dont l’état ne sera pas modifié
après publication sont appelés objets non modifiables dans les faits. Ils n’ont pas besoin
de respecter la définition stricte des objets non modifiables de la section 3.4 ; il suffit
qu’ils soient traités par le programme comme s’ils n’étaient pas modifiables après leur
publication. L’utilisation de ce type d’objet permet de simplifier le développement et
d’améliorer les performances car cela réduit les besoins de synchronisation.
Les objets non modifiables dans les faits qui ont été correctement publiés peuvent être
utilisés en toute sécurité par n’importe quel thread sans synchronisation supplémentaire.
Les objets Date, par exemple, sont modifiables1 mais, si vous les utilisez comme s’ils ne
l’étaient pas, vous pouvez vous passer du verrouillage qui serait sinon nécessaire pour
partager une date entre plusieurs threads. Supposons que vous vouliez utiliser un Map
pour stocker la dernière date de connexion de chaque utilisateur :
public Map<String, Date> lastLogin =
Collections.synchronizedMap (new HashMap<String, Date>());
1. Ce qui est sûrement une erreur de conception.
Chapitre 3 Partage des objets 57
Si les valeurs Date ne sont pas modifiées après avoir été placées dans le Map, la synchroni-
sation de synchronizedMap suffit pour les publier correctement et aucune synchronisation
supplémentaire n’est nécessaire lorsqu’on y accède.
3.5.5 Objets modifiables
Si un objet est susceptible d’être modifié après sa construction, une publication sûre ne
peut garantir la visibilité de son état tel qu’il a été publié. Pour garantir la visibilité des
modifications ultérieures, il faut utiliser la synchronisation non seulement pour publier
un objet modifiable, mais également à chaque fois qu’on y accède. Pour partager des
objets modifiables en toute sécurité, ceux-ci doivent avoir été publiés de façon sûre et
être thread-safe ou protégés par un verrou.
Les exigences de publication d’un objet dépendent du fait qu’il soit modifiable ou non :
• Les objets non modifiables peuvent être publiés par n’importe quel mécanisme.
• Les objets non modifiables dans les faits doivent être publiés de façon sûre.
• Les objets modifiables doivent être publiés de façon sûre et être thread-safe ou
protégés par un verrou.
3.5.6 Partage d’objets de façon sûre
À chaque fois que l’on obtient une référence à un objet, il faut savoir ce que l’on a le
droit d’en faire. Faut-il poser un verrou avant de l’utiliser ? Est-on autorisé à modifier
son état ou peut-on seulement le lire ? De nombreuses erreurs de concurrence provien-
nent d’une mauvaise compréhension de ces "règles de bonne conduite" avec un objet
partagé. Lorsque l’on publie un objet, il faut également indiquer comment on peut y
accéder.
Les politiques les plus utiles pour l’utilisation et le partage des objets dans un programme
concurrent sont :
• Confinement au thread. Un objet confiné à un thread n’appartient et n’est confiné
qu’à un seul thread ; il peut être modifié par le thread qui le détient.
• Partage en lecture seule. Plusieurs threads peuvent accéder simultanément à un objet
partagé en lecture seule sans synchronisation supplémentaire, mais cet objet ne peut
être modifié par aucun thread. Ces objets comprennent les objets non modifiables et
non modifiables dans les faits.
• Partage thread-safe. Un objet thread-safe effectuant une synchronisation interne,
plusieurs threads peuvent y accéder via son interface publique sans synchronisation
supplémentaire.
• Protection par verrou. On ne peut accéder à un objet protégé par un verrou qu’en
prenant le contrôle d’un verrou précis. Ces objets comprennent ceux qui sont encap-
sulés dans d’autres objets thread-safe et les objets publiés protégés par un verrou.
4
Composition d’objets
Pour l’instant, nous n’avons traité que des aspects de bas niveau de la sécurité par rapport
aux threads et de la synchronisation. Cependant, nous ne voulons pas devoir analyser
chaque accès mémoire pour garantir que notre programme est thread-safe ; nous voulons
pouvoir combiner des composants thread-safe pour créer des composants plus gros ou
des programmes thread-safe. Ce chapitre présente donc des patrons de structuration de
classes facilitant la création de classes thread-safe qui pourront être maintenues sans
risquer de saboter les garanties qu’elles offrent vis-à-vis des threads.
4.1 Conception d’une classe thread-safe
Bien qu’il soit possible d’écrire un programme thread-safe qui stocke tout son état dans
des champs statiques publics, vérifier la thread safety d’un tel programme (ou le modifier
pour qu’il reste thread-safe) est bien plus difficile qu’avec un programme qui utilise
correctement l’encapsulation. Cette dernière permet en effet de déterminer qu’une classe
est thread-safe sans avoir besoin d’examiner tout le programme.
Le processus de conception d’une classe thread-safe devrait contenir ces trois éléments
de base :
• identification des variables qui forment l’état de l’objet ;
• identification des invariants qui imposent des contraintes aux variables de l’état ;
• mise en place d’une politique de gestion des accès concurrents à l’état de l’objet.
L’état d’un objet commence avec ses champs. S’ils sont tous de type primitif, les champs
contiennent l’intégralité de l’état. La classe Counter du Listing 4.1 n’ayant qu’un seul
champ, son état est donc entièrement défini par la valeur de ce champ. L’état d’un objet
possédant n champs de types primitifs est simplement un n-uplet des valeurs de ces
60 Les bases Partie I
champs ; l’état d’un point en deux dimensions est formé des valeurs de ses coordonnées
(x, y). Si certains champs d’un objet sont des références vers d’autres objets, l’état
comprend également les champs des objets référencés. L’état d’un objet LinkedList,
par exemple, inclut les états de tous les objets appartenant à la liste.
Listing 4.1 : Compteur mono-thread utilisant le patron moniteur de Java.
@ThreadSafe
public final class Counter {
@GuardedBy("this") private long value = 0;
public synchronized long getValue() {
return value;
}
public synchronized long increment() {
if (value == Long.MAX_VALUE)
throw new IllegalStateException ("counter overflow");
return ++value;
}
}
C’est la politique de synchronisation qui définit comment un objet coordonne l’accès à
son état sans violer ses invariants ou ses postconditions. Elle précise la combinaison
d’immuabilité, de confinement et de verrouillage qu’il faut utiliser pour maintenir la
thread safety et indique quelles variables sont protégées par quels verrous. Pour être sûr
que la classe puisse être analysée et maintenue, vous devez documenter la politique de
synchronisation utilisée.
4.1.1 Exigences de synchronisation
Créer une classe thread-safe implique de s’assurer que ses invariants sont vérifiés lors
des accès concurrents, ce qui signifie qu’il faut tenir compte de son état. Les objets et
les variables ont un espace d’état, l’ensemble des états possibles qu’ils peuvent prendre,
et plus cet espace est réduit, plus il est facile de l’appréhender. En utilisant des champs
final à chaque fois que cela est possible, on simplifie l’analyse des états possibles d’un
objet (dans le cas extrême, les objets non modifiables ne peuvent être que dans un seul
état).
De nombreuses classes ont des invariants qui considèrent certains états comme valides
ou invalides. Le champ value de Counter étant un long, par exemple, l’espace d’état
pourrait aller de Long.MIN_VALUE à Long.MAX_VALUE, mais Counter ajoute une contrainte :
value ne peut pas être négatif.
De même, les opérations peuvent avoir des postconditions qui considèrent certaines
transitions d’état comme incorrectes. Si l’état courant d’un Counter vaut 17, par exemple,
le seul état suivant correct est 18. Lorsque l’état suivant est calculé à partir de l’état
courant, l’opération est nécessairement composée. Toutes les opérations n’imposent
pas de contraintes sur les transitions d’états ; lorsque l’on met à jour une variable qui
Chapitre 4 Composition d’objets 61
contient la température courante, par exemple, son état précédent n’a pas d’influence
sur le calcul.
Les contraintes placées sur les états ou les transitions d’états par les invariants et les
postconditions créent des besoins supplémentaires de synchronisation ou d’encapsulation.
Si certains états sont invalides, alors, les variables d’état sous-jacentes doivent être
encapsulées ; sinon le code client pourrait placer l’objet dans un état invalide. Si une opéra-
tion comprend des transitions d’état invalides, elle doit être atomique. En revanche, si la
classe n’impose aucune de ces contraintes, vous pouvez assouplir les besoins d’encap-
sulation ou de sérialisation, afin de bénéficier de plus de flexibilité ou de meilleures
performances.
Une classe peut également posséder des invariants imposant des contraintes sur plusieurs
variables d’état. Une classe d’intervalle numérique, comme NumberRange dans le
Listing 4.10, utilise généralement des variables d’état pour les limites inférieure et supé-
rieure de l’intervalle, et ces variables doivent obéir à la contrainte précisant que la limite
inférieure doit être inférieure ou égale à la limite supérieure. Des invariants multivariables
comme celui-ci exigent l’atomicité : les variables liées entre elles doivent être lues ou
modifiées en une seule opération atomique. Vous ne pouvez pas en modifier une, libérer
et reprendre le verrou, puis modifier les autres car l’objet pourrait être dans un état inva-
lide lorsque le verrou est libéré. Lorsque plusieurs variables participent à un invariant,
le verrou qui les protège doit être maintenu pendant toute la durée des opérations qui
accèdent à ces variables.
Vous ne pouvez pas garantir la thread safety sans comprendre les invariants et les post-
conditions d’un objet. Les contraintes sur les valeurs ou les transitions d’état autorisées
pour les variables d’état peuvent exiger l’atomicité et l’encapsulation.
4.1.2 Opérations dépendantes de l’état
Les invariants de classe et les postconditions des méthodes imposent des contraintes
aux états et aux transitions d’état admis pour un objet. Certains objets possèdent également
des méthodes imposant des préconditions à leur état. Vous ne pouvez pas, par exemple,
supprimer un élément d’une file vide ; une file doit être dans l’état "non vide" avant de
pouvoir y ôter un élément. Les opérations disposant de telles préconditions sont dites
dépendantes de l’état[CPJ 3].
Dans un programme monothread, lorsqu’une précondition n’est pas vérifiée, l’opération
n’a pas d’autre choix que d’échouer. Dans un programme concurrent, en revanche, la
précondition peut devenir vraie plus tard, par suite de l’action d’un autre thread. Les
programmes concurrents ajoutent donc la possibilité d’attendre qu’une précondition
soit vérifiée avant d’effectuer l’opération.
62 Les bases Partie I
Les mécanismes intégrés permettant d’attendre efficacement qu’une condition soit vérifiée
– wait() et notify() – sont intimement liés au verrouillage interne et peuvent être
difficiles à utiliser correctement. Pour créer des opérations qui attendent qu’une pré-
condition devienne vraie avant de continuer, il est souvent plus simple d’utiliser des
classes existantes de la bibliothèque standard, comme les files d’attente ou les sémaphores,
afin d’obtenir le comportement souhaité. Les classes bloquantes de la bibliothèque, comme
BlockingQueue, Semaphore et les autres synchronisateurs, sont présentées au Chapitre 5 ;
la création de classes dépendantes de l’état utilisant les mécanismes de bas niveau fournis
par la plate-forme et la bibliothèque de classes est présentée au Chapitre 14.
4.1.3 Appartenance de l’état
Dans la section 4.1, nous avons laissé entendre que l’état d’un objet pouvait être un
sous-ensemble des champs apparaissant dans le graphe des objets ayant cet objet pour
racine. Pourquoi un sous-ensemble ? Sous quelles conditions les champs accessibles à
partir d’un objet donné ne font pas partie de l’état de celui-ci ? Lorsque l’on précise les
variables qui forment l’état d’un objet, on souhaite ne prendre en compte que les données
appartenant à cet objet. L’appartenance est non pas une notion explicite du langage,
mais un élément de la conception des classes. Lorsque l’on alloue et remplit un HashMap,
par exemple, on crée plusieurs objets : l’objet HashMap, un certain nombre d’objets Map
.Entry utilisés par l’implémentation de HashMap et, éventuellement, d’autres objets inter-
nes. L’état logique d’un HashMap inclut l’état de tous ses Map.Entry et des objets internes,
bien qu’ils soient implémentés comme des objets distincts.
Pour le meilleur ou pour le pire, le ramasse-miettes nous évite de devoir trop nous
préoccuper de l’appartenance. En C++, lorsque l’on passe un objet à une méthode, il faut
savoir si l’on transfère la propriété, si l’on met en place une location à court terme ou si
l’on envisage une copropriété à long terme. En Java, bien que tous ces modèles d’appar-
tenance soient possibles, le ramasse-miettes réduit le coût des nombreuses erreurs lors
du partage des références, ce qui nous permet de rester un peu flou sur la question de
l’appartenance.
Dans de nombreux cas, l’appartenance et l’encapsulation sont liées – l’objet encapsule
l’état qu’il possède et l’état qu’il encapsule lui appartient. C’est le propriétaire d’une
variable d’état donnée qui doit décider du protocole de verrouillage utilisé pour maintenir
l’intégrité de cette variable. L’appartenance implique le contrôle mais, une fois que nous
avons publié une référence vers un objet modifiable, nous n’avons plus le contrôle
exclusif de cet objet ; au mieux pouvons-nous avoir une "propriété partagée". Une classe
ne possède généralement pas les objets qui sont passés à ses méthodes ou à ses construc-
teurs, sauf si la méthode a été conçue pour transférer explicitement la propriété des
objets qui lui sont passés (c’est le cas, par exemple, des méthodes fabriques de collections
synchronisées).
Chapitre 4 Composition d’objets 63
Les classes Collection utilisent souvent une forme de "propriété divisée" dans laquelle
la collection possède l’état de l’infrastructure de collection alors que le code client
possède les objets stockés dans la collection. C’est le cas, par exemple, de la classe
ServletContext du framework des servlets. Cette dernière fournit aux servlets un objet
conteneur assimilé à un Map, dans lequel elles peuvent enregistrer et récupérer des
objets de niveau application par leurs noms à l’aide des méthodes setAttribute() et
getAttribute(). L’objet ServletContext implémenté par le conteneur de servlets doit
être thread-safe car plusieurs threads y accéderont nécessairement. Les servlets n’ont pas
besoin d’utiliser de synchronisation lorsqu’elles appellent setAttribute() et getAttri-
bute(), mais elles peuvent devoir en avoir besoin lorsqu’elles utilisent les objets stockés
dans le ServletContext. Ces objets appartiennent à l’application ; ils sont stockés par le
conteneur de servlets pour le compte de l’application et, comme tous les objets partagés,
ils doivent être partagés correctement ; pour empêcher les interférences dues aux accès
concurrents de la part des différents threads, ils doivent être soit thread-safe, soit non
modifiables dans les faits, soit protégés explicitement par un verrou 1.
4.2 Confinement des instances
Si un objet n’est pas thread-safe, plusieurs techniques permettent toutefois de l’utiliser
en toute sécurité dans un programme multithread. Vous pouvez faire en sorte qu’on n’y
accède qu’à partir d’un seul thread (confinement au thread) ou que tous ses accès soit
protégés par un verrou.
L’encapsulation simplifie la création de classes thread-safe en favorisant le confinement
des instances, souvent simplement désigné sous le terme de confinement [CPJ 2.3.3].
Lorsqu’un objet est encapsulé dans un autre objet, tout le code qui a accès à l’objet
encapsulé est connu et peut donc être analysé plus facilement que si l’objet était accessible
par tout le programme. En combinant ce confinement à une discipline de verrouillage
adéquate, on peut garantir que des objets qui, sans cela, ne seraient pas thread-safe seront
utilisés de façon thread-safe.
L’encapsulation de données dans un objet confine l’accès de ces données aux méthodes
de l’objet, ce qui permet de garantir plus facilement qu’on accédera toujours aux
données avec le verrou adéquat.
1. L’objet HttpSession qui effectue une fonction similaire dans le framework des servlets peut avoir
des exigences plus strictes. Le conteneur de servlets pouvant accéder aux objets de HttpSession afin de
les sérialiser pour la réplication ou la passivation, ceux-ci doivent être thread-safe puisque le conteneur
y accédera en même temps que l’application web (nous avons écrit "peut avoir" car la réplication et la
passivation ne font pas partie de la spécification des servlets, bien qu’il s’agisse d’une fonctionnalité
courante des conteneurs de servlets).
64 Les bases Partie I
Les objets confinés ne doivent pas s’échapper de leur portée. Un objet peut être confiné
à une instance de classe (c’est le cas, par exemple, d’un membre de classe privé), à une
portée lexicale (c’est le cas des variables locales) ou à un thread (c’est le cas d’un objet
passé de méthode en méthode dans un thread, mais qui n’est pas censé être partagé
entre des threads). Les objets ne s’échappent pas tout seuls, bien sûr : ils ont besoin de
l’aide du développeur, qui les aide en les publiant en dehors de leur portée.
La classe PersonSet du Listing 4.2 illustre la façon dont le confinement et le verrouillage
peuvent fonctionner de concert pour créer une classe thread-safe, même lorsque ses
variables d’état ne le sont pas. L’état de PersonSet est en effet géré par un HashSet qui
n’est pas thread-safe ; mais, comme mySet est privé et ne peut pas s’échapper, ce HashSet
est confiné dans l’objet PersonSet. Le seul code qui peut accéder à mySet est celui des
méthodes addPerson() et containsPerson(), or chacune d’elles prend le verrou sur
l’objet PersonSet. Comme tout son état est protégé par son verrou interne, PersonSet
est thread-safe.
Listing 4.2 : Utilisation du confinement pour assurer la thread safety.
@ThreadSafe
public class PersonSet {
@GuardedBy("this")
private final Set<Person> mySet = new HashSet<Person>();
public synchronized void addPerson(Person p) {
mySet.add(p);
}
public synchronized boolean containsPerson(Person p) {
return mySet.contains(p);
}
}
Cet exemple ne fait aucune supposition sur la thread safety de Person mais, si cette
classe est modifiable, une synchronisation supplémentaire sera nécessaire pour accéder
à une Person récupérée à partir d’un PersonSet. Le moyen le plus sûr serait de rendre
Person thread-safe ; le moins sûr serait de protéger les objets Person par un verrou et de
s’assurer que tous les clients suivent le protocole consistant à prendre le verrou adéquat
avant d’accéder à une Person.
Le confinement d’instance est l’un des moyens les plus simples pour créer des classes
thread-safe. Il permet également de choisir la stratégie de verrouillage ; PersonSet utilise
son propre verrou interne pour protéger son état, mais tout verrou correctement utilisé
ferait de même. Avec le confinement des instances, vous pouvez aussi protéger les
différentes variables d’état par des verrous distincts (la classe ServerStatus [voir
Listing 11.7] est un exemple de classe utilisant plusieurs objets verrous pour protéger
son état).
Les bibliothèques de classes de la plate-forme contiennent plusieurs exemples de confi-
nement ; certaines classes n’existent que pour rendre thread-safe des classes qui ne le
Chapitre 4 Composition d’objets 65
sont pas. Les classes collection de base, comme ArrayList et HashMap, ne sont pas
thread-safe, mais la bibliothèque fournit des méthodes qui enveloppent les méthodes
fabriques de ces objets (Collections.synchronizedList et d’autres) pour pouvoir les
utiliser en toute sécurité dans des environnements multithreads. Ces fabriques utilisent
le patron de conception Décorateur (voir Gamma et al., 1995) pour envelopper la
collection dans un objet synchronisé ; cette enveloppe implémente chaque méthode de
l’interface adéquate sous la forme d’une méthode synchronized qui fait suivre la
requête à l’objet sous-jacent. L’objet enveloppe est thread-safe tant qu’il détient la seule
référence accessible à la collection sous-jacente (cette collection est donc confinée dans
cette enveloppe). La documentation Javadoc de ces méthodes avertit d’ailleurs que tous
les accès à la collection sous-jacente doivent se faire via l’enveloppe.
Il est bien sûr toujours possible de violer le confinement en publiant un objet censé
être confiné ; s’il a été conçu pour être confiné dans une portée spécifique, laisser l’objet
s’échapper de cette portée constitue un bogue. Les objets confinés peuvent égale-
ment s’échapper en publiant d’autres objets comme des itérateurs ou des instances de
classes internes qui peuvent, indirectement, publier les objets confinés.
Le confinement facilite la création de classes thread-safe car une classe qui confine son
état peut être analysée sans avoir besoin d’examiner tout le programme.
4.2.1 Le patron moniteur de Java
En suivant le principe du confinement d’instance jusqu’à sa conclusion logique, on
arrive au patron moniteur de Java1. Un objet respectant ce patron encapsule tout son
état modifiable et le protège à l’aide de son verrou interne.
La classe Counter du Listing 4.1 est un exemple typique de ce patron. Elle encapsule une
seule variable d’état, value, et tous les accès à cette variable passent par ses méthodes,
qui sont toutes synchronisées.
Le patron moniteur de Java est utilisé par de nombreuses classes de la bibliothèque,
comme Vector et Hashtable, mais on peut parfois avoir besoin d’une politique de
synchronisation plus sophistiquée : le Chapitre 11 montrera comment améliorer la
"scalabilité" des programmes à l’aide de stratégies de verrouillage plus fines. L’avantage
de ce patron est sa simplicité.
Le patron moniteur de Java est simplement une convention ; n’importe quel objet
verrou peut servir à protéger l’état d’un objet pourvu qu’il soit utilisé correctement. Le
Listing 4.3 donne un exemple de classe qui utilise un verrou privé pour protéger son état.
1. Le patron moniteur de Java s’inspire des travaux de Hoare sur les moniteurs (Hoare, 74), bien qu’il
existe des différences significatives entre ce patron et un vrai moniteur. Les instructions du pseudo-code
pour entrer et sortir d’un bloc synchronized s’appellent d’ailleurs monitorenter et monitorexit, et
les verrous internes de Java sont parfois appelés verrous moniteurs ou, simplement, moniteurs.
66 Les bases Partie I
Listing 4.3 : Protection de l’état à l’aide d’un verrou privé.
public class PrivateLock {
private final Object myLock = new Object();
@GuardedBy("myLock") Widget widget;
void someMethod() {
synchronized(myLock) {
// Accès ou modification de l’état du widget
}
}
}
Utiliser un objet verrou privé au lieu d’un verrou interne (ou tout autre verrou accessible
publiquement) présente quelques avantages. Le fait que l’objet verrou soit privé
l’encapsule de sorte que le code client ne peut pas le prendre, alors qu’un verrou public
autorise le code client à participer à la politique de synchronisation – correctement ou
non. Les clients qui prennent incorrectement le verrou d’un autre objet peuvent en effet
poser des problèmes de vivacité, et vérifier qu’un verrou public est correctement utilisé
nécessite d’examiner tout le programme au lieu de cantonner l’analyse à une seule
classe.
4.2.2 Exemple : gestion d’une flotte de véhicules
La classe Counter du Listing 4.1 est un exemple d’utilisation du patron moniteur de
Java, mais il est un peu trop simple. Nous allons donc construire un exemple un peu
moins trivial : un "gestionnaire de véhicules" chargé de répartir les véhicules d’une flotte
comme des taxis, des voitures de police ou des camions de livraison. Nous utiliserons
d’abord le patron moniteur, puis nous verrons comment assouplir un peu l’encapsulation
tout en préservant la thread safety.
Chaque véhicule est identifié par une chaîne de caractères et a une position représentée
par les coordonnées (x, y). La classe VehicleTracker encapsule l’identité et les empla-
cements des véhicules connus, ce qui en fait un modèle de données bien adapté à une
application graphique modèle-vue-contrôleur où elle peut être partagée par un thread vue
et plusieurs threads modificateurs. Le thread vue récupère les noms et les emplacements
des véhicules pour les afficher :
Map<String, Point> locations = vehicles.getLocations();
for (String key : locations.keySet())
renderVehicle(key, locations.get(key));
De même, les threads modificateurs mettent à jour les emplacements des véhicules à partir
des données reçues par des dispositifs GPS ou entrées manuellement par un opérateur
via une interface graphique :
void vehicleMoved (VehicleMovedEvent evt) {
Point loc = evt.getNewLocation();
vehicles.setLocation(evt.getVehicleId(), loc.x, loc.y);
}
Chapitre 4 Composition d’objets 67
Le thread vue et les threads modificateurs concourant pour accéder au modèle de
données, ce dernier doit être thread-safe. Le Listing 4.4 présente une implémentation
du gestionnaire de véhicules qui utilise le patron moniteur de Java. Pour représenter les
emplacements des véhicules, ce gestionnaire se sert de la classe MutablePoint du
Listing 4.5.
Bien que MutablePoint ne soit pas thread-safe, la classe du gestionnaire l’est. Ni la
carte ni aucun des points modifiables qu’elle contient ne seront jamais publiés. Si l’on
doit renvoyer des emplacements de véhicules à un client, les valeurs appropriées seront
copiées soit à l’aide du constructeur de copie de la classe MutablePoint, soit en utilisant
la méthode deepCopy(), qui crée une nouvelle carte dont les valeurs sont des copies des
clés et des valeurs de l’ancienne1.
Cette implémentation gère en partie la thread safety en copiant les données modifiables
avant de les renvoyer au client. Cela ne pose généralement pas de problème en terme de
performances, sauf si l’ensemble des véhicules est très important 2. Une autre consé-
quence de la copie des données à chaque appel de getLocations() est que le contenu
de la collection renvoyée ne changera pas, même si les emplacements sous-jacents sont
modifiés ; que ce soit souhaitable ou non dépend de vos besoins. Cela peut être intéres-
sant si la cohérence interne de l’ensemble des emplacements est importante, auquel cas
renvoyer un instantané cohérent est essentiel, mais cela peut également être un problème
si les clients ont besoin d’informations à jour pour chaque véhicule et qu’ils doivent
donc rafraîchir leurs copies plus souvent.
4.3 Délégation de la thread safety
Tous les objets, sauf les plus simples, sont des objets composés. Le patron moniteur de
Java est utile lorsque l’on crée des classes en partant de zéro ou que l’on compose des
classes à partir d’objets non thread-safe, mais qu’en est-il si les composants de notre classe
sont déjà thread-safe ? Faut-il ajouter une couche supplémentaire de thread safety ? La
réponse est… "ça dépend". Dans certains cas, un objet composé à partir d’autres objets
thread-safe est lui-même thread-safe (voir Listings 4.7 et 4.9) ; dans d’autres, c’est
simplement un bon point de départ (voir Listing 4.10).
1. deepCopy() ne peut pas se contenter d’envelopper la carte par un objet unmodifiableMap puisque
cela ne protégerait pas la collection contre les modifications ; les clients pourraient modifier les objets
modifiables contenus dans cette carte. Pour la même raison, le remplissage du HashMap dans deep
Copy() via un constructeur de copie ne fonctionnerait pas car cela ne copierait que les références aux
points, pas les objets points eux-mêmes.
2. deepCopy() étant appelée à partir d’une méthode synchronisée, le verrou interne du gestionnaire
est détenu pendant toute la durée de l’opération de copie, qui pourrait être assez longue. La réactivité
de l’interface utilisateur risque donc d’être dégradée lorsque le nombre de véhicules gérés est très
grand.
68 Les bases Partie I
Dans la classe CountingFactorizer du Listing 2.4, nous avions ajouté un champ
AtomicLong à un objet sans état et l’objet composé obtenu était quand même thread-safe.
L’état de CountingFactorizer étant l’état contenu dans le champ AtomicLong, qui est
thread-safe, et CountingFactorizer n’imposant aucune contrainte de validité sur le
compteur, il est aisé de constater que la classe est elle-même thread-safe. Nous pourrions
dire que CountingFactorizer délègue la responsabilité de sa thread safety à AtomicLong :
CountingFactorizer est thread-safe parce que AtomicLong l’est1.
Listing 4.4 : Implémentation du gestionnaire de véhicule reposant sur un moniteur.
@ThreadSafe
public class MonitorVehicleTracker {
@GuardedBy("this")
private final Map<String, MutablePoint> locations;
public MonitorVehicleTracker(Map<String, MutablePoint> locations) {
this.locations = deepCopy(locations);
}
public synchronized Map<String, MutablePoint> getLocations() {
return deepCopy(locations);
}
public synchronized MutablePoint getLocation(String id) {
MutablePoint loc = locations.get(id);
return loc == null ? null : new MutablePoint(loc);
}
public synchronized void setLocation(String id, int x, int y) {
MutablePoint loc = locations.get(id);
if (loc == null)
throw new IllegalArgumentException ("No such ID: " + id);
loc.x = x;
loc.y = y;
}
private static Map<String, MutablePoint> deepCopy(Map<String,
MutablePoint> m) {
Map<String, MutablePoint> result = new HashMap<String,
MutablePoint>();
for (String id : m.keySet())
result.put(id, new MutablePoint(m.get(id)));
return Collections.unmodifiableMap (result);
}
}
public class MutablePoint { /* Listing 4.5 */ }
1. Si count n’avait pas été final, l’analyse de la thread safety de CountingFactorizer aurait été plus
compliquée. Si CountingFactorizer pouvait modifier count pour qu’il fasse référence à un autre
AtomicLong, nous devrions vérifier que cette modification est visible par tous les threads qui pourraient
accéder au compteur et nous assurer qu’il n’y ait pas de situation de compétition concernant la valeur
de la référence count. C’est une autre bonne raison d’utiliser des champs final à chaque fois que cela
est possible.
Chapitre 4 Composition d’objets 69
Listing 4.5 : Classe Point modifiable ressemblant à java.awt.Point.
@NotThreadSafe
public class MutablePoint {
public int x, y;
public MutablePoint() { x = 0; y = 0; }
public MutablePoint(MutablePoint p) {
this.x = p.x;
this.y = p.y;
}
}
4.3.1 Exemple : gestionnaire de véhicules utilisant la délégation
Construisons maintenant une version du gestionnaire de véhicules qui délègue sa thread
safety à une classe thread-safe. Comme nous stockons les emplacements dans un objet
Map, nous partons d’une implémentation thread-safe de Map, ConcurrentHashMap. À la
place de MutablePoint, nous utilisons également une classe Point non modifiable (voir
Listing 4.6) pour stocker chaque emplacement.
Listing 4.6 : Classe Point non modifiable utilisée par DelegatingVehicleTracker.
@Immutable
public class Point {
public final int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
Point est thread-safe parce qu’elle est non modifiable. Les valeurs non modifiables
pouvant être librement partagées et publiées, nous n’avons plus besoin de copier les
emplacements lorsqu’on les renvoie.
La classe DelegatingVehicleTracker du Listing 4.7 n’utilise pas de synchronisation
explicite ; tous les accès à son état sont gérés par ConcurrentHashMap et toutes les clés
et valeurs de la Map sont non modifiables.
Listing 4.7 : Délégation de la thread safety à un objet ConcurrentHashMap.
@ThreadSafe
public class DelegatingVehicleTracker {
private final ConcurrentMap<String, Point> locations;
private final Map<String, Point> unmodifiableMap ;
public DelegatingVehicleTracker (Map<String, Point> points) {
locations = new ConcurrentHashMap< String, Point>(points);
unmodifiableMap = Collections.unmodifiableMap(locations);
}
public Map<String, Point> getLocations() {
return unmodifiableMap ;
}
70 Les bases Partie I
Listing 4.7 : Délégation de la thread safety à un objet ConcurrentHashMap. (suite)
public Point getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if (locations.replace(id, new Point(x, y)) == null)
throw new IllegalArgumentException("invalid vehicle name: " + id);
}
}
L’utilisation de la classe MutablePoint au lieu de Point aurait brisé l’encapsulation en
autorisant getLocations() à publier une référence vers un état modifiable non thread-safe.
Vous remarquerez que nous avons légèrement modifié le comportement de la classe
gestionnaire des véhicules : alors que la version avec moniteur renvoyait un instantané
des emplacements, la version avec délégation renvoie une vue non modifiable mais
"vivante" de ces emplacements. Ceci signifie que, si un thread A appelle getLocations()
et qu’un thread B modifie ensuite l’emplacement de l’un des points, ces changements
seront répercutés dans l’objet Map renvoyé au thread A. Comme nous l’avons déjà fait
remarquer, ceci peut être un avantage (données plus à jour) ou un handicap (vue éventuel-
lement incohérente de la flotte) en fonction de vos besoins.
Si vous avez besoin d’une vue de la flotte qui ne change pas, getLocations() pourrait
renvoyer une copie de surface de la carte des emplacements. Le contenu de l’objet Map
étant non modifiable, seule sa structure a besoin d’être copiée. C’est ce que l’on fait
dans le Listing 4.8 (où l’on renvoie un vrai HashMap, puisque getLocations() n’a pas
pour contrat de renvoyer un Map thread-safe).
Listing 4.8 : Renvoi d’une copie statique de l’ensemble des emplacements au lieu
d’une copie "vivante".
public Map<String, Point> getLocations() {
return Collections.unmodifiableMap (
new HashMap<String, Point>(locations));
}
4.3.2 Variables d’état indépendantes
Les exemples de délégation que nous avons donnés jusqu’à présent ne déléguaient qu’à
une seule variable d’état thread-safe, mais il est également possible de déléguer la
thread safety à plusieurs variables d’état sous-jacentes du moment que ces dernières
sont indépendantes, ce qui signifie que la classe composée n’impose aucun invariant
impliquant les différentes variables d’état.
La classe VisualComponent du Listing 4.9 est un composant graphique qui permet aux
clients d’enregistrer des écouteurs (listeners) pour les événements souris et clavier. Elle
gère donc deux listes d’écouteurs, une pour chaque type d’événement, afin que les
écouteurs appropriés puissent être appelés lorsqu’un événement survient. Cependant, il
n’y a aucune relation entre l’ensemble des écouteurs de la souris et celui des écouteurs
Chapitre 4 Composition d’objets 71
du clavier ; ils sont tous les deux indépendants et VisualComponent peut donc déléguer
ses obligations de thread safety aux deux listes thread-safe sous-jacentes.
Listing 4.9 : Délégation de la thread à plusieurs variables d’état sous-jacentes.
public class VisualComponent {
private final List<KeyListener> keyListeners
= new CopyOnWriteArrayList <KeyListener>();
private final List<MouseListener> mouseListeners
= new CopyOnWriteArrayList <MouseListener>();
public void addKeyListener(KeyListener listener) {
keyListeners.add(listener);
}
public void addMouseListener (MouseListener listener) {
mouseListeners.add(listener);
}
public void removeKeyListener (KeyListener listener) {
keyListeners.remove(listener);
}
public void removeMouseListener (MouseListener listener) {
mouseListeners.remove(listener);
}
}
VisualComponent utilise un objet CopyOnWriteArrayList pour stocker chaque liste
d’écouteurs ; cette classe est une implémentation thread-safe de List particulièrement
bien adaptée à ce type de gestion (voir la section 5.2.3). Chaque liste est thread-safe
et, comme il n’y a pas de contrainte reliant l’état de l’une à l’état de l’autre, Visual
Component peut déléguer ses responsabilités de thread safety aux objets mouseListeners
et keyListeners.
4.3.3 Échecs de la délégation
La plupart des classes composées ne sont pas aussi simples que VisualComponent :
leurs invariants lient entre elles les variables d’état de leurs composants. Pour gérer son
état, la classe NumberRange du Listing 4.10 utilise deux AtomicInteger mais impose
une contrainte supplémentaire : le premier nombre doit être inférieur ou égal au second.
Listing 4.10 : Classe pour des intervalles numériques, qui ne protège pas suffisamment
ses invariants. Ne le faites pas.
public class NumberRange {
// INVARIANT: lower <= upper
private final AtomicInteger lower = new AtomicInteger(0);
private final AtomicInteger upper = new AtomicInteger(0);
public void setLower(int i) {
// Attention : tester-puis-agir non sûr
if (i > upper.get())
throw new IllegalArgumentException("can’t set lower to " +
i + " > upper");
72 Les bases Partie I
Listing 4.10 : Classe pour des intervalles numériques, qui ne protège pas suffisamment
ses invariants. Ne le faites pas. (suite)
lower.set(i);
}
public void setUpper(int i) {
// Attention : tester-puis-agir non sûr
if (i < lower.get())
throw new IllegalArgumentException("can’t set upper to " +
i + " < lower");
upper.set(i);
}
public boolean isInRange(int i) {
return (i >= lower.get() && i <= upper.get());
}
}
NumberRange n’est pas thread-safe car elle ne préserve pas l’invariant qui contraint les
valeurs de lower et upper. Les méthodes setLower() et setUpper() tentent bien de le
respecter, mais elles le font mal car ce sont toutes les deux des séquences tester-puis-agir
qui n’utilisent pas un verrouillage suffisant pour être atomiques. Si le nombre contient
(0, 10) et qu’un thread appelle setLower(5) pendant qu’un autre appelle setUpper(4),
un timing malheureux fera que les deux tests réussiront et que ces deux modifications
seront donc autorisées. Le résultat sera alors un intervalle (5, 4), donc dans un état
incorrect. Bien que les AtomicInteger sous-jacents soient thread-safe, la classe composée
ne l’est donc pas. Les variables d’état lower et upper n’étant pas indépendantes,
NumberRange ne peut pas se contenter de déléguer sa thread safety à ses variables d’état
thread-safe.
NumberRange pourrait être rendue thread-safe en utilisant le même verrou pour protéger
lower et upper et donc ses invariants. Elle doit également éviter de publier lower et upper
pour empêcher le code client de pervertir les invariants.
Si une classe comprend des actions composées, comme c’est le cas de NumberRange, la
délégation seule ne suffit pas pour assurer la thread safety. La classe doit fournir son
propre verrouillage pour garantir que ces opérations sont atomiques, sauf si l’action
composée peut entièrement être déléguée aux variables d’état sous-jacentes.
Si une classe contient plusieurs variables d’état thread-safe indépendantes et qu’elle
n’ait aucune opération ayant des transitions d’état invalides, elle peut déléguer sa
thread safety à ces variables d’état.
Le problème qui empêchait NumberRange d’être thread-safe bien que ses composants
d’état fussent thread-safe ressemble beaucoup à l’une des règles sur les variables volatiles
de la section 3.1.4 : une variable ne peut être déclarée volatile que si elle ne participe
pas à des invariants impliquant d’autres variables d’état.
Chapitre 4 Composition d’objets 73
4.3.4 Publication des variables d’état sous-jacentes
Lorsque l’on délègue la thread safety aux variables sous-jacentes d’un objet, sous quelles
conditions peut-on publier ces variables pour que les autres classes puissent également
les modifier ? Là encore, la réponse dépend des invariants qu’impose la classe sur ces
variables. Bien que le champ value sous-jacent de Counter puisse recevoir n’importe
quelle valeur entière, Counter la contraint à ne prendre que des valeurs positives et
l’opération d’incrémentation contraint l’ensemble des états suivants admis pour un état
donné. Si le champ value était public, les clients pourraient le modifier et y placer une
valeur invalide ; si ce champ était publié, il rendrait la classe incorrecte. En revanche, si
une variable représente la température courante ou l’identifiant du dernier utilisateur à
s’être connecté, le fait qu’une autre classe modifie sa valeur ne violera probablement
pas les invariants et la publication de cette variable peut donc être acceptable (elle peut
quand même être déconseillée, car la publication de variables modifiables contraint les
futurs développements et les occasions de créer des sous-classes, mais cela ne rendrait
pas nécessairement la classe non thread-safe).
Une variable d’état thread-safe ne participant à aucun invariant qui contraint sa valeur
et dont aucune des opérations ne possède de transition d’état interdit peut être publiée
sans problème.
On pourrait publier sans problème, par exemple, les champs mouseListeners ou
keyListeners de VisualComponent. Cette classe n’imposant aucune contrainte sur les
états admis pour ses listes d’écouteurs, ces champs pourraient être publics ou publiés
sans compromettre la thread safety.
4.3.5 Exemple : gestionnaire de véhicules publiant son état
Nous allons construire une version du gestionnaire de véhicules qui publie son état
modifiable. Nous devons donc modifier à nouveau l’interface pour prendre en compte
ce changement, cette fois-ci en utilisant des points modifiables mais thread-safe.
Listing 4.11 : Classe point modifiable et thread-safe.
@ThreadSafe
public class SafePoint {
@GuardedBy("this") private int x, y;
private SafePoint(int[] a) { this(a[0], a[1]); }
public SafePoint(SafePoint p) { this(p.get()); }
public SafePoint(int x, int y) {
this.set(x, y)
}
74 Les bases Partie I
Listing 4.11 : Classe point modifiable et thread-safe. (suite)
public synchronized int[] get() {
return new int[] { x, y };
}
public synchronized void set(int x, int y) {
this.x = x;
this.y = y;
}
}
La classe SafePoint du Listing 4.11 fournit un accesseur de lecture qui récupère les
deux valeurs x et y et les renvoie dans un tableau de deux éléments1. Si nous avions
fourni des accesseurs de lecture distincts pour x et y, les valeurs pourraient être modifiées
entre le moment où l’on récupère une coordonnée et celui où l’on récupère l’autre : le
code client verrait alors une valeur incohérente, c’est-à-dire un emplacement ( x, y) où le
véhicule n’aurait jamais été. Grâce à SafePoint, nous pouvons construire un gestion-
naire de véhicules qui publie son état modifiable sans compromettre la sécurité par rapport
aux threads, comme le montre la classe PublishingVehicleTracker du Listing 4.12.
Listing 4.12 : Gestionnaire de véhicule qui publie en toute sécurité son état interne.
@ThreadSafe
public class PublishingVehicleTracker {
private final Map<String, SafePoint> locations;
private final Map<String, SafePoint> unmodifiableMap ;
public PublishingVehicleTracker(Map<String, SafePoint> locations) {
this.locations = new ConcurrentHashMap <String,
SafePoint>(locations);
this.unmodifiableMap = Collections.unmodifiableMap(this.locations);
}
public Map<String, SafePoint> getLocations() {
return unmodifiableMap ;
}
public SafePoint getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if (!locations.containsKey(id))
throw new IllegalArgumentException ("invalid vehicle name: " + id);
locations.get(id).set(x, y);
}
}
PublishingVehicleTracker tire sa thread safety de la délégation à un objet Concurrent
HashMap sous-jacent mais, cette fois, le contenu de la Map sont des points modifiables
thread-safe et non plus des points non modifiables. La méthode getLocation() renvoie
1. Le constructeur est privé pour éviter la situation de compétition qui surviendrait si le constructeur
de copie était implémenté sous la forme this(p.x, p.y) ; c’est un exemple de l’idiome capture du
constructeur privé (Bloch et Gafter, 2005).
Chapitre 4 Composition d’objets 75
une copie non modifiable de la Map sous-jacente. Le code appelant ne peut ni ajouter ni
supprimer de véhicules, mais il pourrait modifier l’emplacement d’un véhicule en
modifiant les valeurs SafePoint contenues dans la Map. Ici aussi, la nature "vivante" de
la Map peut être un avantage ou un inconvénient en fonction des besoins. Publishing
VehicleTracker est thread-safe mais elle ne le serait pas si elle imposait des contraintes
supplémentaires sur les valeurs valides des emplacements des véhicules. S’il faut pouvoir
placer un "veto" sur les modifications apportées aux emplacements des véhicules ou
effectuer certaines actions lorsqu’un emplacement est modifié, l’approche choisie par
PublishingVehicleTracker ne convient pas.
4.4 Ajout de fonctionnalités à des classes thread-safe existantes
La bibliothèque de classes de Java contient de nombreuses classes "briques" utiles. Il
est souvent préférable de réutiliser des classes existantes plutôt qu’en créer de nouvelles :
la réutilisation permet de réduire le travail et les risques de développement (puisque les
composants existants ont déjà été testés), ainsi que les coûts de maintenance. Parfois,
une classe thread-safe disposant de toutes les opérations dont on a besoin existe déjà
mais, souvent, le mieux que l’on puisse trouver est une classe fournissant presque toutes
les opérations voulues : nous devons alors ajouter une nouvelle opération sans compro-
mettre son comportement vis-à-vis des threads.
Supposons, par exemple, que nous ayons besoin d’une liste thread-safe disposant d’une
opération ajouter-si-absent atomique. Les implémentations synchronisées de List font
l’affaire puisqu’elles fournissent les méthodes contains() et add() à partir desquelles
nous pouvons construire l’opération voulue.
Le concept ajouter-si-absent est assez évident : on teste si un élément se trouve dans la
collection avant de l’ajouter et on ne l’ajoute pas s’il est déjà présent (vos voyants
d’alarme de tester-puis-agir devraient déjà s’être allumés). Le fait que la classe doive
être thread-safe ajoute une autre contrainte : les opérations comme ajouter-si-absent
doivent être atomiques. Toute interprétation sensée suggère que, si vous prenez une List
qui ne contient pas l’objet X et que vous lui ajoutiez deux fois X avec ajouter-si-absent, la
collection ne contiendra finalement qu’une seule copie de X. Si ajouter-si-absent
n’était pas atomique et avec un timing malheureux, deux threads pourraient constater
que X n’était pas présent et l’ajouteraient tous les deux.
Le moyen le plus simple d’ajouter une nouvelle opération atomique consiste à modifier
la classe initiale pour qu’elle dispose de cette opération, mais ce n’est pas toujours
possible car vous pouvez ne pas avoir accès au code source ou ne pas avoir le droit de le
modifier. Si vous pouvez modifier la classe originale, vous devez comprendre la politique
de synchronisation de son implémentation pour rester cohérent avec sa conception
initiale. Pour ajouter directement une nouvelle méthode à la classe, il faut aussi que tout
76 Les bases Partie I
le code qui implémente la politique de synchronisation de cette classe soit contenu dans
le même fichier source, afin de faciliter sa compréhension et sa maintenance.
Une autre approche consiste à étendre la classe en supposant qu’elle a été conçue pour
pouvoir être étendue. La classe BetterVector du Listing 4.13, par exemple, étend Vector
pour lui ajouter une méthode putIfAbsent(). Étendre Vector est assez simple, mais toutes
les classes n’exposent pas assez leur état aux sous-classes pour qu’il en soit toujours ainsi.
L’extension d’une classe est plus fragile que l’ajout direct de code à cette classe car elle
implique la distribution de la politique de synchronisation entre plusieurs fichiers sources
distincts. Si la classe sous-jacente modifie ensuite sa politique de synchronisation en
choisissant un verrou différent pour protéger ses variables d’état, la sous-classe tomberait
en panne sans prévenir puisqu’elle n’utiliserait plus le bon verrou pour contrôler les
accès concurrents à l’état de sa classe de base (la politique de synchronisation de Vector
étant fixée par sa spécification, BetterVector ne peut pas souffrir de ce problème).
Listing 4.13 : Extension de Vector pour disposer d’une méthode ajouter-si-absent.
@ThreadSafe
public class BetterVector<E> extends Vector<E> {
public synchronized boolean putIfAbsent(E x) {
boolean absent = !contains(x);
if (absent)
add(x);
return absent;
}
}
4.4.1 Verrouillage côté client
Pour un objet ArrayList enveloppé dans un objet Collections.synchronizedList,
aucune des deux approches précédentes ne peut fonctionner puisque le code client ne
connaît même pas la classe de l’objet List renvoyé par les fabriques de l’enveloppe
synchronisée. Une troisième stratégie consiste à étendre la fonctionnalité de la classe
sans l’étendre elle-même mais en plaçant le code d’extension dans une classe auxiliaire.
Le Listing 4.14 présente une tentative manquée de créer une classe auxiliaire dotée d’une
opération ajouter-si-absent atomique portant sur une List thread-safe.
Listing 4.14 : Tentative non thread-safe d’implémenter ajouter-si-absent. Ne le faites pas.
@NotThreadSafe
public class ListHelper<E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
...
public synchronized boolean putIfAbsent(E x) {
boolean absent = !list.contains(x);
if (absent)
list.add(x);
return absent;
}
}
Chapitre 4 Composition d’objets 77
Pourquoi cela ne fonctionne-t-il pas, bien que putIfAbsent() soit synchronisée ? Le
problème est que cette méthode se synchronise sur le mauvais verrou. Quel que soit
celui que List utilise pour protéger son état, il est certain que ce n’est pas le même
que celui de ListHelper. Cette dernière ne fournit donc qu’une illusion de synchronisa-
tion : les différentes opérations sur les listes, bien qu’elles soient toutes synchronisées,
utilisent des verrous différents, ce qui signifie que putIfAbsent() n’est pas atomique
par rapport aux autres opérations sur la List. Il n’y a donc aucune garantie qu’un autre
thread ne pourra modifier la liste pendant l’exécution de putIfAbsent().
Pour que cette approche fonctionne, vous devez utiliser le même verrou que List en
vous servant d’un verrouillage côté client ou externe. Grâce au verrouillage côté client,
un code client qui utilise un objet X sera protégé avec le verrou que X utilise pour protéger
son propre état. Pour mettre en place ce type de verrouillage, il faut connaître le verrou
que X utilise.
La documentation de Vector et des classes enveloppes synchronisées indique indirec-
tement que ces classes supportent le verrouillage côté client en utilisant le verrou interne
du Vector ou de la collection enveloppe (pas celui de la collection enveloppée). Le
Listing 4.15 présente une méthode putIfAbsent() qui utilise correctement le verrouillage
côté client lorsqu’elle s’applique à une List thread-safe.
Listing 4.15 : Implémentation d’ajouter-si-absent avec un verrouillage côté client.
@ThreadSafe
public class ListHelper<E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
...
public boolean putIfAbsent(E x) {
synchronized (list) {
boolean absent = !list.contains(x);
if (absent)
list.add(x);
return absent;
}
}
}
Si l’extension d’une classe en lui ajoutant une autre opération atomique est fragile parce
qu’elle distribue le code de verrouillage entre plusieurs classes d’une hiérarchie d’objets,
le verrouillage côté client l’est plus encore puisqu’il implique de placer le code de
verrouillage d’une classe C dans des classes qui n’ont rien à voir avec C. Faites attention
lorsque vous utilisez un verrouillage côté client sur des classes qui ne précisent pas leur
stratégie de verrouillage. Le verrouillage côté client a beaucoup de points communs
avec l’extension de classe : tous les deux lient le comportement de la classe dérivée à
l’implémentation de la classe de base. Tout comme l’extension viole l’encapsulation
de l’implémentation [EJ Item 14], le verrouillage côté client viole l’encapsulation de la
politique de synchronisation.
78 Les bases Partie I
4.4.2 Composition
Il existe une autre solution, moins fragile, pour ajouter une opération atomique à une
classe existante : la composition. La classe ImprovedList du Listing 4.16 implémente
les opérations de List en les déléguant à une instance sous-jacente de List et ajoute
une opération putIfAbsent() (comme Collections.synchronizedList et les autres
collections enveloppes, ImprovedList suppose que le client n’utilisera pas directement
la liste sous-jacente après l’avoir passée au constructeur de l’enveloppe et qu’il n’y
accédera que par l’objet ImprovedList).
Listing 4.16 : Implémentation d’ajouter-si-absent en utilisant la composition.
@ThreadSafe
public class ImprovedList<T> implements List<T> {
private final List<T> list;
public ImprovedList(List<T> list) { this.list = list; }
public synchronized boolean putIfAbsent(T x) {
boolean contains = list.contains(x);
if (!contains)
list.add(x);
return !contains;
}
public synchronized void clear() { list.clear(); }
// ... délégations similaires pour les autres méthodes de List
}
ImprovedList ajoute un niveau de verrouillage supplémentaire en utilisant son propre
verrou interne. Peu importe que la List sous-jacente soit thread-safe puisque Improved
List fournit son propre verrouillage afin d’assurer la thread safety, même si la List
n’est pas thread-safe ou qu’elle modifie l’implémentation de son verrouillage. Bien que
cette couche de synchronisation additionnelle puisse avoir un léger impact sur les
performances1, l’implémentation de ImprovedList est moins fragile que les tentatives
de mimer la stratégie de verrouillage d’un autre objet. En réalité, nous avons utilisé le
patron moniteur de Java pour encapsuler une List existante et cela garantit la thread
safety tant que notre classe contient la seule référence existante à cette List sous-
jacente.
4.5 Documentation des politiques de synchronisation
La documentation est l’un des outils les plus puissants qui soit (et malheureusement
l’un des moins utilisés) pour gérer la thread safety. C’est dans la documentation que les
utilisateurs recherchent si une classe est thread-safe et que les développeurs qui main-
tiennent le code essaient de comprendre la stratégie de l’implémentation pour éviter de la
1. Cet impact sera peu important car il n’y aura pas de concurrence sur la synchronisation de la List
sous-jacente. Celle-ci sera donc rapide ; voir le Chapitre 11.
Chapitre 4 Composition d’objets 79
compromettre accidentellement. Malheureusement, la documentation ne contient souvent
pas les informations dont on a besoin.
Documentez les garanties de thread safety d’une classe pour ses clients et documentez
sa politique de synchronisation pour ses développeurs ultérieurs.
Chaque utilisation de synchronized, volatile ou d’une classe thread-safe est le reflet
d’une politique de synchronisation qui définit une stratégie assurant l’intégrité des
données vis-à-vis des accès concurrents. Cette politique est un élément de la conception
d’un programme et devrait apparaître dans la documentation. Le meilleur moment pour
documenter des décisions de conception est, évidemment, la phase de conception.
Quelques semaines ou mois plus tard, les détails peuvent s’être estompés et c’est la
raison pour laquelle il faut les écrire avant de les oublier.
Mettre au point une politique de synchronisation exige de prendre un certain nombre de
décisions : quelles variables rendre volatiles, quelles variables protéger par des verrous,
quel(s) verrou(s) protège(nt) quelles variables, quelles variables faut-il rendre non modi-
fiables ou confiner dans un thread, quelles opérations doivent être atomiques, etc. ?
Certaines de ces décisions sont strictement des détails d’implémentation et devraient être
documentées pour aider les futurs développeurs, mais d’autres affectent le comporte-
ment du verrouillage observable d’une classe et devraient être documentées comme une
partie de sa spécification.
Au minimum, documentez les garanties de thread safety offertes par la classe. Est-elle
thread-safe ? Appelle-t-elle des fonctions de rappel pendant qu’elle détient un verrou ?
Y a-t-il des verrous précis qui affectent son comportement ? N’obligez pas les clients à
faire des suppositions risquées. Si vous ne voulez pas fournir d’information pour le
verrouillage côté client, parfait, mais indiquez-le. Si vous souhaitez que les clients puisse
créer de nouvelles opérations atomiques sur votre classe, comme nous l’avons fait dans
la section 4.4, indiquez les verrous qu’ils doivent obtenir pour le faire en toute sécurité.
Si vous utilisez des verrous pour protéger l’état, signalez-le dans la documentation pour
prévenir les futurs développeurs, d’autant que c’est très facile : l’annotation @GuardedBy
s’en chargera. Si vous utilisez des moyens plus subtils pour maintenir la thread safety,
indiquez-les car cela peut ne pas apparaître évident aux développeurs chargés de main-
tenir le code.
La situation actuelle de la documentation de la sécurité vis-à-vis des threads, même pour
les classes de la bibliothèque standard, n’est pas encourageante. Combien de fois avez-
vous recherché une classe dans Javadoc en vous demandant finalement si elle était thread-
safe1 ? La plupart des classes ne donnent aucun indice. De nombreuses spécifications
1. Si vous ne vous l’êtes jamais demandé, nous admirons votre optimisme.
80 Les bases Partie I
officielles des technologies Java, comme les servlets et JDBC, sous-documentent cruel-
lement leurs promesses et leurs besoins de thread safety.
Bien que la prudence suggère de ne pas supposer des comportements qui ne fassent pas
partie de la spécification, il faut bien que le travail soit fait et nous devons souvent choisir
entre plusieurs mauvaises suppositions. Doit-on supposer qu’un objet est thread-safe
parce qu’il nous semble qu’il devrait l’être ? Doit-on supposer que l’accès à un objet
peut être rendu thread-safe en obtenant d’abord son verrou (cette technique risquée ne
fonctionne que si nous contrôlons tout le code qui accède à cet objet ; sinon elle ne fournit
qu’une illusion de thread safety) ? Aucun de ces choix n’est vraiment satisfaisant.
Pour accentuer le tout, notre intuition peut parfois nous tromper sur les classes qui sont
"probablement thread-safe" et celles qui ne le sont pas. java.text.SimpleDateFormat,
par exemple, n’est pas thread-safe, mais sa documentation Javadoc oubliait de le
mentionner jusqu’au JDK 1.4. Que cette classe précise ne pas être thread-safe a pris par
surprise de nombreux développeurs. Combien de programmes ont créé par erreur une
instance partagée d’un objet non thread-safe et l’ont utilisée à partir de plusieurs threads,
sans savoir que cela pourrait donner des résultats incorrects en cas de lourde charge ?
Un problème comme celui de SimpleDateFormat pourrait être évité en ne supposant
pas qu’une classe soit thread-safe si elle n’indique pas qu’elle l’est. En revanche, il est
impossible de développer une application utilisant des servlets sans faire quelques suppo-
sitions assez raisonnables sur la thread safety des objets conteneurs comme HttpSession.
N’obligez pas vos clients ou vos collègues à faire ce genre de supposition.
4.5.1 Interprétation des documentations vagues
Les spécifications de nombreuses technologies Java ne disent rien, ou très peu de choses,
sur les garanties et les besoins de thread safety d’interfaces comme ServletContext,
HttpSession ou DataSource1. Ces interfaces étant implémentées par l’éditeur du conte-
neur ou de la base de données, il est souvent impossible de consulter le code source
pour savoir ce qu’il fait. En outre, on ne souhaite pas se fier aux détails d’implémentation
d’un pilote JDBC particulier : on veut respecter le standard pour que notre code fonc-
tionne correctement avec n’importe quel pilote. Cependant, les mots "thread" et "concur-
rent" n’apparaissent jamais dans la spécification de JDBC et très peu dans celle des
servlets. Comment faire dans une telle situation ?
Il faut faire des suppositions. Un moyen d’améliorer la qualité de ces déductions consiste
à interpréter la spécification du point de vue de celui chargé de l’implémenter (un
éditeur de conteneur ou de base de données, par exemple) et non du point de vue de
celui qui se contentera de l’utiliser. Les servlets étant toujours appelées à partir d’un
thread géré par un conteneur, il est raisonnable de penser que ce conteneur saura s’il y a
1. Il est d’ailleurs particulièrement frustrant que ces omissions perdurent malgré les nombreuses
révisions majeures de ces spécifications.
Chapitre 4 Composition d’objets 81
plusieurs threads. Le conteneur de servlet mettant à disposition des objets qui offrent
des services à plusieurs servlets, comme HttpSession ou ServletContext, il devrait
s’attendre à ce qu’on accède à ces objets de façon concurrente puisqu’il a créé plusieurs
threads qui appelleront des méthodes comme Servlet.service() et que celles-ci accèdent
sûrement à l’objet ServletContext.
Comme il est impossible d’imaginer que ces objets puissent être utilisés dans un contexte
monothread, on doit supposer qu’ils ont été conçus pour être thread-safe, bien que la
spécification ne l’exige pas explicitement. En outre, s’ils demandaient un verrouillage
côté client, quel est le verrou que le client devrait utiliser pour synchroniser son code ?
La documentation ne le dit pas et on imagine mal comment le deviner. Cette "supposi-
tion raisonnable" est confirmée par les exemples de la spécification et des didacticiels
officiels, qui montrent comment accéder à un objet ServletContext ou HttpSession
sans utiliser la moindre synchronisation côté client.
En revanche, les objets placés avec setAttribute() dans un objet ServletContext ou
HttpSession appartiennent à l’application web, pas au conteneur de servlets. La spéci-
fication des servlets ne suggère aucun mécanisme pour coordonner les accès concurrents
aux attributs partagés. Les attributs stockés par le conteneur pour le compte de l’appli-
cation devraient donc être thread-safe ou non modifiables dans les faits. Si le conteneur
se contentait de les stocker pour l’application web, une autre possibilité serait de s’assurer
qu’ils sont correctement protégés par un verrou lorsqu’ils sont utilisés par le code de
l’application servlet. Cependant, le conteneur pouvant vouloir sérialiser les objets dans
HttpSession pour des besoins de réplication ou de passivation et le conteneur de servlets
ne pouvant pas connaître votre protocole de verrouillage, c’est à vous de les rendre
thread-safe.
On peut faire le même raisonnement pour l’interface DataSource de JDBC, qui repré-
sente un pool de connexions réutilisables. Un objet DataSource fournissant un service
à une application, il serait surprenant que cela se passe dans un contexte monothread : il
est en effet difficilement imaginable que cela n’implique pas un appel à getConnection()
à partir de plusieurs threads. D’ailleurs, comme pour les servlets, la spécification JDBC
ne suggère pas de verrouillage côté client dans les nombreux exemples de code qui
utilisent DataSource. Par conséquent, bien que la spécification ne promette pas que
DataSource soit thread-safe et n’exige pas que les éditeurs de conteneurs fournissent
une implémentation thread-safe, nous sommes obligés de supposer que DataSource
.getConnection() n’exige pas de verrouillage côté client.
En revanche, on ne peut pas en dire autant des objets Connection JDBC fournis par
DataSource puisqu’ils ne sont pas nécessairement prévus pour être partagés par d’autres
activités tant qu’ils ne sont pas revenus dans le pool. Si une activité obtient un objet
Connection JDBC et qu’elle se décompose en plusieurs threads, elle doit prendre la
82 Les bases Partie I
responsabilité de garantir que l’accès à cet objet est correctement protégé par synchro-
nisation (dans la plupart des applications, les activités qui utilisent un objet Connection
JDBC sont implémentées de sorte à confiner cet objet dans un thread particulier).
5
Briques de base
Le chapitre précédent a exploré plusieurs techniques pour construire des classes thread-
safe, notamment la délégation de la thread safety à des classes thread-safe existantes.
Lorsqu’elle est applicable, la délégation est l’une des stratégies les plus efficaces pour
créer des classes thread-safe : il suffit de laisser les classes thread-safe existantes gérer
l’intégralité de l’état.
Les bibliothèques de la plate-forme Java contiennent un large ensemble de briques de
base pour les applications concurrentes, comme les collections thread-safe et divers
synchronisateurs qui permettent de coordonner le flux de contrôle des threads qui
coopèrent à l’exécution. Nous présenterons ici les plus utiles, notamment celles qui ont
été introduites par Java 5.0 et Java 6, et nous montrerons quelques patrons d’utilisation
qui permettent de structurer les applications concurrentes.
5.1 Collections synchronisées
Les classes collections synchronisées incluent Vector et Hashtable, qui étaient déjà là
dans le premier JDK, ainsi que leurs cousines ajoutées dans le JDK 1.2, les classes
enveloppes synchronisées que l’on crée par les méthodes fabriques Collections
.synchronizedXxx(). Ces classes réalisent la thread safety en encapsulant leur état et
en synchronisant chaque méthode publique de sorte qu’un seul thread puisse accéder à
l’état de la collection à un instant donné.
5.1.1 Problèmes avec les collections synchronisées
Bien que les collections synchronisées soient thread-safe, il faut parfois utiliser un
verrouillage côté client pour protéger les actions composées comme les itérations (récu-
pération répétée de tous les éléments de la collection), les navigations (rechercher un
élément selon un certain ordre) et les opérations conditionnelles de type ajouter-si-
absent (vérifier qu’une clé K d’un objet Map a une valeur associée et ajouter l’association
84 Les bases Partie I
(K, V) dans le cas contraire). Avec une collection synchronisée, ces actions composées
sont techniquement thread-safe, même sans verrouillage côté client, mais elles peuvent
ne pas se comporter comme vous vous y attendez lorsque d’autres threads peuvent en
même temps modifier la collection.
Le Listing 5.1 présente deux méthodes portant sur un Vector, getLast() et deleteLast(),
qui sont toutes les deux des séquences tester-puis-agir. Chaque appel détermine la taille
du tableau et utilise ce résultat pour récupérer ou supprimer son dernier élément.
Listing 5.1 : Actions composées sur un Vector pouvant produire des résultats inattendus.
public static Object getLast(Vector list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
public static void deleteLast(Vector list) {
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
Ces méthodes semblent inoffensives et le sont dans un certain sens : elles ne peuvent
pas abîmer le Vector, quel que soit le nombre de threads qui les appellent simultanément.
Mais le code qui appelle ces méthodes peut avoir une opinion différente. Si un thread A
appelle getLast() sur un Vector de dix éléments, qu’un thread B appelle deleteLast()
sur le même Vector et que ces opérations soient entrelacées comme à la Figure 5.1,
getLast() lancera ArrayIndexOutOfBoundsException. Entre l’appel à size() et celui
de get() dans getLast(), le Vector s’est rétréci et l’indice calculé lors de la première
étape n’est donc plus valide. Le comportement est parfaitement cohérent avec la spéci-
fication de Vector – une exception est levée lorsque l’on demande un élément qui
n’existe pas – mais ce n’est pas ce qu’attendait celui qui a appelé getLast(), même en
présence d’une modification concurrente (sauf, peut-être, si le Vector était vide).
Les collections synchronisées respectant une politique de synchronisation qui autorise
le verrouillage côté client1, il est alors possible de créer de nouvelles opérations qui
seront atomiques par rapport aux autres opérations de la collection du moment que l’on
connaît le verrou à utiliser. En effet, ces classes protègent chaque méthode avec un
verrou portant sur l’objet collection lui-même : en prenant ce verrou, comme dans le
Listing 5.2, nous pouvons donc rendre getLast() et deleteLast() atomiques, ce qui
garantit que la taille du Vector ne changera pas entre l’appel à size() et celui de get().
Le risque que la taille du Vector puisse changer entre un appel à size() et l’appel
à get() existe aussi lorsque l’on parcourt les éléments du Vector comme dans le
Listing 5.3.
1. Ceci n’est documenté que très vaguement dans le Javadoc de Java 5.0, comme exemple d’idiome
d’itération correct.
Chapitre 5 Briques de base 85
Cet idiome d’itération suppose en effet que les autres threads ne modifieront pas le
Vector entre les appels à size() et à get(). Dans un environnement monothread, cette
supposition est tout à fait correcte mais, si d’autres threads peuvent modifier le Vector
de façon concurrente, cela posera des problèmes. Comme précédemment, l’exception
ArrayIndexOutOfBoundsException sera déclenchée si un autre thread supprime un
élément pendant que l’on parcourt le Vector et que l’entrelacement des opérations est
défavorable.
B size() ⟹ 10 get(9) Boum !
A size() ⟹ 10 remove(9)
Figure 5.1
Entrelacement de getLast() et deleteLast() déclenchant ArrayIndexOutOfBoundsException.
Listing 5.2 : Actions composées sur Vector utilisant un verrouillage côté client.
public static Object getLast(Vector list) {
synchronized(list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
}
public static void deleteLast(Vector list) {
synchronized(list) {
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
}
Listing 5.3 : Itération pouvant déclencher ArrayIndexOutOfBoundsException.
for (int i = 0; i < vector.size(); i++)
doSomething(vector.get(i));
Le fait que l’itération du Listing 5.3 puisse lever une exception ne signifie pas que
Vector ne soit pas thread-safe : l’état du Vector est toujours valide et l’exception est,
en réalité, conforme à la spécification. Cependant, qu’une opération aussi banale que la
récupération du dernier élément d’une itération déclenche une exception est clairement
un comportement indésirable.
Ce problème peut être réglé par un verrouillage côté client, au prix d’un coût supplé-
mentaire en terme d’adaptabilité. En gardant le verrou du Vector pendant la durée de
l’itération, comme dans le Listing 5.4, on empêche les autres threads de le modifier
pendant qu’on le parcourt. Malheureusement, ceci empêche également les autres threads
d’y accéder pendant ce laps de temps, ce qui détériore la concurrence.
86 Les bases Partie I
Listing 5.4 : Itération avec un verrouillage côté client.
synchronized(vector) {
for (int i = 0; i < vector.size(); i++)
doSomething(vector.get(i));
}
5.1.2 Itérateurs et ConcurrentModificationException
Dans de nombreux exemples, nous utilisons Vector pour des raisons de simplicité, bien
qu’elle soit considérée comme une classe collection "historique". Cela dit, les classes
plus "modernes" n’éliminent pas le problème des actions composées. La méthode standard
pour parcourir une Collection consiste à utiliser un Iterator, soit explicitement, soit
via la syntaxe de la boucle "pour-chaque" introduite par Java 5.0, mais l’utilisation
d’itérateurs n’empêche pas qu’il faille verrouiller la collection pendant son parcours si
d’autres threads peuvent la modifier en même temps. Les itérateurs renvoyés par les
collections synchronisées ne sont pas conçus pour gérer les modifications concurrentes
et ils échouent rapidement (on dit que ce sont des itérateurs fail-fast) : s’ils détectent
que la collection a été modifiée depuis le départ de l’itération, ils lancent l’exception
non contrôlée ConcurrentModificationException.
Ces itérateurs "fail-fast" sont conçus non pour être infaillibles mais pour capturer "de
bonne foi" les erreurs de concurrence ; ce ne sont donc que des indicateurs précoces des
problèmes de concurrence. Ils sont implémentés en associant un compteur de modifica-
tion à la collection : si ce compteur change au cours de l’itération, hasNext() ou next()
lancent ConcurrentModificationException. Cependant, ce test étant effectué sans
synchronisation, il existe un risque de voir une valeur obsolète du compteur, et l’itéra-
teur peut alors ne pas réaliser qu’une modification a eu lieu. Il s’agit d’un compromis
délibéré pour réduire l’impact de ce code de détection sur les performances 1.
Le Listing 5.5 parcourt une collection avec la syntaxe de la boucle pour-chaque. En
interne, javac produit un code qui utilise un Iterator et appelle hasNext() et next()
pour parcourir la liste. Comme pour le parcours d’un Vector, il faut donc maintenir un
verrou sur la collection pendant la durée de l’itération si l’on veut éviter le déclen-
chement de ConcurrentModificationException.
Listing 5.5 : Parcours d’un objet List avec un Iterator.
List<Widget> widgetList
= Collections.synchronizedList (new ArrayList<Widget>());
...
// Peut lever ConcurrentModificationException
for (Widget w : widgetList)
doSomething(w);
1. ConcurrentModificationException peut également survenir dans un code monothread ; elle est levée
lorsque des objets sont supprimés directement de la collection plutôt qu’avec Iterator.remove().
Chapitre 5 Briques de base 87
Il y a cependant plusieurs raisons pour lesquelles le verrouillage d’une collection
pendant la durée de son parcours n’est pas souhaitable. Les autres threads qui ont besoin
d’accéder à la collection se bloqueront jusqu’à la fin de l’itération et, si la collection est
grande ou que l’opération effectuée sur chaque élément prenne un certain temps, ils
peuvent attendre longtemps. En outre, si la collection est verrouillée comme dans le
Listing 5.4, doSomething() est appelée sous le couvert d’un verrou, ce qui peut provoquer
un blocage définitif (ou deadlock, voir Chapitre 10). Même en l’absence de risque de
famine ou de deadlock, le verrouillage des collections pendant un temps non négligeable
peut gêner l’adaptabilité de l’application. Plus le verrou est détenu longtemps, plus il
sera disputé et, si de nombreux threads se bloquent en attente de la disponibilité d’un
verrou, l’utilisation du processeur peut en pâtir (voir Chapitre 11).
Au lieu de verrouiller la collection pendant l’itération, on peut cloner la collection et
itérer sur la copie. Le clone étant confiné au thread, aucun autre ne pourra le modifier
pendant son parcours, ce qui élimine le risque de l’exception ConcurrentModification
Exception (mais il faut quand même verrouiller la collection pendant l’opération de
clonage). Cloner une collection a évidemment un coût en termes de performances ; que
ce soit un compromis acceptable ou non dépend de nombreux facteurs, dont la taille de
la collection, le type de traitement à effectuer sur chaque élément, la fréquence relative
de l’itération par rapport aux autres opérations sur la collection et les exigences en
termes de réactivité et de débit des données.
5.1.3 Itérateurs cachés
Bien que le verrouillage puisse empêcher les itérateurs de lancer ConcurrentModification
Exception, vous devez penser à l’utiliser à chaque fois qu’une collection partagée peut
être parcourue par itération. Ceci est plus difficile qu’il n’y paraît car les itérateurs sont
parfois cachés, comme dans la classe HiddenIterator du Listing 5.6. Cette classe ne
contient aucune itération explicite, mais le code en gras revient à en faire une. En effet,
la concaténation des chaînes est traduite par le compilateur en un appel à StringBuilder
.append(Object) , qui, à son tour, appelle la méthode toString() de la collection, or
l’implémentation de toString() dans les collections standard parcourt la collection par
itération et appelle toString() sur chaque élément pour produire une représentation
correctement formatée du contenu de celle-ci.
La méthode addTenThings() pourrait donc lancer ConcurrentModificationException
puisque la collection est itérée par toString() au cours de la préparation du message
de débogage. Le véritable problème, bien sûr, est que HiddenIterator n’est pas thread-
safe ; son verrou devrait être pris avant d’utiliser set() dans l’appel à println(), mais
les codes de débogage et de journalisation négligent souvent de le faire.
La leçon qu’il faut en tirer est que plus la distance est importante entre l’état et la
synchronisation qui le protège, plus il est probable qu’on oubliera d’utiliser une synchro-
nisation adéquate lorsqu’on accédera à cet état. Si HiddenIterator enveloppait l’objet
88 Les bases Partie I
HashSet dans un synchronizedSet en encapsulant ainsi la synchronisation, ce type
d’erreur ne pourrait pas survenir.
Tout comme l’encapsulation de l’état d’un objet facilite la préservation de ses invariants,
l’encapsulation de sa synchronisation facilite le respect de sa politique de synchronisation.
Listing 5.6 : Itération cachée dans la concaténation des chaînes. Ne le faites pas.
public class HiddenIterator {
@GuardedBy("this")
private final Set<Integer> set = new HashSet<Integer>();
public synchronized void add(Integer i) { set.add(i); }
public synchronized void remove(Integer i) { set.remove(i); }
public void addTenThings() {
Random r = new Random();
for (int i = 0; i < 10; i++)
add(r.nextInt());
System.out.println("DEBUG: added ten elements to " + set);
}
}
L’itération est également invoquée indirectement par les méthodes hashCode() et
equals() de la collection, qui peuvent être appelées si la collection est utilisée comme
un élément ou une clé d’une autre collection. De même, les méthodes containsAll(),
removeAll() et retainAll(), ainsi que les constructeurs qui prennent des collections
en paramètre, parcourent également la collection. Toutes ces utilisations indirectes de
l’itération peuvent provoquer une exception ConcurrentModificationException.
5.2 Collections concurrentes
Java 5.0 améliore les collections synchronisées en fournissant plusieurs classes collec-
tions concurrentes. Les collections synchronisées réalisent leur thread safety en sérialisant
tous les accès à l’état de la collection, ce qui donne une concurrence assez pauvre :
quand plusieurs threads concourent pour obtenir le verrou de la collection, le débit des
données en souffre.
Les collections concurrentes, en revanche, sont conçues pour les accès concurrents à
partir de plusieurs threads. Java 5.0 ajoute ConcurrentHashMap pour remplacer les
implémentations de Map reposant sur des hachages synchronisés et CopyOnWriteArray
List pour remplacer les implémentations de List synchronisées dans les cas où l’opéra-
tion prédominante est le parcours d’une liste. La nouvelle interface ConcurrentMap,
quant à elle, ajoute le support des actions composées classiques, comme ajouter-si-
absent, remplacer et suppression conditionnelle.
Chapitre 5 Briques de base 89
Le remplacement des collections synchronisées par les collections concurrentes permet
d’ajouter énormément d’adaptabilité avec peu de risques.
Java 5.0 ajoute également deux nouveaux types collection, Queue et BlockingQueue.
Un objet Queue est conçu pour contenir temporairement un ensemble d’éléments
pendant qu’ils sont en attente de traitement. Plusieurs implémentations sont également
fournies, dont ConcurrentLinkedQueue, une file d’attente classique, et PriorityQueue,
une file (non concurrente) a priorités. Les opérations sur Queue ne sont pas bloquantes ;
si la file est vide, une opération de lecture renverra null. Bien que l’on puisse simuler le
comportement de Queue avec List (en fait, LinkedList implémente également Queue),
les classes Queue ont été ajoutées car éliminer la possibilité d’accès direct de List
permet d’obtenir des implémentations concurrentes plus efficaces.
BlockingQueue étend Queue pour lui ajouter des opérations d’insertion et de lecture
bloquantes. Si la file est vide, la récupération d’un élément se bloquera jusqu’à ce
qu’une donnée soit disponible et, si la file est pleine (dans le cas des files de tailles limi-
tées), une insertion se bloquera tant qu’il n’y a pas d’emplacement libre. Les files
bloquantes sont très importantes dans les schémas producteur-consommateur et seront
présentées en détail dans la section 5.3.
Tout comme ConcurrentHashMap est un remplacement concurrent pour les Map synchro-
nisés, Java 6 ajoute ConcurrentSkipListMap et ConcurrentSkipListSet comme rempla-
cements concurrents des SortedMap ou SortedSet synchronisés (comme TreeMap ou
TreeSet enveloppés par synchronizedMap).
5.2.1 ConcurrentHashMap
Les collections synchronisées maintiennent un verrou pour la durée de chaque opération.
Certaines, comme HashMap.get() ou List.contains(), peuvent impliquer plus de
travail qu’on pourrait le penser : parcourir un hachage ou une liste pour trouver un objet
spécifique nécessite d’appeler la méthode equals() (qui, elle-même, peut impliquer un
calcul assez complexe) sur un certain nombre d’objets candidats. Dans une collection
de type hachage, si hashCode() ne répartit pas bien les valeurs de hachage, les éléments
peuvent être distribués inégalement : dans le pire des cas, une mauvaise fonction de
hachage transformera un hachage en liste chaînée. Le parcours d’une longue liste et
l’appel de equals() sur quelques éléments ou sur tous les éléments peut donc prendre
un certain temps pendant lequel aucun autre thread ne pourra accéder à la collection.
ConcurrentHashMap est un hachage Map comme HashMap, sauf qu’elle utilise une stratégie
de verrouillage entièrement différente qui offre une meilleure concurrence et une adap-
tabilité supérieure. Au lieu de synchroniser chaque méthode sur un verrou commun, ce
qui restreint l’accès à un seul thread à la fois, elle utilise un mécanisme de verrouillage
plus fin, appelé verrouillage partitionné (lock striping, voir la section 11.4.3) afin
90 Les bases Partie I
d’autoriser un plus grand degré d’accès partagé. Un nombre quelconque de threads
lecteurs peuvent accéder simultanément au hachage, en même temps que les écrivains,
et un nombre limité de threads écrivains peuvent modifier le hachage de façon concurrente.
On obtient ainsi un débit de données bien plus important lors des accès concurrents,
moyennant une petite perte de performances pour les accès monothreads.
ConcurrentHashMap, ainsi que les autres collections concurrentes, améliore encore les
classes collections synchronisées en fournissant des itérateurs qui ne lancent pas
ConcurrentModificationException : il n’est donc plus nécessaire de verrouiller la
collection pendant l’itération. Les itérateurs renvoyés par ConcurrentHashMap sont faible-
ment cohérents au lieu d’être fail-fast. Un itérateur faiblement cohérent peut tolérer une
modification concurrente, il parcourt les éléments tels qu’ils étaient lorsque l’itérateur a
été construit et peut (mais ce n’est pas garanti) refléter les modifications apportées à la
collection après la construction de l’itérateur.
Comme avec toutes les améliorations, il a fallu toutefois faire quelques compromis. La
sémantique des méthodes qui agissent sur tout l’objet Map, comme size() et isEmpty(),
a été légèrement affaiblie pour refléter la nature concurrente de la collection. Le résultat
de size() pouvant être obsolète au moment où il est calculé, cette méthode peut donc
renvoyer une valeur approximative au lieu d’un comptage exact. Bien que cela puisse
sembler troublant au premier abord, les méthodes comme size() et isEmpty() sont, en
réalité, bien moins utiles dans les environnements concurrents puisque ces valeurs sont
des cibles mouvantes. Les exigences de ces opérations ont donc été affaiblies pour
permettre d’optimiser les performances des opérations plus importantes que sont get(),
put(), containsKey() et remove().
La seule fonctionnalité offerte par les implémentations synchronisées de Map qui ne se
retrouve pas dans ConcurrentHashMap est la possibilité de verrouiller le hachage pour
disposer d’un accès exclusif. Avec Hashtable et synchronizedMap, la détention du
verrou de l’objet Map empêche tout autre thread d’y accéder. Cela peut être nécessaire
dans des cas spéciaux, comme lorsque l’on souhaite ajouter plusieurs associations de
façon atomique ou que l’on veut parcourir plusieurs fois le hachage en voyant à chaque
fois les éléments dans le même ordre. Cependant, globalement, l’absence de cette fonc-
tionnalité est un compromis acceptable puisque les collections concurrentes sont censées
changer leurs éléments en permanence.
ConcurrentHashMap ayant de nombreux avantages et peu d’inconvénients par rapport à
Hashtable ou synchronizedMap, vous avez tout intérêt à remplacer ces dernières par
ConcurrentHashMap afin de disposer d’une plus grande adaptabilité. Ce n’est que
lorsqu’une application a besoin de verrouiller un hachage pour y accéder de manière
exclusive1 que ConcurrentHashMap n’est pas la meilleure solution.
1. Ou si vous avez besoin des effets de bord de la synchronisation des implémentations synchronisées
de Map.
Chapitre 5 Briques de base 91
5.2.2 Opérations atomiques supplémentaires sur les Map
Un objet ConcurrentHashMap ne pouvant pas être verrouillé pour garantir un accès
exclusif, nous ne pouvons pas utiliser le verrouillage côté client pour créer de nouvelles
opérations atomiques comme ajouter-si-absent, comme nous l’avons fait pour Vector
dans la section 4.4.1. Cependant, un certain nombre d’opérations composées comme
ajouter-si-absent, supprimer-si-égal et remplacer-si-égal sont implémentées sous forme
d’opérations atomiques par l’interface ConcurrentMap présentée dans le Listing 5.7. Si
vous constatez que vous devez ajouter l’une de ces fonctionnalités à une implémentation
existante de Map synchronisée, il s’agit sûrement d’un signe indiquant que vous devriez
utiliser ConcurrentMap à la place.
Listing 5.7 : Interface ConcurrentMap.
public interface ConcurrentMap<K,V> extends Map<K,V> {
// Insertion uniquement si K n’a pas de valeur associée
V putIfAbsent(K key, V value);
// Suppression uniquement si K est associée à V
boolean remove(K key, V value);
// Remplacement de la valeur uniquement si K est associée à oldValue
boolean replace(K key, V oldValue, V newValue);
// Remplacement de la valeur uniquement si K est associée à une valeur
V replace(K key, V newValue);
}
5.2.3 CopyOnWriteArrayList
CopyOnWriteArrayList remplace les List synchronisées et offre une meilleure gestion
de la concurrence dans quelques situations classiques. Avec cette classe, il n’y a plus
besoin de verrouiller ou de copier une collection pendant les itérations. De la même
façon, CopyOnWriteArraySet remplace les Set synchronisés.
La thread safety des collections "copie lors de l’écriture" vient du fait qu’il n’y a pas
besoin de synchronisation supplémentaire pour accéder à un objet non modifiable dans
les faits qui a été correctement publié. Ces classes implémentent la "mutabilité" en
créant et en republiant une nouvelle copie de la collection à chaque fois qu’elle est
modifiée. Les itérateurs de ces collections stockent une référence au tableau sous-jacent
tel qu’il était au début de l’itération et, comme il ne changera jamais, ils doivent ne se
synchroniser que très brièvement pour assurer la visibilité de son contenu. Plusieurs
threads peuvent donc parcourir la collection sans interférer les uns avec les autres ni
avec ceux qui veulent la modifier. Les itérateurs renvoyés par ces collections ne lèvent
pas ConcurrentModificationException et renvoient les éléments tels qu’ils étaient à
la création de l’itérateur, quelles que soient les modifications apportées ensuite.
Évidemment, la copie du tableau sous-jacent à chaque modification de la collection a un
coût, notamment lorsque la collection est importante ; il ne faut utiliser les collections
92 Les bases Partie I
"copie lors de l’écriture" que lorsque les opérations d’itérations sont bien plus fréquentes
que celles de modification. Ce critère s’applique en réalité aux nombreux systèmes de
notification d’événements : signaler un événement nécessite de parcourir la liste des
écouteurs enregistrés et d’appeler chacun d’eux. Or, dans la plupart des cas, enregistrer
ou désinscrire un écouteur d’événement est une opération bien moins fréquente que
recevoir une notification d’événement (voir [CPJ 2.4.4] pour plus d’informations sur la
"copie lors de l’écriture").
5.3 Files bloquantes et patron producteur-consommateur
Les files bloquantes fournissent les méthodes bloquantes put() et take(), ainsi que
leurs équivalents offer() et poll(), qui utilisent un délai d’expiration. Si la file est
pleine, put() se bloque jusqu’à ce qu’un emplacement soit disponible ; si la file est vide,
take() se bloque jusqu’à ce qu’il y ait un élément disponible. Les files peuvent être
bornées ou non bornées ; les files non bornées n’étant jamais pleines, un appel à put()
sur ce type de file ne sera donc jamais bloquant.
Les files bloquantes reconnaissent le patron de conception "producteur-consommateur".
Ce patron permet de séparer l’identification d’un travail de son exécution en plaçant les
tâches à réaliser dans une liste "à faire" qui sera traitée plus tard au lieu de l’être immé-
diatement à mesure qu’elles sont identifiées. Le patron producteur-consommateur
simplifie donc le développement puisqu’il supprime les dépendances entre les classes
producteurs et consommateurs ; il allège également la gestion de la charge de travail en
découplant les activités qui peuvent produire ou consommer des données à des vitesses
différentes ou variables.
Dans une conception producteur-consommateur construite autour d’une file bloquante,
les producteurs placent les données dans la file dès qu’elles deviennent disponibles et les
consommateurs les récupèrent dans cette file lorsqu’ils sont prêts à effectuer les actions
adéquates. Les producteurs n’ont pas besoin de connaître ni l’identité, ni le nombre de
consommateurs, ni même s’il y a d’autres producteurs : leur seule tâche consiste à
placer des données dans la file. De même, les consommateurs n’ont pas besoin de
savoir qui sont les producteurs ni d’où provient le travail. BlockingQueue simplifie
l’implémentation de ce patron avec un nombre quelconque de producteurs et de consom-
mateurs. L’une des applications les plus fréquentes du modèle producteur-consommateur
est un pool de threads associé à une file de tâches ; ce patron est d’ailleurs intégré dans
le framework d’exécution de tâches Executor, qui sera présenté aux Chapitres 6 et 8.
Un exemple d’application du patron producteur-consommateur est la division des tâches
entre deux personnes qui font la vaisselle : l’une lave les plats et les pose sur l’égouttoir,
l’autre prend les plats sur l’égouttoir et les essuie. Dans ce schéma, l’égouttoir sert de
file bloquante ; s’il n’y a aucun plat dessus, le consommateur attend qu’il y ait des plats
à essuyer et, lorsque l’égouttoir est plein, le producteur doit s’arrêter de laver jusqu’à ce
Chapitre 5 Briques de base 93
qu’il y ait de la place. Cette analogie s’étend à plusieurs producteurs (bien qu’il puisse
y avoir un conflit pour l’accès à l’évier) et à plusieurs consommateurs ; chaque
personne n’interagit qu’avec l’égouttoir. Personne n’a besoin de savoir quel est le nombre
de producteurs ou de consommateurs ni qui a produit un élément particulier.
Les étiquettes "producteur" et "consommateur" sont relatives ; une activité qui agit
comme un consommateur dans un contexte peut très bien agir comme un producteur
dans un autre. Essuyer les plats "consomme" des plats propres et mouillés et "produit"
des plats propres et secs. Une troisième personne voulant aider les deux plongeurs
pourrait retirer les plats essuyés, auquel cas celui qui essuie serait à la fois un consom-
mateur et un producteur et il y aurait alors deux files des tâches partagées (chacune
pouvant bloquer celui qui essuie en l’empêchant de continuer son travail).
Les files bloquantes simplifient le codage des consommateurs car take() se bloque
jusqu’à ce qu’il y ait des données disponibles. Si les producteurs ne produisent pas
assez vite pour occuper les consommateurs, ceux-ci peuvent simplement attendre que
du travail arrive. Parfois, ce fonctionnement est tout à fait acceptable (c’est ce que fait
une application serveur lorsqu’aucun client ne demande ses services) et, parfois, cela
indique que le nombre de threads producteurs par rapport aux threads consommateurs
doit être ajusté pour améliorer l’utilisation (comme pour un robot web ou toute autre
application qui a un travail infini à réaliser).
Si les producteurs produisent toujours plus vite que les consommateurs ne peuvent traiter,
l’application risque de manquer de mémoire car les tâches s’ajouteront sans fin à la file.
Là aussi, la nature bloquante de put() simplifie beaucoup le codage des producteurs : si
l’on utilise une file bornée, les producteurs seront bloqués lorsque cette file sera pleine,
ce qui laissera le temps aux consommateurs de rattraper leur retard puisqu’un producteur
bloqué ne peut pas générer d’autre tâche.
Les files bloquantes fournissent également une méthode offer() qui renvoie un code
d’erreur si l’élément n’a pas pu être ajouté. Elle permet donc de mettre en place des
politiques plus souples pour gérer la surcharge en réduisant par exemple le nombre de
threads producteurs ou en les ralentissant d’une manière ou d’une autre.
Les files bornées constituent un outil de gestion des ressources relativement puissant
pour produire des applications fiables : elles rendent les programmes plus résistants à la
surcharge en ralentissant les activités qui menacent de produire plus de travail qu’on ne
peut en traiter.
Bien que le patron producteur-consommateur permette de découpler les codes du
producteur et du consommateur, leur comportement reste indirectement associé par la
file des tâches partagée. Il est tentant de supposer que les consommateurs suivront
toujours le rythme, ce qui évite de devoir placer des limites à la taille des files de tâches,
94 Les bases Partie I
mais cette supposition vous amènerait à réarchitecturer votre système plus tard.
Construisez la gestion des ressources dès le début de la conception en utilisant des files
bloquantes : c’est bien plus facile de le faire d’abord que de faire machine arrière plus
tard. Les files bloquantes facilitent cette gestion dans un grand nombre de situations mais,
si elles s’intègrent mal à votre conception, vous pouvez construire d’autres structures de
données bloquantes à l’aide de la classe Semaphore (voir la section 5.5.3).
La bibliothèque de classes contient plusieurs implémentations de BlockingQueue.
LinkedBlockingQueue et ArrayBlockingQueue sont des files d’attente FIFO, analogues
à LinkedList et ArrayList mais avec une meilleure concurrence que celle d’une List
synchronisée. PriorityBlockingQueue est une file a priorités, ce qui est utile lorsque
l’on veut traiter des éléments dans un autre ordre que celui de leur insertion dans la file.
Tout comme les collections triées, PriorityBlockingQueue peut comparer les éléments
selon leur ordre naturel (s’ils implémentent Comparable) ou à l’aide d’un Comparator.
La dernière implémentation de BlockingQueue, SynchronousQueue, n’est en fait pas
une file d’attente du tout car elle ne gère aucun espace de stockage pour les éléments
placés dans la file. Au lieu de cela, elle maintient une liste des threads qui attendent
d’ajouter et de supprimer un élément de la file. Dans notre analogie de la vaisselle, cela
reviendrait à ne pas avoir d’égouttoir mais à passer directement les plats lavés au
sécheur disponible suivant. Bien que cela semble être une étrange façon d’implémenter
une file, cela permet de réduire le temps de latence associé au déplacement des données
du producteur au consommateur car les tâches sont traitées directement (dans une file
traditionnelle, les opérations d’ajout et de suppression doivent s’effectuer en séquence
avant qu’une tâche puisse être traitée). Ce traitement direct fournit également plus
d’informations au producteur sur l’état de la tâche ; lorsqu’elle est prise en charge, le
producteur sait qu’un consommateur en a pris la responsabilité au lieu d’être placée
quelque part dans la file (c’est la même différence qu’entre remettre un document en
main propre à un collègue et le placer dans sa boîte aux lettres en espérant qu’il la
relèvera bientôt). Un objet SynchronousQueue n’ayant pas de capacité de stockage, les
méthodes put() et take() se bloqueront si un autre thread n’est pas prêt à participer au
traitement. Les files synchrones ne conviennent généralement qu’aux situations où il y
a suffisamment de consommateurs et où il y en a presque toujours un qui sera prêt à
traiter la tâche.
5.3.1 Exemple : indexation des disques
Les agents qui parcourent les disques durs locaux et les indexent pour accélérer les
recherches ultérieures (comme Google Desktop et le service d’indexation de Windows)
sont de bons clients pour la décomposition en producteurs et consommateurs. La classe
FileCrawler du Listing 5.8 présente une tâche productrice qui recherche dans une
arborescence de répertoires les fichiers correspondant à un critère d’indexation et place
Chapitre 5 Briques de base 95
leurs noms dans la file des tâches ; la classe Indexer du Listing 5.8 montre la tâche
consommatrice qui extrait les noms de la file et les indexe.
Le patron producteur-consommateur offre un moyen de décomposer ce problème
d’indexation en threads afin d’obtenir des composants plus simples. La factorisation du
parcours et de l’indexation de fichiers en activités distinctes produit un code plus lisible
et mieux réutilisable qu’une activité monolithique qui s’occuperait de tout ; chaque
activité n’a qu’une seule tâche à réaliser et la file bloquante gère tout le contrôle de flux.
Le patron producteur-consommateur a également plusieurs avantages en termes de
performances. Les producteurs et les consommateurs peuvent s’exécuter en parallèle ;
si l’un d’eux est lié aux E/S et que l’autre est lié au processeur, leur exécution concur-
rente produira un débit global supérieur à celui obtenu par leur exécution séquentielle.
Si les activités du producteur et du consommateur sont parallélisables à des degrés
différents, un couplage trop fort risque de ramener ce parallélisme à celui de l’activité
la moins parallélisable.
Le Listing 5.9 lance plusieurs FileCrawler et Indexer dans des threads différents. Tel
que ce code est écrit, le thread consommateur ne se terminera jamais, ce qui empêchera
le programme lui-même de se terminer ; nous étudierons plusieurs techniques pour
corriger ce problème au Chapitre 7. Bien que cet exemple utilise des threads gérés
explicitement, de nombreuses conceptions producteur-consommateur peuvent s’exprimer
à l’aide du framework d’exécution des tâches Executor, qui utilise lui-même le patron
producteur-consommateur.
Listing 5.8 : Tâches producteur et consommateur dans une application d’indexation
des fichiers.
public class FileCrawler implements Runnable {
private final BlockingQueue <File> fileQueue;
private final FileFilter fileFilter;
private final File root;
...
public void run() {
try {
crawl(root);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void crawl(File root) throws InterruptedException {
File[] entries = root.listFiles(fileFilter);
if (entries != null) {
for (File entry : entries)
if (entry.isDirectory())
crawl(entry);
else if (!alreadyIndexed (entry))
fileQueue.put(entry);
}
}
}
96 Les bases Partie I
Listing 5.8 : Tâches producteur et consommateur dans une application d’indexation
des fichiers. (suite)
public class Indexer implements Runnable {
private final BlockingQueue <File> queue;
public Indexer(BlockingQueue<File> queue) {
this.queue = queue;
}
public void run() {
try {
while (true)
indexFile(queue.take());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Listing 5.9 : Lancement de l’indexation.
public static void startIndexing(File[] roots) {
BlockingQueue<File> queue = new LinkedBlockingQueue <File>(BOUND);
FileFilter filter = new FileFilter() {
public boolean accept(File file) { return true; }
};
for (File root : roots)
new Thread(new FileCrawler(queue, filter, root)).start();
for (int i = 0; i < N_CONSUMERS; i++)
new Thread(new Indexer(queue)).start();
}
5.3.2 Confinement en série
Les implémentations des files bloquantes dans java.util.concurrent contiennent toutes
une synchronisation interne suffisante pour publier correctement les objets d’un thread
producteur vers un thread consommateur.
Pour les objets modifiables, les conceptions producteur-consommateur et les files
bloquantes facilitent le confinement en série pour transférer l’appartenance des objets
des producteurs aux consommateurs. Un objet confiné à un thread appartient exclusive-
ment à un seul thread, mais cette propriété peut être "transférée" en le publiant correc-
tement de sorte qu’un seul autre thread y aura accès et en s’assurant que le thread qui
publie n’y accédera pas après ce transfert. La publication correcte garantit que l’état de
l’objet est visible par son nouveau propriétaire et, le propriétaire initial ne le touchant
plus, que cet objet est désormais confiné au nouveau thread. Le nouveau propriétaire
peut alors le modifier librement puisqu’il est le seul à y avoir accès.
Les pools d’objets exploitent le confinement en série en "prêtant" un objet à un thread
qui le demande. Tant que le pool dispose d’une synchronisation interne suffisante pour
publier l’objet correctement et tant que les clients ne publient pas eux-mêmes cet objet
Chapitre 5 Briques de base 97
ou ne l’utilisent pas après l’avoir rendu au pool, la propriété peut être transférée en toute
sécurité d’un thread à un autre.
On pourrait également utiliser d’autres mécanismes de publication pour transférer la
propriété d’un objet modifiable, mais il faut alors s’assurer qu’un seul thread recevra
l’objet transféré. Les files bloquantes facilitent cette opération mais, avec un petit peu
plus de travail, cela pourrait également être fait en utilisant la méthode atomique remove()
de ConcurrentMap ou la méthode compareAndSet() de AtomicReference.
5.3.3 Classe Deque et vol de tâches
Java 6 ajoute deux autres types collection, Deque (que l’on prononce "deck") et Blocking
Deque, qui étend Queue et BlockingQueue. Un objet Deque est une file à double entrée qui
garantit des insertions et des supressions efficaces à ses deux extrémités. Ses implémen-
tations s’appellent ArrayDeque et LinkedBlockingDeque.
Tout comme les files bloquantes se prêtent parfaitement au patron producteur-consom-
mateur, les files doubles sont parfaitement adaptées au patron vol de tâche. Alors que le
patron producteur-consommateur n’utilise qu’une seule file de tâches partagée par tous
les consommateurs, le patron vol de tâche utilise une file double par consommateur. Un
consommateur ayant épuisé toutes les tâches de sa file peut voler une tâche à la fin de la
file double d’un autre. Le vol de tâche est plus adaptatif qu’une conception producteur-
consommateur traditionnelle car, ici, les travailleurs ne rivalisent pas pour une file
partagée ; la plupart du temps, ils n’accèdent qu’à leur propre file double, ce qui réduit
donc les rivalités. Lorsqu’un travailleur doit accéder à la file d’un autre, il le fait à partir
de la fin de celle-ci plutôt que de son début, ce qui réduit encore les conflits. Le vol de
tâche est bien adapté aux problèmes dans lesquels les consommateurs sont également
des producteurs – lorsque l’exécution d’une tâche identifie souvent une autre tâche. Le
traitement d’une page par un robot web, par exemple, identifie généralement de nouvelles
pages à analyser. Les algorithmes de parcours de graphes, comme le marquage du tas
par le ramasse-miettes, peuvent aisément être parallélisés à l’aide du vol de tâches.
Lorsqu’un travailleur identifie une nouvelle tâche, il la place à la fin de sa propre file
double (ou, dans le cas d’un partage du travail, sur celle d’un autre travailleur) ;
lorsque sa file est vide, il recherche du travail à la fin de la file de quelqu’un d’autre, ce
qui garantit que chaque travailleur restera occupé.
5.4 Méthodes bloquantes et interruptions
Les threads peuvent se bloquer, ou se mettre en pause, pour plusieurs raisons : ils peuvent
attendre la fin d’une opération d’E/S, attendre de pouvoir prendre un verrou, attendre de
se réveiller d’un appel à Thread.sleep() ou attendre le résultat d’un calcul effectué par
un autre thread. Lorsqu’un thread se bloque, il est généralement suspendu et placé dans
l’un des états correspondant au blocage des threads (BLOCKED, WAITING ou TIMED_WAITING).
98 Les bases Partie I
La différence entre une opération bloquante et une opération classique qui met simple-
ment longtemps à se terminer est qu’un thread bloqué doit attendre un événement qu’il
ne contrôle pas avant de pouvoir poursuivre – l’opération d’E/S s’est terminée, le
verrou est devenu disponible ou le calcul externe s’est achevé. Lorsque cet événement
externe survient, le thread est replacé dans l’état RUNNABLE et redevient éligible pour
l’accès au processeur.
Les méthodes put() et take() de BlockingQueue, comme un certain nombre d’autres
méthodes de la bibliothèque comme Thread.sleep(), lancent l’exception contrôlée
InterruptedException. Lorsqu’une méthode annonce qu’elle peut lancer Interrupted
Exception, elle indique qu’elle est bloquante et que, si elle est interrompue, elle fera
son possible pour se débloquer le plut tôt possible.
La classe Thread fournit la méthode interrupt() pour interrompre un thread ou savoir
si un thread a été interrompu (chaque thread a une propriété booléenne représentant son
état d’interruption). L’interruption d’un thread est un mécanisme coopératif : un thread
ne peut pas forcer un autre à arrêter ce qu’il fait pour faire quelque chose d’autre ; lorsque
le thread A interrompt le thread B, A demande simplement que B arrête son traitement
en cours lorsqu’il trouvera un point d’arrêt judicieux – et s’il en a envie. Bien que l’API
ou la spécification du langage ne précisent nulle part à quoi peut servir une interruption
dans une application, son utilisation la plus courante consiste à annuler une activité. Les
méthodes bloquantes qui répondent aux interruptions facilitent l’annulation à point nommé
des activités qui tournent sans fin.
Si vous appelez une méthode susceptible de lever une InterruptedException, cette
méthode est également bloquante et vous devez avoir un plan pour répondre à l’inter-
ruption. Pour le code d’une bibliothèque, deux choix sont essentiellement possibles :
m Propager l’InterruptedException. C’est souvent la politique la plus raisonnable
si vous pouvez le faire – il suffit de propager l’interruption au code appelant. Cela
peut impliquer de ne pas capturer InterruptedException ou de la capturer afin de
la relancer après avoir effectué quelques opérations spécifiques de nettoyage.
m Restaurer l’interruption. Parfois, vous ne pouvez pas lancer InterruptedException
(si votre code fait partie d’un Runnable, par exemple). Dans ce cas, vous devez la
capturer et restaurer l’état d’interruption en appelant interrupt() sur le thread courant,
afin que le code plus haut dans la pile des appels puisse voir qu’une interruption est
survenue, comme on le montre dans le Listing 5.10.
Vous pouvez aller bien plus loin avec les interruptions, mais ces deux approches suffiront
à la grande majorité des situations. Il est toutefois déconseillé de capturer Interrupted
Exception pour ne rien faire en réponse. En effet, cela empêcherait le code situé plus
haut dans la pile des appels de réagir puisqu’il ne saura jamais que le thread a été inter-
rompu. La seule situation où l’absorption d’une interruption est acceptable est lorsque
Chapitre 5 Briques de base 99
l’on étend Thread et que l’on veut donc contrôler tout le code situé plus haut dans la
pile. L’annulation et les interruptions sont présentées plus en détail au Chapitre 7.
Listing 5.10 : Restauration de l’état d’interruption afin de ne pas absorber l’interruption.
public class TaskRunnable implements Runnable {
BlockingQueue<Task> queue;
...
public void run() {
try {
processTask(queue.take());
} catch (InterruptedException e) {
// Restauration de l’état d’interruption
Thread.currentThread().interrupt();
}
}
}
5.5 Synchronisateurs
Les files bloquantes sont uniques parmi les classes collections : non seulement elles
servent de conteneurs, mais elles permettent également de coordonner le flux de contrôle
des threads producteur et consommateur puisque les méthodes take() et put() se
bloquent jusqu’à ce que la file soit dans l’état désiré (non vide ou non pleine).
Un synchronisateur est un objet qui coordonne le contrôle de flux des threads en
fonction de son état. Les files bloquantes peuvent donc servir de synchronisateurs ; parmi
les autres types, on peut également citer les sémaphores, les barrières et les loquets.
Bien que la bibliothèque standard contienne déjà un certain nombre de classes synchroni-
satrices, vous pouvez créer les vôtres à partir des mécanismes décrits dans le Chapitre 14
si elles ne correspondent pas à vos besoins.
Tous les synchronisateurs partagent certaines propriétés structurelles : ils encapsulent
un état qui détermine si les threads qui leur parviennent seront autorisés à passer ou
forcés d’attendre, ils founissent des méthodes pour manipuler cet état et d’autres pour
attendre que le synchronisateur soit dans l’état attendu.
5.5.1 Loquets
Un loquet est un synchronisateur permettant de retarder l’exécution des threads tant
qu’il n’est pas dans son état terminal [CPJ 3.4.2]. Un loquet agit comme une porte :
tant qu’il n’est pas dans son état terminal, la porte est fermée et aucun thread ne peut
passer ; dans son état terminal, la porte s’ouvre et tous les threads peuvent entrer. Quand
un loquet est dans son état terminal, il ne peut changer d’état et reste ouvert à jamais.
Les loquets peuvent donc servir à garantir que certaines activités ne se produiront pas
tant qu’une autre activité ne s’est pas terminée. Voici quelques exemples d’application :
m Garantir qu’un calcul ne sera pas lancé tant que les ressources dont il a besoin n’ont
pas été initialisées. Un simple loquet binaire (à deux états) peut servir à indiquer que
100 Les bases Partie I
"la ressource R a été initialisée" et toute activité nécessitant R devra attendre ce
loquet avant de s’exécuter.
m Garantir qu’un service ne sera pas lancé tant que d’autres services dont il dépend
n’ont pas démarré. Chaque service utilise un loquet binaire qui lui est associé ;
lancer le service S implique d’abord d’attendre les loquets des autres services dont
dépend S, puis de relâcher le loquet de S après son lancement pour que les services
qui dépendent de S puissent à leur tour être lancés.
m Attendre que toutes les parties impliquées dans une activité, les joueurs d’un jeu, par
exemple, soient prêtes. Dans ce cas, le loquet n’atteint son état terminal que lorsque
tous les joueurs sont prêts.
CountDownLatch est une implémentation des loquets pouvant être utilisée dans chacune
de ces situations ; elle permet à un ou à plusieurs threads d’attendre qu’un ensemble
d’événements se produisent. L’état du loquet est formé d’un compteur initialisé avec un
nombre positif qui représente le nombre d’événements à attendre. La méthode count
Down() décrémente ce compteur pour indiquer qu’un événement est survenu, tandis que
la méthode await() attend que le compteur passe à zéro, ce qui signifie que tous les
événements se sont passés. Si le compteur est non nul lorsqu’elle est appelée, await()
se bloque jusqu’à ce qu’il atteigne zéro, que le thread appelant soit interrompu ou que
le délai d’attente soit dépassé.
La classe TestHarness du Listing 5.11 illustre deux utilisations fréquentes des loquets.
Elle crée un certain nombre de threads qui exécutent en parallèle une tâche donnée et
utilise deux loquets, une "porte d’entrée" et une "porte de sortie". Le compteur de la
porte d’entrée est initialisé à un, celui de la porte de sortie reçoit le nombre de threads
travailleurs. Chaque thread travailleur commence par attendre à la porte d’entrée, ce qui
garantit qu’aucun d’eux ne commencera à travailler tant que tous les autres ne sont pas
prêts. À la fin de son exécution, chaque thread décrémente le compteur de la porte de
sortie, ce qui permet au thread maître d’attendre qu’ils soient tous terminés et de calculer
le temps écoulé.
Nous utilisons des loquets dans TestHarness au lieu de simplement lancer immédiatement
les threads dès qu’ils sont créés car nous voulons mesurer le temps nécessaire à l’exécu-
tion de la même tâche n fois en parallèle. Si nous avions simplement créé et lancé les
threads, les premiers auraient été "avantagés" par rapport aux derniers et le degré de
contention aurait varié au cours du temps, à mesure que le nombre de threads actifs
aurait augmenté ou diminué. L’utilisation d’une porte d’entrée permet au thread maître de
lancer simultanément tous les threads travailleurs et la porte de sortie lui permet d’attendre
que le dernier thread se termine au lieu d’attendre que chacun d’eux se termine un à un.
Chapitre 5 Briques de base 101
Listing 5.11 : Utilisation de la classe CountDownLatch pour lancer et stopper des threads
et mesurer le temps d’exécution.
public class TestHarness {
public long timeTasks(int nThreads, final Runnable task)
throws InterruptedException {
final CountDownLatch startGate = new CountDownLatch (1);
final CountDownLatch endGate = new CountDownLatch(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
startGate.await();
try {
task.run();
} finally {
endGate.countDown();
}
} catch (InterruptedException ignored) { }
}
};
t.start();
}
long start = System.nanoTime();
startGate.countDown();
endGate.await();
long end = System.nanoTime();
return end-start;
}
}
5.5.2 FutureTask
FutureTask agit également comme un loquet (elle implémente Future, qui décrit un
calcul par paliers [CPJ 4.3.3]). Un calcul représenté par un objet FutureTask est implé-
menté par un Callable, l’équivalent par paliers de Runnable ; il peut être dans l’un des
trois états "attente d’exécution", "en cours d’exécution" ou "terminé". La terminaison
englobe toutes les façons par lesquelles un calcul peut se terminer, que ce soit une
terminaison normale, une annulation ou une exception. Lorsqu’un objet FutureTask est
dans l’état "terminé", il y reste définitivement.
Le comportement de Future.get() dépend de l’état de la tâche. Si elle est terminée,
get() renvoie immédiatement le résultat ; sinon elle se bloque jusqu’à ce que la tâche
passe dans l’état terminé, puis renvoie le résultat ou lance une exception. FutureTask
transfère le résultat du thread qui exécute le calcul au(x) thread(s) qui le récupère(nt) ;
sa spécification garantit que ce transfert constitue une publication correcte du résultat.
FutureTask est utilisée par le framework Executor pour représenter les tâches asyn-
chrones et peut également servir à représenter tout calcul potentiellement long pouvant
être lancé avant que l’on ait besoin du résultat. La classe Preloader du Listing 5.12
l’utilise pour effectuer un calcul coûteux dont le résultat sera nécessaire plus tard. En
102 Les bases Partie I
lançant ce calcul assez tôt, on réduit le temps qu’il faudra attendre ensuite, lorsqu’on
aura vraiment besoin de son résultat.
Listing 5.12 : Utilisation de FutureTask pour précharger des données dont on aura besoin
plus tard.
public class Preloader {
private final FutureTask<ProductInfo> future =
new FutureTask<ProductInfo>(new Callable<ProductInfo>() {
public ProductInfo call() throws DataLoadException {
return loadProductInfo ();
}
});
private final Thread thread = new Thread(future);
public void start() { thread.start(); }
public ProductInfo get()
throws DataLoadException , InterruptedException {
try {
return future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof DataLoadException)
throw (DataLoadException) cause;
else
throw launderThrowable(cause);
}
}
}
La classe Preloader crée un objet FutureTask qui décrit la tâche consistant à charger
les informations d’un produit à partir d’une base de données et un thread qui effectuera
le calcul. Elle fournit une méthode start() pour lancer ce thread car il est déconseillé
de le faire à partir d’un constructeur ou d’un initialisateur statique. Lorsque le programme
aura plus tard besoin du ProductInfo, il pourra appeler get(), qui renvoie les données
chargées si elles sont disponibles ou attend que le chargement soit terminé s’il ne l’est
pas encore.
Les tâches décrites par Callable peuvent lancer des exceptions contrôlées ou non
contrôlées et n’importe quel code peut lancer une instance de Error. Tout ce que lance
le code de la tâche est enveloppé dans un objet ExecutionException et relancé à partir
de Future.get(). Cela complique le code qui appelle get(), non seulement parce qu’il
doit gérer la possibilité d’une ExecutionException (et d’une CancellationException
non contrôlée), mais aussi parce que la raison de l’ExecutionException est renvoyée
sous forme de Throwable, ce qui est peu pratique à gérer.
Lorsque get() lance une ExecutionException dans Preloader, la cause appartiendra à
l’une des trois catégories suivantes : une exception contrôlée lancée par l’objet Callable,
une RuntimeException ou une Error. Nous devons traiter séparément ces trois cas mais
nous utilisons la méthode auxiliaire launderThrowable() du Listing 5.13 pour encap-
suler les parties les plus pénibles du code de gestion des exceptions. Avant d’appeler
cette méthode, Preloader teste les exceptions contrôlées connues et les relance : il ne
Chapitre 5 Briques de base 103
reste donc plus que les exceptions non contrôlées, que Preloader traite en les transmet-
tant à launderThrowable() et en lançant le résultat renvoyé par cet appel. Si l’objet
Throwable passé à launderThrowable() est une Error, la méthode la relance directe-
ment ; si ce n’est pas une RuntimeException, elle lance une IllegalStateException pour
indiquer une erreur de programmation. Il ne reste donc plus que RuntimeException,
que launderThrowable() renvoie à l’appelant, qui, généralement, se contentera de la
relancer.
Listing 5.13 : Coercition d’un objet Throwable non contrôlé en RuntimeException.
/** Si le Throwable est une Error, on le lance ; si c’est une
* RuntimeException, on le renvoie ;
* sinon, on lance IllegalStateException
*/
public static RuntimeException launderThrowable(Throwable t) {
if (t instanceof RuntimeException )
return (RuntimeException ) t;
else if (t instanceof Error)
throw (Error) t;
else
throw new IllegalStateException ("Not unchecked", t);
}
5.5.3 Sémaphores
Les sémaphores servent à contrôler le nombre d’activités pouvant simultanément accéder
à une ressource ou exécuter une certaine action [CPJ 3.4.1]. Les sémaphores permettent
notamment d’implémenter des pools de ressources ou d’imposer une limite à une
collection.
Un objet Semaphore gère un ensemble de jetons virtuels, dont le nombre initial est passé
au constructeur. Les activités peuvent prendre des jetons avec acquire() (s’il en reste) et
les rendre avec release() quand elles ont terminé1. S’il n’y a plus de jeton disponible,
acquire() se bloque jusqu’à ce qu’il y en ait un (ou jusqu’à ce qu’elle soit interrompue
ou que le délai de l’opération ait expiré). Un sémaphore binaire est un sémaphore parti-
culier puisque son nombre de jetons initial est égal à un. Un sémaphore binaire est souvent
utilisé comme mutex pour fournir une sémantique de verrouillage non réentrante : celui
qui détient le seul jeton détient le mutex.
Les sémaphores permettent d’implémenter des pools de ressources comme les pools de
connexions aux bases de données. Bien qu’il soit relativement aisé de mettre en place
un pool de taille fixe et de faire en sorte qu’une demande de ressource échoue s’il est
1. L’implémentation n’utilise pas de véritables objets jetons et Semaphore n’associe pas aux threads
les jetons distribués : un jeton pris par un thread peut être rendu par un autre thread. Vous pouvez donc
considérer acquire() comme une méthode qui consomme un jeton et release() comme une méthode
qui en ajoute un ; un Semaphore n’est pas limité au nombre de jetons qui lui ont été affectés lors de sa
création.
104 Les bases Partie I
vide, on préfère que cette demande se bloque si le pool est vide et se débloque quand il
ne l’est plus. En initialisant un objet Semaphore avec un nombre de jetons égal à la taille
du pool, en prenant un jeton avant de tenter d’obtenir une ressource et en redonnant le
jeton après avoir remis la ressource dans le pool, acquire() se bloquera jusqu’à ce que
le pool ne soit plus vide. Cette technique est utilisée par la classe de tampon borné du
Chapitre 12 (un moyen plus simple de construire un pool bloquant serait d’utiliser une
BlockingQueue pour y stocker les ressources de ce pool).
De même, vous pouvez utiliser un Semaphore pour transformer n’importe quelle collec-
tion en collection bornée et bloquante, comme on le fait dans la classe BoundedHashSet
du Listing 5.14. Le sémaphore est initialisé avec un nombre de jetons égal à la taille
maximale désirée pour la collection et l’opération add() prend un jeton avant d’ajouter
un élément à l’ensemble sous-jacent (si cette opération d’ajout sous-jacente n’ajoute
rien, on redonne immédiatement le jeton). Inversement, une opération de suppression
réussie redonne un jeton, permettant ainsi l’ajout d’autres éléments. L’implémentation
Set sous-jacente ne sait rien de la limite fixée, qui est gérée par BoundedHashSet.
Listing 5.14 : Utilisation d’un Semaphore pour borner une collection.
public class BoundedHashSet<T> {
private final Set<T> set;
private final Semaphore sem;
public BoundedHashSet(int bound) {
this.set = Collections.synchronizedSet(new HashSet<T>());
sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException {
sem.acquire();
boolean wasAdded = false;
try {
wasAdded = set.add(o);
return wasAdded;
} finally {
if (!wasAdded)
sem.release();
}
}
public boolean remove(Object o) {
boolean wasRemoved = set.remove(o);
if (wasRemoved)
sem.release();
return wasRemoved;
}
}
Chapitre 5 Briques de base 105
5.5.4 Barrières
Nous avons vu que les loquets facilitaient le démarrage ou l’attente de terminaison d’un
groupe d’activités apparentées. Les loquets sont des objets éphémères : un loquet qui a
atteint son état terminal ne peut plus être réinitialisé.
Les barrières ressemblent aux loquets car elles permettent de bloquer un groupe de
threads jusqu’à ce qu’un événement survienne [CPJ 4.4.3], mais la différence essentielle
est qu’avec une barrière tous les threads doivent arriver en même temps sur la barrière
pour pouvoir s’exécuter. Les loquets attendent donc des événements tandis que les
barrières attendent les autres threads. Une barrière implémente le protocole que certaines
familles utilisent pour se donner rendez-vous : "Rendez-vous sur la place du Capitole à
18 heures ; attendez que tout le monde arrive et nous verrons où aller ensuite."
CyclicBarrier permet à un nombre donné de parties de se donner des rendez-vous répétés
à un point donné ; cette classe peut être utilisée par les algorithmes parallèles itératifs
qui décomposent un problème en un nombre fixe de sous-problèmes indépendants. Les
threads appellent await() lorsqu’ils atteignent la barrière ; cet appel est bloquant tant
que tous les threads n’ont pas atteint ce point. Lorsque tous les threads se sont rencontrés
sur la barrière, celle-ci s’ouvre et tous les threads sont libérés. Elle se referme alors pour
pouvoir être réutilisée. Si un appel à await() dépasse son temps d’expiration ou si un
thread bloqué par await() est interrompu, la barrière est considérée comme brisée et
tous les appels à await() en attente se terminent avec BrokenBarrierException. Si la
barrière s’est ouverte correctement, await() renvoie un indice d’arrivée unique pour
chaque thread, qui peut être utilisé pour "élire" un leader qui aura un rôle particulier lors
de l’itération suivante. CyclicBarrier permet également de passser une action de barrière
au constructeur, c’est-à-dire un Runnable qui sera exécuté (dans l’un des threads des
sous-tâches) lorsque la barrière se sera ouverte, mais avant que les threads bloqués
soient libérés.
On utilise souvent les barrières dans les simulations où le travail pour calculer une étape
peut s’effectuer en parallèle mais que tout le travail associé à une étape donnée doit être
terminé avant de passer à l’étape suivante. Dans les simulations de particules, par exem-
ple, chaque étape calcule une nouvelle valeur pour la position de chaque particule en
fonction des emplacements et des attributs des autres particules. Attendre sur une barrière
entre chaque calcul garantit que toutes les modifications pour l’étape k se seront terminées
avant de passer à l’étape k + 1.
La classe CellularAutomata du Listing 5.15 utilise une barrière pour effectuer une
simulation de cellules, comme celle du jeu de la vie de Conway (Gardner, 1970). Lorsque
l’on parallélise une simulation, il est généralement impossible d’affecter un thread par
élément (une cellule, dans le cas du jeu de la vie) : cela nécessiterait un trop grand
nombre de threads et le surcoût de leur coordination handicaperait les calculs. On choisit
donc de partitionner le problème en un certain nombre de sous-parties, chacune étant
prise en charge par un thread, et l’on fusionne ensuite les résultats. CellularAutomata
106 Les bases Partie I
partitionne le damier en Ncpu parties, où Ncpu est le nombre de processeurs disponibles,
et affecte chacune d’elles à un thread1.
À chaque étape, les threads calculent les nouvelles valeurs pour toutes les cellules de
leur partie du damier. Quand ils ont tous atteint la barrière, l’action de barrière applique
ces nouvelles valeurs au modèle des données. Après cette action, les threads sont libérés
pour calculer l’étape suivante, qui implique d’appeler la méthode isDone() pour savoir
si d’autres itérations sont nécessaires.
Listing 5.15 : Coordination des calculs avec CyclicBarrier pour une simulation de cellules.
public class CellularAutomata {
private final Board mainBoard;
private final CyclicBarrier barrier;
private final Worker[] workers;
public CellularAutomata (Board board) {
this.mainBoard = board;
int count = Runtime.getRuntime().availableProcessors ();
this.barrier = new CyclicBarrier (count, new Runnable() {
public void run() {
mainBoard.commitNewValues ();
}});
this.workers = new Worker[count];
for (int i = 0; i < count; i++)
workers[i] = new Worker(mainBoard.getSubBoard(count, i));
}
private class Worker implements Runnable {
private final Board board;
public Worker(Board board) { this.board = board; }
public void run() {
while (!board.hasConverged()) {
for (int x = 0; x < board.getMaxX(); x++)
for (int y = 0; y < board.getMaxY(); y++)
board.setNewValue(x, y, computeValue (x, y));
try {
barrier.await();
} catch (InterruptedException ex) {
return;
} catch (BrokenBarrierException ex) {
return;
}
}
}
}
public void start() {
for (int i = 0; i < workers.length; i++)
new Thread(workers[i]).start();
mainBoard.waitForConvergence ();
}
}
1. Pour les traitements comme celui-ci, qui n’effectuent pas d’E/S et n’accèdent à aucune donnée
partagée, Ncpu ou Ncpu+1 threads fourniront le débit optimal ; plus de threads n’amélioreront pas les
performances et peuvent même les dégrader car ils entreront en compétition pour l’accès au processeur
et à la mémoire.
Chapitre 5 Briques de base 107
Exchanger est une autre forme de barrière, formée de deux parties qui échangent des
données [CPJ 3.4.3]. Les objets Exchanger sont utiles lorsque les parties effectuent
des activités asymétriques : lorsqu’un thread remplit un tampon de données et que l’autre
les lit, par exemple. Ces threads peuvent alors utiliser un Exchanger pour se rencontrer
et échanger un tampon plein par un tampon vide. Lorsque deux threads échangent des
données via un Exchanger, l’échange est une publication correcte des deux objets vers
l’autre partie.
Le timing de l’échange dépend de la réactivité nécessaire pour l’application. L’approche
la plus simple est que la tâche qui remplit échange lorsque le tampon est plein et que la
tâche qui vide échange quand il est vide ; cela réduit au minimum le nombre d’échanges,
mais peut retarder le traitement des données si la vitesse d’arrivée des nouvelles
données n’est pas prévisible. Une autre approche serait que la tâche qui remplit échange
lorsque le tampon est plein mais également lorsqu’il est partiellement rempli et qu’un
certain temps s’est écoulé.
5.6 Construction d’un cache efficace et adaptable
Quasiment toutes les applications serveur utilisent une forme ou une autre de cache. La
réutilisation des résultats d’un calcul précédent peut en effet réduire les temps d’attente
et améliorer le débit, au prix d’un peu plus de mémoire utilisée.
Comme de nombreuses autres roues souvent réinventées, la mise en cache semble
souvent plus simple qu’elle ne l’est en réalité. Une implémentation naïve d’un cache
transformera sûrement un problème de performances en problème d’adaptabilité, même
si elle améliore les performances d’une exécution monothread. Dans cette section, nous
allons développer un cache efficace et adaptable pour y placer les résultats d’une
fonction effectuant un calcul coûteux. Commençons par l’approche évidente – un simple
HashMap – et examinons quelques-uns de ses inconvénients en terme de concurrence et
comment y remédier.
L’interface Computable <A,V> du Listing 5.16 décrit une fonction ayant une entrée de
type A et un résultat de type V. La classe ExpensiveFunction, qui implémente Computable,
met longtemps à calculer son résultat et nous aimerions créer une enveloppe Compu-
table qui mémorise les résultats des calculs précédents en encapsulant le processus de
mise en cache (cette technique est appelée mémoïzation).
Listing 5.16 : Première tentative de cache, utilisant HashMap et la synchronisation.
public interface Computable<A, V> {
V compute(A arg) throws InterruptedException ;
}
public class ExpensiveFunction
implements Computable<String, BigInteger> {
public BigInteger compute(String arg) {
108 Les bases Partie I
Listing 5.16 : Première tentative de cache, utilisant HashMap et la synchronisation. (suite)
// Après une longue réflexion...
return new BigInteger(arg);
}
}
public class Memoizer1<A, V> implements Computable<A, V> {
@GuardedBy("this")
private final Map<A, V> cache = new HashMap<A, V>();
private final Computable<A, V> c;
public Memoizer1(Computable<A, V> c) {
this.c = c;
}
public synchronized V compute(A arg) throws InterruptedException {
V result = cache.get(arg);
if (result == null) {
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
A L calcule f(1) U
B L calcule f(2) U
renvoie le résultat
C L U
en cache de f(1)
Figure 5.2
Concurrence médiocre de Memoizer1.
La classe Memoizer1 du Listing 5.16 montre notre première tentative : on utilise un
HashMap pour stocker les résultats des calculs précédents. La méthode compute()
commence par vérifier si le résultat souhaité se trouve déjà dans le cache, auquel cas
elle renvoie la valeur déjà calculée. Sinon elle effectue le calcul et le stocke dans le
HashMap avant de le renvoyer.
HashMap n’étant pas thread-safe, Memoizer1 adopte l’approche prudente qui consiste à
synchroniser toute la méthode compute() pour s’assurer que deux threads ne pourront
pas accéder simultanément au HashMap. Cela garantit la thread safety mais a un effet
évident sur l’adaptabilité puisqu’un seul thread peut exécuter compute() à la fois : si un
autre thread est occupé à calculer un résultat, les autres threads appelant cette méthode
peuvent donc être bloqués pendant un certain temps. Si plusieurs threads sont en attente
pour calculer des valeurs qui n’ont pas encore été calculées, compute() peut, en fait,
Chapitre 5 Briques de base 109
mettre plus de temps à s’exécuter que si elle n’était pas mémoïsée. La Figure 5.2 illustre
ce qui pourrait se passer lorsque plusieurs threads tentent d’utiliser une fonction mémoïsée
de cette façon. Ce n’est pas ce genre d’amélioration des performances que nous espérions
obtenir avec une mise en cache.
La classe Memoizer2 du Listing 5.17 améliore la concurrence désastreuse de Memoizer1
en remplaçant le HashMap par un ConcurrentHashMap. Cette classe étant thread-safe, il
n’y a plus besoin de se synchroniser lorsque l’on accède au hachage sous-jacent, ce qui
élimine la sérialisation induite par la synchronisation de compute() dans Memoizer1.
Listing 5.17 : Remplacement de HashMap par ConcurrentHashMap.
public class Memoizer2<A, V> implements Computable<A, V> {
private final Map<A, V> cache = new ConcurrentHashMap<A, V>();
private final Computable<A, V> c;
public Memoizer2(Computable<A, V> c) { this.c = c; }
public V compute(A arg) throws InterruptedException {
V result = cache.get(arg);
if (result == null) {
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
Memoizer2 offre certainement une meilleure concurrence que Memoizer1 : plusieurs
threads peuvent l’utiliser simultanément. Pourtant, elle souffre encore de quelques
défauts : deux threads appelant compute() au même moment pourraient calculer la
même valeur. Pour une mémoïsation, c’est un comportement assez inefficace puisque le
but d’un cache est justement d’empêcher de répéter plusieurs fois le même calcul. C’est
encore pire pour un mécanisme de cache plus général ; pour un cache d’objet censé ne
fournir qu’une et une seule initialisation, cette vulnérabilité pose également un risque
de sécurité.
Le problème avec Memoizer2 est que, lorsqu’un thread lance un long calcul, les autres
threads ne savent pas que ce calcul est en cours et peuvent donc lancer le même, comme
le montre la Figure 5.3. Nous voudrions donc représenter le fait que "le thread X est
en train de calculer f(27)" afin qu’un autre thread voulant calculer f(27) sache que le
moyen le plus efficace d’obtenir ce résultat consiste à attendre que X ait fini et lui
demande "qu’as-tu trouvé pour f(27) ?".
Nous avons déjà rencontré une classe qui fait exactement cela : FutureTask. On rappelle
que cette classe représente une tâche de calcul qui peut, ou non, s’être déjà terminée
et que FutureTask.get() renvoie immédiatement le résultat du calcul si celui-ci est
disponible ou se bloque jusqu’à ce que le calcul soit terminé puis renvoie son résultat.
110 Les bases Partie I
f(1) absent ajout de
A calcul de f(1)
du cache f(1) au cache
f(1) absent ajout de
B calcul de f(1)
du cache f(1) au cache
Figure 5.3
Deux threads calculant la même valeur avec Memoizer2.
La classe Memoizer3 du Listing 5.18 redéfinit donc le hachage sous-jacent comme un
ConcurrentHashMap<A,Future<V>> au lieu d’un ConcurrentHashMap<A,V>. Elle teste
d’abord si le calcul approprié a été lancé (et non terminé comme dans Memoizer2). Si ce
n’est pas le cas, elle crée un objet FutureTask, l’enregistre dans le hachage et lance le
calcul ; sinon elle attend le résultat du calcul en cours. Ce résultat peut être disponible
immédiatement ou en cours de calcul, mais tout cela est transparent pour celui qui
appelle Future.get().
Listing 5.18 : Enveloppe de mémoïsation utilisant FutureTask.
public class Memoizer3<A, V> implements Computable<A, V> {
private final Map<A, Future<V>> cache
= new ConcurrentHashMap<A, Future<V>>();
private final Computable<A, V> c;
public Memoizer3(Computable<A, V> c) { this.c = c; }
public V compute(final A arg) throws InterruptedException {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedException {
return c.compute(arg);
}
};
FutureTask<V> ft = new FutureTask<V>(eval);
f = ft;
cache.put(arg, ft);
ft.run(); // L’appel à c.compute() a lieu ici
}
try {
return f.get();
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
L’implémentation de Memoizer3 est presque parfaite : elle produit une bonne concurrence
(grâce, essentiellement, à l’excellente concurrence de ConcurrentHashMap), le résultat
est renvoyé de façon efficace s’il est déjà connu et, si le calcul est en cours dans un autre
thread, les autres threads qui arrivent attendent patiemment le résultat. Elle n’a qu’un
seul défaut : il reste un risque que deux threads puissent calculer la même valeur. Ce
risque est bien moins élevé qu’avec Memoizer2, mais le bloc if de compute() étant
Chapitre 5 Briques de base 111
toujours une séquence tester-puis-agir non atomique, deux threads peuvent appeler
compute() avec la même valeur à peu près en même temps, voir tous les deux que le
cache ne contient pas la valeur désirée et donc lancer tous les deux le même calcul. Ce
timing malheureux est illustré par la Figure 5.4.
f(1) absent place Future pour f(1) produit
A calcule f(1)
du cache dans le cache le résultat
f(1) absent place Future pour f(1) produit
B calcule f(1)
du cache dans le cache le résultat
Figure 5.4
Timing malheureux forçant Memoizer3 à calculer deux fois la même valeur.
Memoizer3 est vulnérable à ce problème parce qu’une action composée (ajouter-si-
absent) effectuée sur le hachage sous-jacent ne peut pas être rendue atomique avec le
verrouillage. La classe Memoizer du Listing 5.19 résout ce problème en tirant parti de la
méthode atomique putIfAbsent() de ConcurrentMap.
Listing 5.19 : Implémentation finale de Memoizer.
public class Memoizer<A, V> implements Computable<A, V> {
private final ConcurrentMap <A, Future<V>> cache
= new ConcurrentHashMap <A, Future<V>>();
private final Computable<A, V> c;
public Memoizer(Computable<A, V> c) { this.c = c; }
public V compute(final A arg) throws InterruptedException {
while (true) {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedException {
return c.compute(arg);
}
};
FutureTask<V> ft = new FutureTask<V>(eval);
f = cache.putIfAbsent(arg, ft);
if (f == null) { f = ft; ft.run(); }
}
try {
return f.get();
} catch (CancellationException e) {
cache.remove(arg, f);
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
}
112 Les bases Partie I
Mettre en cache un objet Future au lieu d’une valeur fait courir le risque d’une pollution
du cache : si un calcul est annulé ou échoue, les futures tentatives de calculer ce résultat
indiqueront également une annulation ou une erreur. C’est pour éviter ce problème que
Memoizer supprime l’objet Future du cache s’il s’aperçoit que le calcul a été annulé ; il
pourrait également être judicieux de faire de même si l’on détecte une RuntimeException
et que le calcul peut réussir lors d’une tentative extérieure. Memoizer ne gère pas non plus
l’expiration du cache, mais cela pourrait se faire en utilisant une sous-classe de Future
Task associant une date d’expiration à chaque résultat et en parcourant périodiquement
le cache pour rechercher les entrées expirées (de même, on ne traite pas l’éviction du
cache consistant à supprimer les anciennes entrées afin de libérer de la place pour les
nouvelles et ainsi empêcher que le cache ne consomme trop d’espace mémoire).
Avec cette implémentation concurrente d’un cache désormais complète, nous pouvons
maintenant ajouter un véritable cache à la servlet de factorisation du Chapitre 2, comme
nous l’avions promis. La classe Factorizer du Listing 5.20 utilise Memoizer pour mettre
en cache de façon efficace et adaptative les valeurs déjà calculées.
Listing 5.20 : Servlet de factorisation mettant en cache ses résultats avec Memoizer.
@ThreadSafe
public class Factorizer implements Servlet {
private final Computable<BigInteger, BigInteger[]> c =
new Computable<BigInteger, BigInteger[]>() {
public BigInteger[] compute(BigInteger arg) {
return factor(arg);
}
};
private final Computable<BigInteger, BigInteger[]> cache
= new Memoizer<BigInteger, BigInteger[]>(c);
public void service(ServletRequest req, ServletResponse resp) {
try {
BigInteger i = extractFromRequest (req);
encodeIntoResponse (resp, cache.compute(i));
} catch (InterruptedException e) {
encodeError(resp, "factorization interrupted");
}
}
}
Résumé de la première partie
Nous avons déjà présenté beaucoup de choses ! L’antisèche sur la concurrence qui suit
résume les concepts principaux et les règles essentielles présentées dans cette première
partie.1
• C’est l’état modifiable, idiot 1.
Tous les problèmes de concurrence se ramènent à une coordination des accès à l’état
modifiable. Moins l’état est modifiable, plus il est facile d’assurer la thread safety.
• Créez des champs final sauf s’ils ont besoin d’être modifiables.
• Les objets non modifiables sont automatiquement thread-safe.
Les objets non modifiables simplifient énormément la programmation concurrente.
Ils sont plus simples et plus sûrs et peuvent être partagés librement sans nécessiter de
verrous ni de copies défensives.
• L’encapsulation permet de gérer la complexité.
Vous pourriez écrire un programme thread-safe avec toutes les données dans des
variables globales, mais pourquoi le faire ? L’encapsulation des données dans des objets
facilite la préservation de leurs invariants ; l’encapsulation de la synchronisation dans
des objets facilite le respect de leur politique de synchronisation.
• Protégez chaque variable modifiable par un verrou.
• Protégez toutes les variables d’un invariant par le même verrou.
• Gardez les verrous pendant l’exécution des actions composées.
• Un programme qui accède à une variable modifiable à partir de plusieurs threads et
sans synchronisation est un programme faux.
• Ne croyez pas les raisonnements subtils qui vous expliquent pourquoi vous n’avez pas
besoin de synchroniser.
• Ajoutez la thread safety à la phase de conception, ou indiquez explicitement que
votre classe n’est pas thread-safe.
• Documentez votre politique de synchronisation.
1. En 1992, James Carville, l’un des stratèges de la victoire de Bill Clinton, avait affiché au QG de
campagne un pense-bête devenu légendaire, “It’s the economy, stupid!”, pour insister sur ce message
lors de la campagne.
II
Structuration
des applications concurrentes
6
Exécution des tâches
La plupart des applications concurrentes sont organisées autour de l’exécution de tâches,
que l’on peut considérer comme des unités de travail abstraites. Diviser une application
en plusieurs tâches simplifie l’organisation du programme et facilite la découverte des
erreurs grâce aux frontières naturelles séparant les différentes transactions. Cette division
encourage également la concurrence en fournissant une structure naturelle permettant
de paralléliser le travail.
6.1 Exécution des tâches dans les threads
La première étape pour organiser un programme autour de l’exécution de tâches consiste
à identifier les frontières entre ces tâches. Dans l’idéal, les tâches sont des activités
indépendantes, c’est-à-dire des opérations qui ne dépendent ni de l’état, ni du résultat,
ni des effets de bord des autres tâches. Cette indépendance facilite la concurrence puisque
les tâches indépendantes peuvent s’exécuter en parallèle si l’on dispose des ressources
de traitement adéquates. Pour disposer de plus de souplesse dans l’ordonnancement et
la répartition de la charge entre ces tâches, chacune devrait également représenter une
petite fraction des possibilités du traitement de l’application.
Les applications serveur doivent fournir un bon débit de données et une réactivité correcte
sous une charge normale. Les fournisseurs d’applications veulent des programmes permet-
tant de supporter autant d’utilisateurs que possible afin de réduire d’autant les coûts par
utilisateur ; ces utilisateurs veulent évidemment obtenir rapidement les réponses qu’ils
demandent. En outre, les applications doivent non pas se dégrader brutalement lorsqu’elles
sont surchargées mais réagir le mieux possible. Tous ces objectifs peuvent être atteints en
choisissant de bonnes frontières entre les tâches et en utilisant une politique raisonnable
d’exécution des tâches (voir la section 6.2.2).
La plupart des applications serveur offrent un choix naturel pour les frontières entre
tâches : les différentes requêtes des clients. Les serveurs web, de courrier, de fichiers,
118 Structuration des applications concurrentes Partie II
les conteneurs EJB et les serveurs de bases de données reçoivent tous des requêtes de
clients distants via des connexions réseau. Utiliser ces différentes requêtes comme des
frontières de tâches permet généralement d’obtenir à la fois des tâches indépendantes et
de taille appropriée. Le résultat de la soumission d’un message à un serveur de courrier,
par exemple, n’est pas affecté par les autres messages qui sont traités en même temps,
et la prise en charge d’un simple message ne nécessite généralement qu’un très petit
pourcentage de la capacité totale du serveur.
6.1.1 Exécution séquentielle des tâches
Il existe un certain nombre de politiques possibles pour ordonnancer les tâches au sein
d’une application ; parmi elles, certaines exploitent mieux la concurrence que d’autres.
La plus simple consiste à exécuter les tâches séquentiellement dans un seul thread. La
classe SingleThreadWebServer du Listing 6.1 traite ses tâches – des requêtes HTTP
arrivant sur le port 80 – en séquence. Les détails du traitement de la requête ne sont pas
importants ; nous ne nous intéressons ici qu’à la concurrence des différentes politiques
d’ordonnancement.
Listing 6.1 : Serveur web séquentiel.
class SingleThreadWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
Socket connection = socket.accept();
handleRequest(connection);
}
}
}
SingleThreadedWebServer est simple et correcte d’un point de vue théorique, mais
serait très inefficace en production car elle ne peut gérer qu’une seule requête à la fois.
Le thread principal alterne constamment entre accepter des connexions et traiter la
requête associée : pendant que le serveur traite une requête, les nouvelles connexions
doivent attendre qu’il ait fini ce traitement et qu’il appelle à nouveau accept(). Cela
peut fonctionner si le traitement des requêtes est suffisamment rapide pour que handle
Request() se termine immédiatement, mais cette hypothèse ne reflète pas du tout la
situation des serveurs web actuels.
Traiter une requête web implique un mélange de calculs et d’opérations d’E/S. Le
serveur doit lire dans un socket pour obtenir la requête et y écrire pour envoyer la
réponse ; ces opérations peuvent être bloquantes en cas de congestion du réseau ou de
problèmes de connexion. Il peut également effectuer des E/S sur des fichiers ou lancer
des requêtes de bases de données, qui peuvent elles aussi être bloquantes. Avec un
serveur monothread, un blocage ne fait pas que retarder le traitement de la requête en
cours : il empêche également celui des requêtes en attente. Si une requête se bloque
pendant un temps très long, les utilisateurs penseront que le serveur n’est plus disponible
Chapitre 6 Exécution des tâches 119
puisqu’il ne semble plus répondre. En outre, les ressources sont mal utilisées puisque le
processeur reste inactif pendant que l’unique thread attend que ses E/S se terminent.
Pour les applications serveur, le traitement séquentiel fournit rarement un bon débit ou
une réactivité correcte. Il existe des exceptions – lorsqu’il y a très peu de tâches et qu’elles
durent longtemps, ou quand le serveur ne sert qu’un seul client qui n’envoie qu’une
seule requête à la fois – mais la plupart des applications serveur ne fonctionnent pas de
cette façon1.
6.1.2 Création explicite de threads pour les tâches
Une approche plus réactive consiste à créer un nouveau thread pour répondre à chaque
nouvelle requête, comme dans le Listing 6.2.
Listing 6.2 : Serveur web lançant un thread par requête.
class ThreadPerTaskWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
new Thread(task).start();
}
}
}
La structure de la classe ThreadPerTaskWebServer ressemble à celle de la version
monothread – le thread principal continue d’alterner entre la réception d’une connexion
entrante et le traitement de la requête. Mais, ici, la boucle principale crée un nouveau
thread pour chaque connexion afin de traiter la requête au lieu de le faire dans le thread
principal. Ceci a trois conséquences importantes :
m Le thread principal se décharge du traitement de la tâche, ce qui permet à la boucle
principale de poursuivre et de venir attendre plus rapidement la connexion entrante
suivante. Ceci autorise de nouvelles connexions alors que les requêtes sont en cours
de traitement, ce qui améliore les temps de réponse.
m Les tâches peuvent être traitées en parallèle, ce qui permet de traiter simultanément
plusieurs requêtes. Ceci améliore le débit lorsqu’il y a plusieurs processeurs ou si
des tâches doivent se bloquer en attente d’une opération d’E/S, de l’acquisition d’un
verrou ou de la disponibilité d’une ressource, par exemple.
1. Dans certaines situations, le traitement séquentiel offre des avantages en termes de simplicité et de
sécurité ; la plupart des interfaces graphiques traitent séquentiellement les tâches dans un seul thread.
Nous reviendrons sur le modèle séquentiel au Chapitre 9.
120 Structuration des applications concurrentes Partie II
m Le code du traitement de la tâche doit être thread-safe car il peut être invoqué de
façon concurrente par plusieurs tâches.
En cas de charge modérée, cette approche est plus efficace qu’une exécution séquentielle.
Tant que la fréquence d’arrivée des requêtes ne dépasse pas les capacités de traitement
du serveur, elle offre une meilleure réactivité et un débit supérieur.
6.1.3 Inconvénients d’une création illimitée de threads
Cependant, dans un environnement de production, l’approche "un thread par tâche" a quel-
ques inconvénients pratiques, notamment lorsqu’elle peut produire un grand nombre de
threads :
m Surcoût dû au cycle de vie des threads. La création d’un thread et sa suppression
ne sont pas gratuites. Bien que le surcoût dépende des plates-formes, la création d’un
thread prend du temps, induit une certaine latence dans le traitement de la requête et
nécessite un traitement de la part de la JVM et du système d’exploitation. Si les
requêtes sont fréquentes et légères, comme dans la plupart des applications serveur,
la création d’un thread par requête peut consommer un nombre non négligeable de
ressources.
m Consommation des ressources. Les threads actifs consomment des ressources du
système, notamment la mémoire. S’il y a plus de threads en cours d’exécution qu’il
n’y a de processeurs disponibles, certains threads resteront inactifs, ce qui peut
consommer beaucoup de mémoire et surcharger le ramasse-miettes. En outre, le fait
que de nombreux threads concourent pour l’accès aux processeurs peut également
avoir des répercussions sur les performances. Si vous avez suffisamment de threads
pour garder tous les processeurs occupés, en créer plus n’améliorera rien, voire
dégradera les performances.
m Stabilité. Il y a une limite sur le nombre de threads qui peuvent être créés. Cette
limite varie en fonction des plates-formes, des paramètres d’appels de la JVM, de la
taille de pile demandée dans le constructeur de Thread et des limites imposées aux
threads par le système d’exploitation1. Lorsque vous atteignerez cette limite, vous
obtiendrez très probablement une exception OutOfMemoryError. Tenter de se rétablir
de cette erreur est très risqué ; il est bien plus simple de structurer votre programme
afin d’éviter d’atteindre cette limite.
1. Sur des machines 32 bits, un facteur limitant important est l’espace d’adressage pour les piles des
threads. Chaque thread gère deux piles d’exécution : une pour le code Java, l’autre pour le code natif.
Généralement, la JVM produit par défaut une taille de pile combinée d’environ 512 kilo-octets (vous
pouvez changer cette valeur avec le paramètre -Xss de la JVM ou lors de l’appel du constructeur de
Thread). Si vous divisez les 232 adresses par la taille de la pile de chaque thread, vous obtenez une
limite de quelques milliers ou dizaines de milliers de threads. D’autres facteurs, comme les limites du
système d’exploitation, peuvent imposer des limites plus contraignantes.
Chapitre 6 Exécution des tâches 121
Jusqu’à un certain point, ajouter plus de threads permet d’améliorer le débit mais, au-
delà de ce point, créer des threads supplémentaires ne fera que ralentir, et en créer un de
trop peut totalement empêcher l’application de fonctionner. Le meilleur moyen de se
protéger de ce danger consiste à fixer une limite au nombre de threads qu’un programme
peut créer et à tester sérieusement l’application pour vérifier qu’elle ne tombera pas à
court de ressources, même lorsque cette limite sera atteinte.
Le problème de l’approche "un thread par tâche" est que la seule limite imposée au nombre
de threads créés est la fréquence à laquelle les clients distants peuvent lancer des requêtes
HTTP. Comme tous les autres problèmes liés à la concurrence, la création infinie de
threads peut sembler fonctionner parfaitement au cours des phases de prototypage et
de développement, ce qui n’empêchera pas le problème d’apparaître lorsque l’application
sera déployée en production et soumise à une forte charge.
Un utilisateur pervers, voire des utilisateurs ordinaires, peut donc faire planter votre
serveur web en le soumettant à une charge trop forte. Pour une application serveur
supposée fournir une haute disponibilité et se dégrader correctement en cas de charge
importante, il s’agit d’un sérieux défaut.
6.2 Le framework Executor
Les tâches sont des unités logiques de travail et les threads sont un mécanisme grâce
auquel ces tâches peuvent s’exécuter de façon asynchrone. Nous avons étudié deux
politiques d’exécution des tâches à l’aide des threads – l’exécution séquentielle des tâches
dans un seul thread et l’exécution de chaque tâche dans son propre thread. Toutes les
deux ont de sévères limitations : l’approche séquentielle implique une mauvaise réactivité
et un faible débit et l’approche "une tâche par thread" souffre d’une mauvaise gestion des
ressources.
Au Chapitre 5, nous avons vu comment utiliser des files de taille fixe pour empêcher
qu’une application surchargée ne soit à court de mémoire. Un pool de threads offrant
les mêmes avantages pour la gestion des threads, java.util.concurrent en fournit une
implémentation dans le cadre du framework Executor. Comme le montre le Listing 6.3,
la principale abstraction de l’exécution des tâches dans la bibliothèque des classes Java
est non pas Thread mais Executor.
Listing 6.3 : Interface Executor.
public interface Executor {
void execute(Runnable command);
}
Executor est peut-être une interface simple, mais elle forme les fondements d’un
framework souple et puissant pour l’exécution asynchrone des tâches sous un grand
nombre de politiques d’exécution des tâches. Elle fournit un moyen standard pour
122 Structuration des applications concurrentes Partie II
découpler la soumission des tâches de leur exécution en les décrivant comme des objets
Runnable. Les implémentations de Executor fournissent également un contrôle du
cycle de vie et des points d’ancrage permettant d’ajouter une collecte des statistiques,
ainsi qu’une gestion et une surveillance des applications.
Executor repose sur le patron producteur-consommateur, où les activités qui soumettent
des tâches sont les producteurs (qui produisent le travail à faire) et les threads qui
exécutent ces tâches sont les consommateurs (qui consomment ce travail). L’utilisation
d’un Executor est, généralement, le moyen le plus simple d’implémenter une conception
de type producteur-consommateur dans une application.
6.2.1 Exemple : serveur web utilisant Executor
La création d’un serveur web à partir d’un Executor est très simple. La classe Task
ExecutionWebServer du Listing 6.4 remplace la création des threads, qui était codée en
dur dans la version précédente, par un Executor. Ici, nous utilisons l’une de ses implé-
mentations standard, un pool de threads de taille fixe, avec 100 threads.
Dans TaskExecutionWebServer, la soumission de la tâche de gestion d’une requête est
séparée de son exécution grâce à un Executor et son comportement peut être modifié en
utilisant simplement une autre implémentation de Executor. Le changement d’implé-
mentation ou de configuration d’Executor est une opération bien moins lourde que
modifier la façon dont les tâches sont soumises ; généralement, la configuration est un
événement unique qui peut aisément être présenté lors du déploiement de l’application,
alors que le code de soumission des tâches a tendance à être disséminé un peu partout
dans le programme et à être plus difficile à mettre en évidence.
Listing 6.4 : Serveur web utilisant un pool de threads.
class TaskExecutionWebServer {
private static final int NTHREADS = 100;
private static final Executor exec
= Executors.newFixedThreadPool(NTHREADS);
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
exec.execute(task);
}
}
}
TaskExecutionWebServer peut être facilement modifié pour qu’il se comporte comme
ThreadPerTaskWebServer : comme le montre le Listing 6.5, il suffit d’utiliser un
Executor qui crée un nouveau thread pour chaque requête, ce qui est très simple.
Chapitre 6 Exécution des tâches 123
Listing 6.5 : Executor lançant un nouveau thread pour chaque tâche.
public class ThreadPerTaskExecutor implements Executor {
public void execute(Runnable r) {
new Thread(r).start();
};
}
De même, il est tout aussi simple d’écrire un Executor pour que TaskExecutionWeb
Server se comporte comme la version monothread, en exécutant chaque tâche de façon
synchrone dans execute(), comme le montre la classe WithinThreadExecutor du
Listing 6.6.
Listing 6.6 : Executor exécutant les tâches de façon synchrone dans le thread appelant.
public class WithinThreadExecutor implements Executor {
public void execute(Runnable r) {
r.run();
};
}
6.2.2 Politiques d’exécution
L’intérêt de séparer la soumission de l’exécution est que cela permet de spécifier facile-
ment, et donc de modifier simplement, la politique d’exécution d’une classe de tâches.
Une politique d’exécution répond aux questions "quoi, où, quand et comment" concernant
l’exécution des tâches :
m Dans quel thread les tâches s’exécuteront-elles ?
m Dans quel ordre les tâches devront-elles s’exécuter (FIFO, LIFO, selon leurs priorités) ?
m Combien de tâches peuvent s’exécuter simultanément ?
m Combien de tâches peuvent être mises en attente d’exécution ?
m Si une tâche doit être rejetée parce que le système est surchargé, quelle sera la
victime et comment l’application sera-t-elle prévenue ?
m Quelles actions faut-il faire avant ou après l’exécution d’une tâche ?
Les politiques d’exécution sont un outil de gestion des ressources et la politique opti-
male dépend des ressources de calcul disponibles et de la qualité du service recherchée.
En limitant le nombre de tâches simultanées, vous pouvez garantir que l’application
n’échouera pas si les ressources sont épuisées et que ses performances ne souffriront
pas de problèmes dus à la concurrence pour des ressources en quantités limitées 1.
1. Ceci est analogue à l’un des rôles d’un moniteur de transactions dans une application d’entreprise ;
il peut contrôler la fréquence à laquelle les transactions peuvent être traitées, afin de ne pas épuiser des
ressources limitées.
124 Structuration des applications concurrentes Partie II
Séparer la spécification de la politique d’exécution de la soumission des tâches permet
de choisir une politique d’exécution lors du déploiement adaptée au matériel disponible.
À chaque fois que vous voyez un code de la forme :
new Thread(runnable).start()
et que vous pensez avoir besoin d’une politique d’exécution plus souple, réfléchissez
sérieusement à son remplacement par l’utilisation d’un Executor.
6.2.3 Pools de threads
Un pool de threads gère un ensemble homogène de threads travailleurs. Il est étroitement
lié à une file contenant les tâches en attente d’exécution. La vie des threads travailleurs
est simple : demander la tâche suivante dans la file, l’exécuter et revenir attendre une
autre tâche.
L’exécution des tâches avec un pool de threads présente un certain nombre d’avantages
par rapport à l’approche "un thread par tâche". La réutilisation d’un thread existant au
lieu d’en créer un nouveau amortit les coûts de création et de suppression des threads en
les répartissant sur plusieurs requêtes. En outre, le thread travailleur existant souvent
déjà lorsque la requête arrive, le temps de latence associé à la création du thread ne
retarde pas l’exécution de la tâche, d’où une réactivité accrue. En choisissant soigneu-
sement la taille du pool, vous pouvez avoir suffisamment de threads pour occuper les
processeurs tout en évitant que l’application soit à court de mémoire ou se plante à
cause d’une trop forte compétition pour les ressources.
La bibliothèque standard fournit une implémentation flexible des pools de threads, ainsi
que quelques configurations prédéfinies assez utiles. Pour créer un pool, vous pouvez
appeler l’une des méthodes fabriques statiques de la classe Executors :
m newFixedThreadPool(). Crée un pool de taille fixe qui crée les threads à mesure
que les tâches sont soumises jusqu’à atteindre la taille maximale du pool, puis qui
tente de garder constante la taille de ce pool (en créant un nouveau thread lorsqu’un
thread meurt à cause d’une Exception inattendue).
m newCachedThreadPool(). Crée un pool de threads en cache, ce qui donne plus de
souplesse pour supprimer les threads inactifs lorsque la taille courante du pool dépasse
la demande de traitement et pour ajouter de nouveaux threads lorsque cette demande
augmente, tout en ne fixant pas de limite à la taille du pool.
m newSingleThreadExecutor(). Crée une instance Executor monothread qui ne produit
qu’un seul thread travailleur pour traiter les tâches, en le remplaçant s’il meurt
Chapitre 6 Exécution des tâches 125
accidentellement. Les tâches sont traitées séquentiellement selon l’ordre imposé par
la file d’attente des tâches (FIFO, LIFO, ordre des priorités)1.
m newScheduledThreadPool(). Crée un pool de threads de taille fixe, permettant de
différer ou de répéter l’exécution des tâches, comme Timer (voir la section 6.2.5).
Les fabriques newFixedThreadPool() et newCachedThreadPool() renvoient des instances
de la classe générale ThreadPoolExecutor qui peuvent également servir directement à
construire des "exécuteurs" plus spécialisés. Nous présenterons plus en détail les
options de configuration des pools de threads au Chapitre 8.
Le serveur web de TaskExecutionWebServer utilise un Executor avec un pool limité de
threads travailleurs. Soumettre une tâche avec execute() l’ajoute à la file d’attente dans
laquelle les threads viennent sans cesse chercher des tâches pour les exécuter.
Passer d’une politique "un thread par tâche" à une politique utilisant un pool a un effet
non négligeable sur la stabilité de l’application : le serveur web ne souffrira plus lorsqu’il
sera soumis à une charge importante2. En outre, son comportement se dégradera moins
violemment puisqu’il ne crée pas des milliers de threads qui combattent pour des
ressources processeur et mémoire limitées. Enfin, l’utilisation d’un Executor ouvre la
porte à toutes sortes d’opportunités de configuration, de gestion, de surveillance, de
journalisation, de suivi des erreurs et autres possibilités qui sont bien plus difficiles à
ajouter sans un framework d’exécution des tâches.
6.2.4 Cycle de vie d’un Executor
Nous avons vu comment créer un Executor mais pas comment l’arrêter. Une implé-
mentation de Executor crée des threads pour traiter des tâches mais, la JVM ne pouvant
pas se terminer tant que tous les threads (non démons) ne se sont pas terminés, ne pas
arrêter un Executor empêche l’arrêt de la JVM.
Un Executor traitant les tâches de façon asynchrone, l’état à un instant donné des
tâches soumises n’est pas évident. Certaines se sont peut-être terminées, certaines
peuvent être en cours d’exécution et d’autres peuvent être en attente d’exécution. Pour
arrêter une application, il y a une marge entre un arrêt en douceur (finir ce qui a été
lancé et ne pas accepter de nouveau travail) et un arrêt brutal (éteindre la machine), avec
1. Les instance Executor monothreads fournissent également une synchronisation interne suffisante
pour garantir que toute écriture en mémoire par les tâches sera visible par les tâches suivantes ; ceci
signifie que les objets peuvent être confinés en toute sécurité au "thread tâche", même si ce thread est
remplacé épisodiquement par un autre.
2. Même s’il ne souffrira plus à cause de la création d’un nombre excessif de threads, il peut quand
même (bien que ce soit plus difficile) arriver à court de mémoire si la fréquence d’arrivée des tâches
est supérieure à celle de leur traitement pendant une période suffisamment longue, à cause de
l’augmentation de la taille de la file des Runnable en attente d’exécution. Avec le framework Executor,
ce problème peut se résoudre en utilisant une file d’attente de taille fixe – voir la section 8.3.2.
126 Structuration des applications concurrentes Partie II
plusieurs degrés entre les deux. Les Executor fournissant un service aux applications,
ils devraient également pouvoir être arrêtés, en douceur et brutalement, et renvoyer des
informations à l’application sur l’état des tâches affectées par cet arrêt.
Pour résoudre le problème du cycle de vie du service d’exécution, l’interface Executor
Service étend Executor en lui ajoutant un certain nombre de méthodes dédiées à la
gestion du cycle de vie, présentées dans le Listing 6.7 (elle ajoute également certaines
méthodes utilitaires pour la soumission des tâches).
Listing 6.7 : Méthodes de ExecutorService pour le cycle de vie.
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination (long timeout, TimeUnit unit)
throws InterruptedException ;
// ... méthodes utilitaires pour la soumission des tâches
}
Le cycle de vie qu’implique ExecutorService a trois états : en cours d’exécution, en
cours d’arrêt et terminé. Les objets ExecutorService sont initialement créés dans l’état
en cours d’exécution. La méthode shutdown() lance un arrêt en douceur : aucune nouvelle
tâche n’est acceptée, mais les tâches déjà soumises sont autorisées à se terminer – même
celles qui n’ont pas encore commencé leur exécution. La méthode shutdownNow() lance
un arrêt brutal : elle tente d’annuler les tâches en attente et ne lance aucune des tâches
qui sont dans la file et qui n’ont pas commencé.
Les tâches soumises à un ExecutorService après son arrêt sont gérées par le gestion-
naire d’exécution rejetée (voir la section 8.3.3), qui peut supprimer la tâche sans prévenir
ou forcer execute() à lancer l’exception non controlée RejectedExecutionException.
Lorsque toutes les tâches se sont terminées, l’objet ExecutorService passe dans l’état
terminé. Vous pouvez attendre qu’il atteigne cet état en appelant la méthode await
Termination() ou en l’interrogeant avec isTerminated() pour savoir s’il est terminé.
En général, on fait suivre immédiatement l’appel à shutdown() par awaitTermination(),
afin d’obtenir l’effet d’un arrêt synchrone de ExecutorService (l’arrêt de Executor et
l’annulation de tâche sont présentés plus en détail au Chapitre 7).
La classe LifecycleWebServer du Listing 6.8 ajoute un cycle de vie à notre serveur
web. Ce dernier peut désormais être arrêté de deux façons : par programme en appelant
stop() ou via une requête client en envoyant au serveur une requête HTTP respectant
un certain format.
Listing 6.8 : Serveur web avec cycle de vie.
class LifecycleWebServer {
private final ExecutorService exec = ...;
public void start() throws IOException {
Chapitre 6 Exécution des tâches 127
ServerSocket socket = new ServerSocket(80);
while (!exec.isShutdown()) {
try {
final Socket conn = socket.accept();
exec.execute(new Runnable() {
public void run() { handleRequest(conn); }
});
} catch (RejectedExecutionException e) {
if (!exec.isShutdown())
log("task submission rejected", e);
}
}
}
public void stop() { exec.shutdown(); }
void handleRequest (Socket connection) {
Request req = readRequest(connection);
if (isShutdownRequest(req))
stop();
else
dispatchRequest(req);
}
}
6.2.5 Tâches différées et périodiques
La classe utilitaire Timer gère l’exécution des tâches différées ("lancer cette tâche dans
100 ms") et périodiques ("lancer cette tâche toutes les 10 ms"). Cependant, elle a
quelques inconvénients et il est préférable d’utiliser ScheduledThreadPoolExecutor à
la place1. Pour construire un objet ScheduledThreadPoolExecutor, on peut utiliser son
constructeur ou la méthode fabrique newScheduledThreadPool().
Un Timer ne crée qu’un seul thread pour exécuter les tâches qui lui sont confiées. Si l’une
de ces tâches met trop de temps à s’exécuter, cela peut perturber la précision du timing des
autres TimerTask.
Si une tâche TimerTask est planifiée pour s’exécuter toutes les 10 ms et qu’une autre
TimerTask met 40 ms pour terminer son exécution, par exemple, la tâche récurrente
sera soit appelée quatre fois de suite après la fin de la tâche longue soit "manquera"
totalement quatre appels (selon qu’elle a été planifiée pour une fréquence donnée ou
pour un délai fixé). Les pools de thread résolvent cette limitation en permettant d’utiliser
plusieurs threads pour exécuter des tâches différées et planifiées.
Un autre problème de Timer est son comportement médiocre lorsqu’une TimerTask
lance une exception non contrôlée : le thread Timer ne capturant pas l’exception, une
exception non contrôlée lancée par une TimerTask met fin au planificateur. En outre,
dans cette situation, Timer ne ressuscite pas le thread : il suppose à tort que l’objet Timer
tout entier a été annulé. En ce cas, les TimerTasks déjà planifiées mais pas encore
1. Timer ne permet d’ordonnancer les tâches que de façon absolue, pas relative, ce qui les rend dépen-
dantes des modifications de l’horloge système ; ScheduledThreadPoolExecutor n’utilise qu’un temps
relatif.
128 Structuration des applications concurrentes Partie II
exécutées ne seront jamais lancées et les nouvelles tâches ne pourront pas être planifiées
(ce problème, appelé "fuite de thread", est décrit dans la section 7.3, en même temps
que les techniques permettant de l’éviter).
La classe OutOfTime du Listing 6.9 illustre la façon dont un Timer peut être perturbé de
cette manière et, comme un problème ne vient jamais seul, comment l’objet Timer
partage cette confusion avec le malheureux client suivant qui tente de soumettre une
nouvelle TimerTask. Vous pourriez vous attendre à ce que ce programme s’exécute
pendant 6 secondes avant de se terminer alors qu’en fait il se terminera après 1 seconde
avec une exception IllegalStateException associée au message "Timer already
cancelled". ScheduledThreadPoolExecutor sachant correctement gérer ces tâches qui
se comportent mal, il y a peu de raisons d’utiliser Timer à partir de Java 5.0.
Listing 6.9 : Classe illustrant le comportement confus de Timer.
public class OutOfTime {
public static void main(String[] args) throws Exception {
Timer timer = new Timer();
timer.schedule(new ThrowTask(), 1);
SECONDS.sleep(1);
timer.schedule(new ThrowTask(), 1);
SECONDS.sleep(5);
}
static class ThrowTask extends TimerTask {
public void run() { throw new RuntimeException(); }
}
}
Si vous devez mettre en place un service de planification, vous pouvez quand même
tirer parti de la bibliothèque standard en utilisant DelayQueue, une implémentation de
BlockingQueue fournissant les fonctionnalités de planification de ScheduledThread
PoolExecutor. Un objet DelayQueue gère une collection d’objets Delayed, associés à
un délai : DelayQueue ne vous autorise à prendre un élément que si son délai a expiré.
Les objets sortent d’une DelayQueue dans l’ordre de leur délai.
6.3 Trouver un parallélisme exploitable
Le framework Executor facilite la spécification d’une politique d’exécution mais, pour
utiliser un Executor, vous devez décrire votre tâche sous la forme d’un objet Runnable.
Dans la plupart des applications serveur, le critère de séparation des tâches est évident :
c’est une requête client. Parfois, dans la plupart des applications classiques notamment,
il n’est pas si facile de trouver une bonne séparation des tâches. Même dans les applica-
tions serveur, il peut également exister un parallélisme exploitable dans une même requête
client ; c’est parfois le cas avec les serveurs de bases de données (pour plus de détails
sur les forces en présence lors du choix de la séparation des tâches, voir [CPJ 4.4.1.1]).
Chapitre 6 Exécution des tâches 129
Dans cette section, nous allons développer plusieurs versions d’un composant permettant
différents degrés de concurrence. Ce composant est la partie consacrée au rendu d’une
page dans un navigateur : il prend une page HTML et la représente dans un tampon
image. Pour simplifier, nous supposerons que le code HTML ne contient que du texte
balisé, parsemé d’éléments image ayant des dimensions et des URL préétablies.
6.3.1 Exemple : rendu séquentiel d’une page
L’approche la plus simple consiste à traiter séquentiellement le document HTML. À
chaque fois que l’on rencontre un marqueur, on le rend dans le tampon image ; lorsque
l’on rencontre un marqueur image, on récupère l’image sur le réseau et on la dessine
également dans le tampon. Cette technique est facile à implémenter et ne nécessite
qu’une seule manipulation de chaque élément d’entrée (il n’y a même pas besoin de
mettre le document dans un tampon), mais elle risque d’ennuyer l’utilisateur, qui devra
attendre longtemps avant que tout le texte soit affiché. Une approche moins ennuyeuse,
mais toujours séquentielle, consiste à afficher d’abord les éléments textuels en laissant
des emplacements rectangulaires pour les images puis, après avoir effectué cette
première passe, à revenir télécharger les images et à les dessiner dans les rectangles qui
leur sont associés. C’est ce que fait la classe SingleThreadRenderer du Listing 6.10.
Listing 6.10 : Affichage séquentiel des éléments d’une page.
public class SingleThreadRenderer {
void renderPage(CharSequence source) {
renderText(source);
List<ImageData> imageData = new ArrayList<ImageData>();
for (ImageInfo imageInfo : scanForImageInfo (source))
imageData.add(imageInfo.downloadImage());
for (ImageData data : imageData)
renderImage(data);
}
}
Le téléchargement d’une image implique d’attendre la fin d’une opération d’E/S pendant
laquelle le processeur est peu actif. L’approche séquentielle risque donc de sous-employer
le processeur et de faire attendre plus que nécessaire l’utilisateur. Nous pouvons optimiser
l’utilisation du CPU et la réactivité du composant en découpant le problème en tâches
indépendantes qui pourront s’exécuter en parallèle.
6.3.2 Tâches partielles : Callable et Future
Le framework Executor utilise Runnable comme représentation de base pour les tâches.
Runnable est une abstraction assez limitée : run() ne peut pas renvoyer de valeur ni
lancer d’exception contrôlée, bien qu’elle puisse avoir des effets de bord comme écrire
dans un fichier journal ou stocker un résultat dans une structure de données partagée.
En pratique, de nombreuses tâches sont des calculs différés – exécuter une requête sur
une base de données, télécharger une ressource sur le réseau ou calculer une fonction
130 Structuration des applications concurrentes Partie II
compliquée. Pour ce genre de tâche, Callable est une meilleure abstraction : son point
d’entrée principal, call(), renverra une valeur et peut lancer une exception1. Executors
contient plusieurs méthodes utilitaires permettant d’envelopper dans un objet Callable
d’autres types de tâches, comme Runnable et java.security.PrivilegedAction.
Runnable et Callable décrivent des tâches abstraites de calcul. Ces tâches sont généra-
lement finies : elles ont un point de départ bien déterminé et finissent par se terminer. Le
cycle de vie d’une tâche exécutée par un Executor passe par quatre phases : créée,
soumise, lancée et terminée. Les tâches pouvant s’exécuter pendant un temps assez
long, nous voulons également pouvoir annuler une tâche. Dans le framework Executor,
les tâches qui ont été soumises mais pas encore lancées peuvent toujours être annulées
et celles qui ont été lancées peuvent parfois être annulées si elles répondent à une inter-
ruption. L’annulation d’une tâche qui s’est déjà terminée n’a aucun effet (l’annulation
sera présentée plus en détail au Chapitre 7).
Future représente le cycle de vie d’une tâche et fournit des méthodes pour tester si une
tâche s’est terminée ou a été exécutée, pour récupérer son résultat et pour annuler la
tâche. Les interfaces Callable et Future sont présentées dans le Listing 6.11. La spéci-
fication de Future implique que le cycle de vie d’une tâche progresse toujours vers
l’avant, pas en arrière – exactement comme le cycle de vie ExecutorService. Le compor-
tement de get() dépend de l’état de la tâche (pas encore lancée, en cours d’exécution
ou terminée). Cette méthode se termine immédiatement ou lance une Exception si la
tâche s’est déjà terminée mais, dans le cas contraire, elle se bloque jusqu’à la fin de
la tâche. Si la tâche se termine en lançant une exception, get() relance l’exception en
l’enveloppant dans ExecutionException ; si elle a été annulée, get() lance Cancellation
Exception. Si get() lance ExecutionException, l’exception sous-jacente peut être
récupérée par getCause().
Listing 6.11 : Interfaces Callable et Future.
public interface Callable<V> {
V call() throws Exception;
}
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning );
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException , ExecutionException ,
CancellationException ;
V get(long timeout, TimeUnit unit) throws InterruptedException,
ExecutionException, CancellationException ,
TimeoutException ;
}
Il y a plusieurs moyens de créer un objet Future pour décrire une tâche. Les méthodes
submit() de ExecutorService renvoyant toutes un Future, vous pouvez soumettre un
1. Pour indiquer qu’une tâche ne renverra pas de valeur, on utilise Callable<Void>.
Chapitre 6 Exécution des tâches 131
objet Runnable ou Callable à un Executor et récupérer un Future utilisable pour récu-
pérer le résultat ou annuler la tâche. Vous pouvez également instancier explicitement un
objet FutureTask pour un Runnable ou un Callable donné (FutureTask implémentant
Runnable, cet objet peut être soumis à un Executor pour qu’il l’exécute ou exécuté
directement en appelant sa méthode run()).
À partir de Java 6, les implémentations de ExecutorService peuvent redéfinir newTask
For() dans AbstractExecutorService afin de contrôler l’instanciation de l’objet Future
correspondant à un Callable ou à un Runnable qui a été soumis. Comme le montre le
Listing 6.12, l’implémentation par défaut se contente de créer un nouvel objet Future
Task.
Listing 6.12 : Implémentation par défaut de newTaskFor() dans ThreadPoolExecutor.
protected <T> RunnableFuture <T> newTaskFor(Callable<T> task) {
return new FutureTask<T>(task);
}
La soumission d’un Runnable ou d’un Callable à un Executor constitue une publication
correcte (voir la section 3.5) de ce Runnable ou de ce Callable, du thread qui le soumet
au thread qui finira par exécuter la tâche. De même, l’initialisation de la valeur du résultat
pour un Future est une publication correcte de ce résultat, du thread dans lequel il a été
calculé vers tout thread qui le récupère via get().
6.3.3 Exemple : affichage d’une page avec Future
La première étape pour ajouter du parallélisme à l’affichage d’une page consiste à la
diviser en deux tâches, une pour afficher le texte, une autre pour télécharger toutes les
images (l’une étant fortement liée au processeur et l’autre, aux E/S, cette approche peut
apporter une grosse amélioration, même sur un système monoprocesseur).
Les classes Callable et Future peuvent nous aider à exprimer l’interaction entre ces
tâches coopératives. Dans le Listing 6.13, nous créons un objet Callable pour télécharger
toutes les images et nous le soumettons à un ExecutorService afin d’obtenir un objet
Future décrivant l’exécution de cette tâche. Lorsque la tâche principale a besoin des
images, elle attend le résultat en appelant Future.get(). Avec un peu de chance, ce
résultat sera déjà prêt lorsqu’on le demandera ; sinon nous aurons au moins lancé le
téléchargement des images.
Listing 6.13 : Attente du téléchargement d’image avec Future.
public class FutureRenderer {
private final ExecutorService executor = ...;
void renderPage(CharSequence source) {
final List<ImageInfo> imageInfos = scanForImageInfo (source);
Callable<List<ImageData>> task =
new Callable<List<ImageData>>() {
public List<ImageData> call() {
132 Structuration des applications concurrentes Partie II
Listing 6.13 : Attente du téléchargement d’image avec Future. (suite)
List<ImageData> result = new ArrayList<ImageData>();
for (ImageInfo imageInfo : imageInfos)
result.add(imageInfo.downloadImage());
return result;
}
};
Future<List<ImageData>> future = executor.submit(task);
renderText(source);
try {
List<ImageData> imageData = future.get();
for (ImageData data : imageData)
renderImage(data);
} catch (InterruptedException e) {
// Réaffirme l’état "interrompu" du thread
Thread.currentThread().interrupt();
// On n’a pas besoin du résultat, donc on annule aussi la tâche
future.cancel(true);
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
Le comportement de get() dépendant de l’état, l’appelant n’a pas besoin de connaître
l’état de la tâche ; en outre, la publication correcte de la soumission de la tâche et la
récupération du résultat suffisent à rendre cette approche thread-safe. Le code de gestion
d’exception associé à l’appel de Future.get() traite deux problèmes possibles : la tâche
a pu rencontrer une exception ou le thread qui a appelé get() a été interrompu avant
que le résultat ne soit disponible (voir les sections 5.5.2 et 5.4).
FutureRenderer permet d’afficher le texte en même temps que les images sont télé-
chargées. Lorsque toutes les images sont disponibles, elles sont affichées sur la page.
C’est une amélioration puisque l’utilisateur voit rapidement le résultat et on exploite ici
le parallélisme, bien que l’on puisse faire beaucoup mieux. En effet, l’utilisateur n’a pas
besoin d’attendre que toutes les images soient téléchargées : il préférerait sûrement les
voir séparément à mesure qu’elles deviennent disponibles.
6.3.4 Limitations du parallélisme de tâches hétérogènes
Dans le dernier exemple, nous avons tenté d’exécuter en parallèle deux types de tâches
différents – télécharger des images et afficher la page. Cependant, obtenir des gains de
performances intéressants en mettant en parallèle des tâches séquentielles hétérogènes
peut être assez compliqué. Deux personnes peuvent diviser efficacement le travail qui
consiste à faire la vaisselle : l’une lave pendant que l’autre essuie. Cependant, affecter
un type de tâche différent à chaque participant n’est pas très évolutif : si plusieurs
personnes proposent leur aide pour la vaisselle, il n’est pas évident de savoir comment
les répartir sans qu’elles se gênent ou sans restructurer la division du travail. Si l’on ne
trouve pas un parallélisme suffisamment précis entre des tâches similaires, cette approche
produira des résultats décevants.
Chapitre 6 Exécution des tâches 133
Un autre problème de la division des tâches hétérogènes entre plusieurs participants est
que ces tâches peuvent avoir des tailles différentes. Si vous divisez les tâches A et B entre
deux participants, mais que A soit dix fois plus longue que B, nous n’aurez accéléré le
traitement total que de 9 %. Enfin, diviser le travail entre plusieurs participants implique
toujours un certain coût de coordination ; pour que cette division soit intéressante, ce coût
doit être plus que compensé par l’amélioration de la productivité due au parallélisme.
FutureRenderer utilise deux tâches : l’une pour afficher le texte, l’autre pour télécharger
les images. Si la première est bien plus rapide que la seconde, comme c’est sûrement le
cas, les performances ne seront pas beaucoup différentes de celles de la version séquen-
tielle alors que le code sera bien plus compliqué. Le mieux que l’on puisse obtenir avec
deux threads est une vitesse multipliée par deux. Par conséquent, tenter d’augmenter la
concurrence en mettant en parallèle des activités hétérogènes peut demander beaucoup
d’efforts et il y a une limite à la dose de concurrence supplémentaire que vous pouvez
en tirer (voir les sections 11.4.2 et 11.4.3 pour un autre exemple de ce phénomène).
Le véritable bénéfice en terme de performances de la division d’un programme en
tâches s’obtient lorsque l’on peut traiter en parallèle un grand nombre de tâches
indépendantes et homogènes.
6.3.5 CompletionService : quand Executor rencontre BlockingQueue
Si vous avez un grand nombre de calculs à soumettre à un Executor et que vous vouliez
récupérer leurs résultats dès qu’ils sont disponibles, vous pouvez conserver les objets
Future associés à chaque tâche et les interroger régulièrement pour savoir s’ils se sont
terminés en appelant leurs méthodes get() avec un délai d’expiration de zéro. C’est
possible, mais ennuyeux. Heureusement, il y a une meilleure solution : un service de
terminaison.
CompletionService combine les fonctionnalités d’un Executor et d’une BlockingQueue.
Vous pouvez lui soumettre des tâches Callable pour les exécuter et utiliser des méthodes
sur les files comme take() et poll() pour obtenir les résultats calculés, empaquetés
sous la forme d’objets Future, dès qu’ils sont disponibles. ExecutorCompletionService
implémente CompletionService, en déléguant le calcul à un Executor.
L’implémentation de ExecutorCompletionService est assez simple à comprendre. Le
constructeur crée une BlockingQueue qui contiendra les résultats terminés. FutureTask
dispose d’une méthode done() qui est appelée lorsque le calcul s’achève. Lorsqu’une
tâche est soumise, elle est enveloppée dans un objet QueueingFuture, une sous-classe de
FutureTask qui redéfinit done() pour placer le résultat dans la BlockingQueue, comme
le montre le Listing 6.14. Les méthodes take() et poll() délèguent leur traitement à la
BlockingQueue et se bloquent si le résultat n’est pas encore disponible.
134 Structuration des applications concurrentes Partie II
Listing 6.14 : La classe QueueingFuture utilisée par ExecutorCompletionService.
private class QueueingFuture <V> extends FutureTask<V> {
QueueingFuture(Callable<V> c) { super(c); }
QueueingFuture(Runnable t, V r) { super(t, r); }
protected void done() {
completionQueue .add(this);
}
}
6.3.6 Exemple : affichage d’une page avec CompletionService
CompletionService va nous permettre d’améliorer les performances de l’affichage de la
page de deux façons : un temps d’exécution plus court et une meilleure réactivité. Nous
pouvons créer une tâche distincte pour télécharger chaque image et les exécuter dans un
pool de threads, ce qui aura pour effet de transformer le téléchargement séquentiel en
téléchargement en parallèle : cela réduit le temps nécessaire à l’obtention de toutes les
images. En récupérant les résultats à partir du CompletionService et en affichant
chaque image dès qu’elle est disponible, nous fournissons à l’utilisateur une interface
plus dynamique et plus réactive. Cette implémentation est décrite dans le Listing 6.15.
Listing 6.15 : Utilisation de CompletionService pour afficher les éléments de la page
dès qu’ils sont disponibles.
public class Renderer {
private final ExecutorService executor;
Renderer(ExecutorService executor) { this.executor = executor; }
void renderPage(CharSequence source) {
List<ImageInfo> info = scanForImageInfo (source);
CompletionService <ImageData> completionService =
new ExecutorCompletionService <ImageData>(executor);
for (final ImageInfo imageInfo : info)
completionService.submit( new Callable<ImageData>() {
public ImageData call() {
return imageInfo.downloadImage();
}
});
renderText(source);
try {
for (int t = 0, n = info.size(); t < n; t++) {
Future<ImageData> f = completionService.take();
ImageData imageData = f.get();
renderImage(imageData);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
Chapitre 6 Exécution des tâches 135
Plusieurs ExecutorCompletionServices pouvant partager le même Executor, il est
tout à fait sensé de créer un ExecutorCompletionService privé à un calcul particulier tout
en partageant un Executor commun. Utilisé de cette façon, un CompletionService agit
comme un descripteur d’un ensemble de calculs, exactement comme un Future agit comme
un descripteur d’un simple calcul. En mémorisant le nombre de tâches soumises au
CompletionService et en comptant le nombre de résultats récupérés, vous pouvez
savoir quand tous les résultats d’un ensemble de calculs ont été obtenus, même si vous
utilisez un Executor partagé.
6.3.7 Imposer des délais aux tâches
Le résultat d’une activité qui met trop de temps à se terminer peut ne plus être utile et
l’activité peut alors être abandonnée. Une application web qui récupère des publicités à
partir d’un serveur externe, par exemple, pourrait afficher une publicité par défaut si le
serveur ne répond pas au bout de 2 secondes afin de ne pas détériorer la réactivité du
site. De la même façon, un portail web peut récupérer des données en parallèle à partir
de plusieurs sources mais ne vouloir attendre qu’un certain temps avant d’afficher les
données.
Le principal défi de l’exécution des tâches dans un délai imparti consiste à s’assurer que
l’on n’attendra pas plus longtemps que ce délai pour obtenir une réponse ou à constater
qu’une réponse n’est pas fournie. La version temporisée de Future.get() fournit cette
garantie puisqu’elle se termine dès que le résultat est disponible, mais lance Timeout
Exception si le résultat n’est pas prêt dans le délai fixé.
Le deuxième problème avec les tâches avec délais consiste à les arrêter lorsque le temps
imparti s’est écoulé afin qu’elles ne consomment pas inutilement des ressources en
continuant de calculer un résultat qui, de toute façon, ne sera pas utilisé. Pour cela, on
peut faire en sorte que la tâche gère de façon stricte son propre délai et se termine
d’elle-même lorsqu’il est écoulé, ou l’on peut annuler la tâche lorsque le délai a expiré.
Une nouvelle fois, Future peut nous aider ; si un appel get() temporisé se termine avec
une exception TimeoutException, nous pouvons annuler la tâche via l’objet Future. Si
la tâche a été conçue pour être annulable (voir Chapitre 7), nous pouvons y mettre fin
précocément afin qu’elle ne consomme pas de ressources inutiles. C’est la technique
utilisée dans les Listings 6.13 et 6.16.
Listing 6.16 : Récupération d’une publicité dans un délai imparti.
Page renderPageWithAd() throws InterruptedException {
long endNanos = System.nanoTime() + TIME_BUDGET;
Future<Ad> f = exec.submit(new FetchAdTask());
// Affiche la page pendant qu’on attend la publicité
Page page = renderPageBody();
Ad ad;
try {
// On n’attend que pendant le temps restant
long timeLeft = endNanos - System.nanoTime();
136 Structuration des applications concurrentes Partie II
Listing 6.16 : Récupération d’une publicité dans un délai imparti. (suite)
ad = f.get(timeLeft, NANOSECONDS);
} catch (ExecutionException e) {
ad = DEFAULT_AD;
} catch (TimeoutException e) {
ad = DEFAULT_AD;
f.cancel(true);
}
page.setAd(ad);
return page;
}
Ce dernier listing montre une application typique de la version temporisée de
Future.get(). Ce code produit une page web composite avec le contenu demandé
accompagné d’une publicité récupérée à partir d’un serveur externe. La tâche d’obtention
de la publicité est confiée à un Executor ; le code calcule le reste du contenu de la page
puis attend la publicité jusqu’à l’expiration de son délai1. Si celui-ci expire, il annule2 la
tâche de récupération et utilise une publicité par défaut.
6.3.8 Exemple : portail de réservations
L’approche par délai de la section précédente peut aisément se généraliser à un nombre
quelconque de tâches. Dans un portail de réservation, par exemple, l’utilisateur saisit
des dates de voyages et le portail recherche et affiche les tarifs d’un certain nombre de
vols, hôtels ou sociétés de location de véhicule. Selon la société, la récupération d’un
tarif peut impliquer l’appel d’un service web, la consultation d’une base de données,
l’exécution d’une transaction EDI ou tout autre mécanisme. Au lieu que le temps de
réponse de la page ne soit décidé par la réponse la plus lente, il peut être préférable de
ne présenter que les informations disponibles dans un certain délai. Dans le cas de four-
nisseurs ne répondant pas à temps, la page pourrait soit les omettre totalement, soit
écrire un texte comme "Air Java n’a pas répondu à temps".
Obtenir un tarif auprès d’une compagnie étant indépendant de l’obtention des tarifs des
autres compagnies, la récupération d’un tarif est une bonne candidate au découpage en
tâches, permettant ainsi l’obtention en parallèle des différents tarifs. Il serait assez
simple de créer n tâches, de les soumettre à un pool de threads, de mémoriser les objets
Future et d’utiliser un appel get() temporisé pour obtenir séquentiellement chaque
résultat via son Future, mais il existe un moyen plus simple : invokeAll().
Le Listing 6.17 utilise la version temporisée de invokeAll() pour soumettre plusieurs
tâches à un ExecutorService et récupérer les résultats. La méthode invokeAll() prend
1. Le délai passé à get() est calculé en soustrayant l’heure courante de la date limite ; on peut donc
obtenir un nombre négatif mais, toutes les méthodes temporisées de java.util.concurrent traitant
les délais négatifs comme des délais nuls, aucun code supplémentaire n’est nécessaire pour traiter ce
cas.
2. Le paramètre true de Future.cancel() signifie que le thread de la tâche peut être interrompu même
si la tâche est en cours d’exécution (voir Chapitre 7).
Chapitre 6 Exécution des tâches 137
en paramètre une collection de tâches et renvoie une collection de Future. Ces deux
collections ont des structures identiques ; invokeAll() ajoute les Future dans le résultat
selon l’ordre imposé par l’itérateur de la collection des tâches, ce qui permet à l’appelant
d’associer un Future à l’objet Callable qu’il représente. La version temporisée de
invokeAll() se terminera quand toutes les tâches se seront terminées, si le thread appelant
est interrompu ou si le délai imparti a expiré. Toutes les tâches non terminées à l’expi-
ration du délai sont annulées. Au retour de invokeAll(), chaque tâche se sera donc
terminée normalement ou aura été annulée ; le code client peut appeler get() ou
isCancelled() pour le savoir.
Listing 6.17 : Obtention de tarifs dans un délai imparti.
private class QuoteTask implements Callable<TravelQuote> {
private final TravelCompany company;
private final TravelInfo travelInfo;
...
public TravelQuote call() throws Exception {
return company.solicitQuote(travelInfo);
}
}
public List<TravelQuote> getRankedTravelQuotes (
TravelInfo travelInfo, Set<TravelCompany> companies,
Comparator<TravelQuote> ranking, long time, TimeUnit unit)
throws InterruptedException {
List<QuoteTask> tasks = new ArrayList<QuoteTask>();
for (TravelCompany company : companies)
tasks.add(new QuoteTask(company, travelInfo));
List<Future<TravelQuote>> futures =
exec.invokeAll(tasks, time, unit);
List<TravelQuote> quotes =
new ArrayList<TravelQuote>(tasks.size());
Iterator<QuoteTask> taskIter = tasks.iterator();
for (Future<TravelQuote> f : futures) {
QuoteTask task = taskIter.next();
try {
quotes.add(f.get());
} catch (ExecutionException e) {
quotes.add(task.getFailureQuote(e.getCause()));
} catch (CancellationException e) {
quotes.add(task.getTimeoutQuote (e));
}
}
Collections.sort(quotes, ranking);
return quotes;
}
Résumé
Structurer les applications autour de l’exécution en tâches permet de simplifier le dévelop-
pement et de faciliter le parallélisme. Grâce au framework Executor, vous pouvez séparer
la soumission des tâches de la politique d’exécution ; en outre, de nombreuses politiques
138 Structuration des applications concurrentes Partie II
d’exécution sont disponibles : à chaque fois que vous devez créer des threads pour
exécuter des tâches, pensez à utiliser un Executor. Pour obtenir le maximum de béné-
fice de la décomposition d’une application en tâches, vous devez trouver comment
séparer les tâches. Dans certaines applications, cette décomposition est évidente alors
que, dans d’autres, elle nécessite une analyse un peu plus poussée pour faire ressortir un
parallélisme exploitable.
7
Annulation et arrêt
Lancer des tâches et des threads est une opération simple. La plupart du temps, on leur
permet de décider quand s’arrêter en les laissant s’exécuter jusqu’à la fin. Parfois,
cependant, nous voulons stopper des tâches plus tôt que prévu, par exemple parce qu’un
utilisateur a annulé une opération ou parce que l’application doit s’arrêter rapidement.
Il n’est pas toujours simple de stopper correctement, rapidement et de façon fiable des
tâches et des threads. Java ne fournit aucun mécanisme pour forcer en toute sécurité un
thread à stopper ce qu’il était en train de faire1. En revanche, il fournit les interruptions,
un mécanisme coopératif qui permet à un thread de demander à un autre d’arrêter ce
qu’il est en train de faire.
L’approche coopérative est nécessaire car on souhaite rarement qu’une tâche, un thread
ou un service s’arrête immédiatement puisqu’il pourrait laisser des structures de données
partagées dans un état incohérent. Ces tâches et services doivent donc être codés pour
que, lorsqu’on leur demande, ils nettoient le travail en cours puis se terminent. Cette
pratique apporte une grande souplesse car le code lui-même est généralement plus
qualifié que le code demandant l’annulation pour effectuer le nettoyage nécessaire.
Les problèmes de fin de vie peuvent compliquer la conception et l’implémentation des
tâches, des services et des applications ; c’est également un aspect important de la
conception des programmes qui est trop souvent ignoré. Bien gérer les pannes, les arrêts
et les annulations fait partie de ces caractéristiques qui distinguent une application bien
construite d’une autre qui se contente de fonctionner. Ce chapitre présente les méca-
nismes d’annulation et d’interruption et montre comment coder les tâches et les services
pour qu’ils répondent aux demandes d’annulation.
1. Les méthodes dépréciées Thread.stop() et suspend() étaient une tentative pour fournir ce méca-
nisme, mais on a vite réalisé qu’elles avaient de sérieux défauts et qu’il fallait les éviter. Pour une
explication des problèmes avec ces méthodes, consutez la page http://java.sun.com/ j2se/1.5.0/docs/
guide/misc/threadPrimitiveDeprecation.
140 Structuration des applications concurrentes Partie II
7.1 Annulation des tâches
Une activité est annulable, si un code externe peut l’amener à se terminer avant sa fin
normale. Il existe de nombreuses raisons d’annuler une activité :
m Annulation demandée par l’utilisateur. Celui-ci a cliqué sur le bouton "Annuler"
d’une interface graphique ou a demandé l’annulation via une interface de gestion
comme JMX (Java Management Extensions).
m Activité limitée dans le temps. Une application parcourt un espace de problèmes
pendant un temps fini et choisit la meilleure solution trouvée pendant ce temps imparti.
Quand le délai expire, toutes les tâches encore en train de chercher sont annulées.
m Événement d’une application. Une application parcourt un espace de problèmes
en le décomposant pour que des tâches distinctes examinent différentes régions de
cet espace. Lorsqu’une tâche trouve une solution, toutes celles encore en train de
chercher sont annulées.
m Erreurs. Un robot web recherche des pages, les stocke ou produit un résumé des
données sur disque. S’il rencontre une erreur (disque plein, par exemple), ses autres
tâches doivent être annulées, éventuellement après avoir enregistré leur état courant
afin de pouvoir les reprendre plus tard.
m Arrêt. Lorsque l’on arrête une application ou un service, il faut faire quelque chose
pour le travail en cours ou en attente de traitement. Dans un arrêt en douceur, les
tâches en cours d’exécution peuvent être autorisées à se terminer alors qu’elle peuvent
être annulées dans un arrêt plus brutal.
Il n’y a aucun moyen sûr d’arrêter autoritairement un thread en Java et donc aucun
moyen sûr d’arrêter une tâche. Il n’existe que des mécanismes coopératifs dans lesquels
la tâche et le code qui demande l’annulation respectent un protocole d’agrément.
L’un de ces mécanismes consiste à positionner un indicateur "demande d’annulation"
que la tâche consultera périodiquement ; si elle constate qu’il est positionné, elle se
termine dès que possible. C’est la technique qu’utilise la classe PrimeGenerator du
Listing 7.1, qui énumère les nombres premiers jusqu’à son annulation. La méthode
cancel() positionne l’indicateur d’annulation qui est consulté par la boucle principale
avant chaque recherche d’un nouveau nombre premier (pour que cela fonctionne
correctement, l’indicateur doit être volatile).
Listing 7.1 : Utilisation d’un champ volatile pour stocker l’état d’annulation.
@ThreadSafe
public class PrimeGenerator implements Runnable {
@GuardedBy("this")
private final List<BigInteger> primes
= new ArrayList<BigInteger>();
private volatile boolean cancelled;
Chapitre 7 Annulation et arrêt 141
public void run() {
BigInteger p = BigInteger.ONE;
while (!cancelled) {
p = p.nextProbablePrime();
synchronized (this) {
primes.add(p);
}
}
}
public void cancel() { cancelled = true; }
public synchronized List<BigInteger> get() {
return new ArrayList<BigInteger>(primes);
}
}
Le Listing 7.2 présente un exemple d’utilisation de cette classe dans lequel on laisse
une seconde au générateur de nombres premiers avant de l’annuler. Le générateur ne
s’arrêtera pas nécessairement après exactement une seconde puisqu’il peut y avoir un
léger délai entre la demande d’annulation et le moment où la boucle revient tester
l’indicateur. La méthode cancel() est appelée à partir d’un bloc finally pour garantir
que le générateur sera annulé même si l’appel à sleep() est interrompu. Si cancel()
n’était pas appelé, le thread de recherche des nombres premiers continuerait de s’exécuter,
ce qui consommerait des cycles processeur et empêcherait la JVM de se terminer.
Listing 7.2 : Génération de nombres premiers pendant une seconde.
List<BigInteger> aSecondOfPrimes() throws InterruptedException {
PrimeGenerator generator = new PrimeGenerator();
new Thread(generator).start();
try {
SECONDS.sleep(1);
} finally {
generator.cancel();
}
return generator.get();
}
Pour être annulable, une tâche doit avoir une politique d’annulation précisant le
"comment", le "quand" et le "quoi" de l’annulation : comment un autre code peut demander
l’annulation, quand est-ce que la tâche vérifie qu’une annulation a été demandée et
quelles actions doit entreprendre la tâche en réponse à une demande d’annulation.
Prenons l’exemple d’une annulation d’un chèque : les banques ont des règles qui précisent
la façon de demander l’annulation d’un paiement, les garanties sur leur réactivité pour
une telle demande et les procédures qui suivront l’annulation du chèque (prévenir l’autre
banque impliquée dans la transaction et prélever des frais de dossier sur le compte du
demandeur, par exemple). Prises ensemble, ces procédures et ces garanties forment la
politique d’annulation d’un paiement par chèque.
PrimeGenerator utilise une politique simple : le code client demande l’annulation en
appelant cancel(), le code principal teste les demandes d’annulation pour chaque
nombre premier et se termine lorsqu’il détecte qu’une demande a eu lieu.
142 Structuration des applications concurrentes Partie II
7.1.1 Interruption
Le mécanisme d’annulation de PrimeGenerator finira par provoquer la fin de la tâche
de calcul des nombres premiers, mais cela peut prendre un certain temps. Si une tâche
utilisant cette approche appelle une méthode bloquante comme BlockingQueue.put(),
nous pourrions avoir un sérieux problème : elle pourrait ne jamais tester l’indicateur
d’annulation et donc ne jamais se terminer.
Ce problème est illustré par la classe BrokenPrimeProducer du Listing 7.3. Le producteur
génère des nombres premiers et les place dans une file bloquante. Si le producteur va
plus vite que le consommateur, la file se remplira et un appel à put() sera bloquant. Que
se passera-t-il si le consommateur essaie d’annuler la tâche productrice alors qu’elle est
bloquée dans put() ? Le consommateur peut appeler cancel(), qui positionnera l’indi-
cateur cancelled, mais le producteur ne le testera jamais car il ne sortira jamais de
l’appel à put() bloquant (puisque le consommateur a cessé de récupérer des nombres
premiers dans la file).
Listing 7.3 : Annulation non fiable pouvant bloquer les producteurs. Ne le faites pas.
class BrokenPrimeProducer extends Thread {
private final BlockingQueue <BigInteger> queue;
private volatile boolean cancelled = false;
BrokenPrimeProducer (BlockingQueue <BigInteger> queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while (!cancelled)
queue.put(p = p.nextProbablePrime());
} catch (InterruptedException consumed) { }
}
public void cancel() { cancelled = true; }
}
void consumePrimes() throws InterruptedException {
BlockingQueue<BigInteger> primes = ...;
BrokenPrimeProducer producer = new BrokenPrimeProducer(primes);
producer.start();
try {
while (needMorePrimes())
consume(primes.take());
} finally {
producer.cancel();
}
}
Comme nous l’avions évoqué au Chapitre 5, certaines méthodes bloquantes de la biblio-
thèque permettent d’être interrompues. L’interruption d’un thread est un mécanisme
coopératif permettant d’envoyer un signal à un thread pour qu’il arrête son traitement
en cours et fasse autre chose, s’il en a envie et quand il le souhaite.
Chapitre 7 Annulation et arrêt 143
Rien dans l’API ou les spécifications du langage ne lie une interruption à une sémantique
particulière de l’annulation mais, en pratique, utiliser une interruption pour autre chose
qu’une annulation est une démarche fragile et difficile à gérer dans les applications de
taille importante.
Chaque thread a un indicateur d’interruption booléen précisant s’il a été interrompu.
Comme le montre le Listing 7.4, la classe Thread contient des méthodes permettant
d’interrompre un thread et d’interroger cet indicateur. La méthode interrupt() inter-
rompt le thread cible et isInterrupted() renvoie son état d’interruption. La méthode
statique interrupted() porte mal son nom puisqu’elle réinitialise l’indicateur d’inter-
ruption du thread courant et renvoie sa valeur précédente : c’est le seul moyen de
réinitialiser cet indicateur.
Listing 7.4 : Méthodes d’interruption de Thread.
public class Thread {
public void interrupt() { ... }
public boolean isInterrupted() { ... }
public static boolean interrupted() { ... }
...
}
Les méthodes bloquantes de la bibliothèque, comme Thread.sleep() et Object.wait(),
tentent de détecter quand un thread a été interrompu, auquel cas elles se terminent plus
tôt que prévu. Elles répondent à l’interruption en réinitialisant l’indicateur d’interruption
et en lançant InterruptedException pour indiquer que l’opération bloquante s’est
terminée précocément à cause d’une interruption. La JVM ne garantit pas le temps que
mettra une méthode bloquante pour détecter une interruption mais, en pratique, c’est
relativement rapide.
Si un thread est interrompu alors qu’il n’était pas bloqué, son indicateur d’interruption
est positionné et c’est à l’activité annulée de l’interroger pour détecter l’interruption. En
ce sens, l’interruption est "collante" : si elle ne déclenche pas une InterruptedException,
la trace de l’interruption persiste jusqu’à ce que quelqu’un réinitialise délibérément
l’indicateur.
L’appel interrupt() n’empêche pas nécessairement le thread cible de continuer ce
qu’il est en train de faire : il indique simplement qu’une interruption a été demandée.
En réalité, une interruption n’interrompt pas un thread en cours d’exécution : elle ne fait
que demander au thread de s’interrompre lui-même à la prochaine occasion (ces occa-
sions sont appelées points d’annulation). Certaines méthodes comme wait(), sleep()
et join() prennent ces requêtes au sérieux et lèvent une exception lorsqu’elles les reçoi-
vent ou rencontrent un indicateur d’interruption déjà positionné. D’autres, pourtant tout
144 Structuration des applications concurrentes Partie II
à fait correctes, peuvent totalement ignorer ces requêtes et les laisser en place pour que
le code appelant puisse en faire quelque chose. Les méthodes mal conçues perdent les
requêtes d’interruption, empêchant ainsi le code situé plus haut dans la pile des appels
d’agir en conséquence. La méthode statique interrupted() doit être utilisée avec précau-
tion puisqu’elle réinitialise l’indicateur d’interruption du thread courant. Si vous l’appelez
et qu’elle renvoie true, vous devez agir (à moins que vous ne vouliez perdre l’interruption)
en lançant InterruptedException ou en restaurant l’indicateur en rappelant interrupt()
comme dans le Listing 5.10.
BrokenPrimeProducer montre que les mécanismes personnalisés d’annulation ne font
pas toujours bon ménage avec les méthodes bloquantes de la bibliothèque. Si vous codez
vos tâches pour qu’elles répondent aux interruptions, vous pouvez les utiliser comme
mécanisme d’annulation et tirer parti du support des interruptions fourni par de nombreuses
classes de la bibliothèque.
Une interruption est généralement le meilleur moyen d’implémenter l’annulation.
Comme le montre le Listing 7.5, on peut facilement corriger (et simplifier) BrokenPrime
Producer en utilisant une interruption à la place d’un indicateur booléen pour demander
l’annulation.
Listing 7.5 : Utilisation d’une interruption pour l’annulation.
class PrimeProducer extends Thread {
private final BlockingQueue <BigInteger> queue;
PrimeProducer(BlockingQueue <BigInteger> queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while (!Thread.currentThread().isInterrupted())
queue.put(p = p.nextProbablePrime());
} catch (InterruptedException consumed) {
/* Autorise le thread à se terminer */
}
}
public void cancel() { interrupt(); }
}
L’interruption peut être détectée à deux endroits dans chaque itération de la boucle :
dans l’appel bloquant à put() et en interrogeant explicitement l’indicateur d’interrup-
tion dans le test de boucle. Ce test explicite n’est d’ailleurs pas strictement nécessaire
ici à cause de l’appel bloquant à put() mais il rend PrimeProducer plus réactive à une
interruption puisqu’il teste l’interruption avant de commencer la recherche d’un nombre
premier, ce qui peut être une opération assez longue. Lorsque les appels aux méthodes
Chapitre 7 Annulation et arrêt 145
bloquantes ne sont pas suffisamment fréquents pour produire la réactivité attendue, un
test explicite de l’indicateur d’interruption peut améliorer la situation.
7.1.2 Politiques d’interruption
Tout comme les tâches devraient avoir une politique d’annulation, les threads devraient
respecter une politique d’interruption. Une telle politique détermine la façon dont un
thread interprétera une demande d’interruption : ce qu’il fera (s’il fait quelque chose)
lorsqu’il en détectera une, quelles unités de travail seront considérées comme atomiques
par rapport à l’interruption et à quelle vitesse il réagira à cette interruption.
La politique d’interruption la plus raisonnable est une forme d’annulation au niveau du
thread ou du service : quitter aussi vite que possible, nettoyer si nécessaire et, éventuel-
lement, prévenir l’entité propriétaire que le thread se termine. On peut établir d’autres
politiques comme mettre un service en pause ou le relancer mais, en ce cas, les threads
ou les pools de threads utilisant une politique d’interruption non standard devront peut-
être se limiter à des tâches écrites pour tenir compte de cette politique.
Il est important de faire la différence entre la façon dont les tâches et les threads
devraient réagir aux interruptions. Une simple requête d’interruption peut avoir d’autres
destinataires que celui qui est initialement visé : interrompre un thread dans un pool
peut signifier "annule la tâche courante" et "termine ce thread".
Les tâches ne s’exécutent pas dans des threads qu’elles possèdent ; elles empruntent des
threads qui appartiennent à un service comme un pool de threads. Le code qui ne
possède pas le thread (pour un pool de thread, il s’agit du code situé à l’extérieur de
l’implémentation du pool) doit faire attention à préserver l’indicateur d’interruption
afin que le code propriétaire puisse éventuellement y réagir, même si le code "invité"
réagit également à l’interruption (lorsque l’on garde une maison, on ne jette pas le courrier
qui arrive pour les propriétaires – on le sauvegarde et on les laisse s’en occuper à leur
retour, même si on lit leurs magazines).
C’est la raison pour laquelle la plupart des méthodes bloquantes de la bibliothèque se
contentent de lancer InterruptedException en réponse à une interruption. Comme
elles ne s’exécuteront jamais dans un thread qui leur appartient, elles implémentent la
politique d’annulation la plus raisonnable pour une tâche ou un code d’une biblio-
thèque : elles débarrassent le plancher le plus vite possible et transmettent l’interruption
à l’appelant afin que le code situé plus haut dans la pile des appels puisse prendre les
mesures nécessaires.
Une tâche n’a pas nécessairement besoin de tout abandonner lorsqu’elle détecte une
demande d’interruption – elle peut choisir un moment plus opportun en mémorisant qu’elle
a été interrompue, en finissant le travail qu’elle effectuait puis en lançant Interrupted
Exception ou tout autre signal indiquant l’interruption. Cette technique permet d’éviter
146 Structuration des applications concurrentes Partie II
qu’une interruption survenant au beau milieu d’une modification n’abîme les structures
de données.
Une tâche ne devrait jamais rien supposer sur la politique d’interruption du thread qui
l’exécute, sauf si elle a été explicitement conçue pour s’exécuter au sein d’un service
utilisant une politique d’interruption spécifique. Qu’elle interprète une interrruption
comme une annulation ou qu’elle agisse d’une certaine façon en cas d’interruption, une
tâche devrait prendre soin de préserver l’indicateur d’interruption du thread qui s’exécute.
Si elle ne se contente pas de propager InterruptedException à son appelant, elle devrait
restaurer l’indicateur d’interruption après avoir capturé l’exception :
Thread.currentThread().interrupt();
Tout comme le code d’une tâche ne devrait pas faire de supposition sur ce que signifie
une interruption pour le thread qui l’exécute, le code d’annulation ne devrait rien
supposer de la politique d’interruption des threads. Un thread ne devrait être interrompu
que par son propriétaire ; ce dernier peut encapsuler la connaissance de la politique
d’interruption du thread dans un mécanisme d’annulation adéquat – une méthode d’arrêt,
par exemple.
Chaque thread ayant sa propre politique d’interruption, vous devriez interrompre un
thread que si vous savez ce que cela signifie pour lui.
Certaines critiques se sont moquées des interruptions de Java car elles ne permettent
pas de faire des interruptions préemptives et forcent pourtant les développeurs à traiter
InterruptedException. Cependant, la possibilité de reporter la prise en compte d’une
demande d’interruption permet aux développeurs de créer des politiques d’interruption
souples qui trouvent un équilibre entre réactivité et robustesse en fonction de l’application.
7.1.3 Répondre aux interruptions
Comme on l’a mentionné dans la section 5.4, il y a deux stratégies possibles pour traiter
une InterruptedException lorsqu’on appelle une méthode bloquante interruptible
comme Thread.sleep() ou BlockingQueue.put() :
m propager l’exception (éventuellement après un peu de ménage spécifique à la tâche),
ce qui rend également votre méthode bloquante et interruptible ;
m restaurer l’indicateur d’interruption pour que le code situé plus haut dans la pile des
appels puisse la traiter.
La propagation de InterruptedException consiste simplement à ajouter le nom de
cette classe à la clause throws, comme le fait la méthode getNextTask() du Listing 7.6.
Chapitre 7 Annulation et arrêt 147
Listing 7.6 : Propagation de InterruptedException aux appelants.
BlockingQueue <Task> queue;
...
public Task getNextTask() throws InterruptedException {
return queue.take();
}
Si vous ne voulez pas ou ne pouvez pas propager InterruptedException (parce que
votre tâche est définie par un Runnable, par exemple), vous devez utiliser un autre
moyen pour préserver la demande d’interruption. Pour ce faire, la méthode standard
consiste à restaurer l’indicateur d’interruption en appelant à nouveau interrupt(). Vous
ne devez pas absorber InterruptedException en la capturant pour ne rien en faire, sauf
si votre code implémente la politique d’interruption d’un thread. La classe PrimeProducer
absorbe l’interruption, mais le fait en sachant que le thread va se terminer et qu’il n’y a
donc pas de code plus haut dans la pile d’appels qui a besoin de savoir que cette inter-
ruption a eu lieu. Mais, dans la plupart des cas, un code ne connaît pas le thread qu’il
exécutera et il devrait donc préserver l’indicateur d’interruption.
Seul le code qui implémente la politique d’interruption d’un thread peut absorber une
demande d’interruption. Les tâches générales et le code d’une bibliothèque ne devraient
jamais le faire.
Les activités qui ne reconnaissent pas les annulations mais qui appellent quand même
des méthodes bloquantes interruptibles devront les appeler dans une boucle, en réessayant
lorsque l’interruption a été détectée. Dans ce cas, elles doivent sauvegarder localement
l’indicateur d’interruption et le restaurer juste avant de se terminer, comme le montre le
Listing 7.7, plutôt qu’immédiatement lorsqu’elles capturent InterruptedException.
Positionner trop tôt l’indicateur d’interruption pourrait provoquer une boucle sans fin
car la plupart des méthodes bloquantes interruptibles le testent avant de commencer et
lancent immédiatement InterruptedException s’il est positionné (ces méthodes inter-
rogent cet indicateur avant de se bloquer ou d’effectuer un travail un peu important afin
de réagir le plus vite possible à une interruption).
Listing 7.7 : Tâche non annulable qui restaure l’interruption avant de se terminer.
public Task getNextTask(BlockingQueue <Task> queue) {
boolean interrupted = false;
try {
while (true) {
try {
return queue.take();
} catch (InterruptedException e) {
interrupted = true;
// Ne fait rien et réessaie
}
}
148 Structuration des applications concurrentes Partie II
Listing 7.7 : Tâche non annulable qui restaure l’interruption avant de se terminer. (suite)
} finally {
if (interrupted)
Thread.currentThread().interrupt();
}
}
Si votre code n’appelle pas de méthodes bloquantes interruptibles, il peut quand même
réagir aux interruptions en interrogeant l’indicateur d’interruption du thread courant dans
le code de la tâche. Choisir la fréquence de cette consultation relève d’un compromis
entre efficacité et réactivité : si vous devez être très réactif, vous ne pouvez pas vous
permettre d’appeler des méthodes susceptibles de durer longtemps et qui ne sont pas
elles-mêmes réactives aux interruptions ; cela peut donc vous limiter dans vos appels.
L’annulation peut impliquer d’autres états que celui d’interruption ; ce dernier peut
servir à attirer l’attention du thread et les informations stockées ailleurs par le thread
qui interrompt peuvent être utilisées pour fournir des instructions supplémentaires au
thread interrompu (il faut bien sûr utiliser une synchronisation lorsque l’on accède à ces
informations). Lorsqu’un thread détenu par un ThreadPoolExecutor détecte une inter-
ruption, par exemple, il vérifie si le pool est en cours d’arrêt, auquel cas il peut faire un
peu de nettoyage avant de se terminer ; sinon il peut créer un autre thread pour que le
pool de threads puisse garder la même taille.
7.1.4 Exemple : exécution avec délai
De nombreux problèmes peuvent mettre un temps infini (l’énumération de tous les
nombres premiers, par exemple) ; pour d’autres, la réponse pourrait être trouvée assez
vite mais ils peuvent également durer éternellement. Or, dans certains cas, il peut être
utile de pouvoir dire "consacre dix minutes à trouver la réponse" ou "énumère toutes les
réponses possibles pendant dix minutes".
La méthode aSecondOfPrimes() du Listing 7.2 lance un objet PrimeGenerator et
l’interrompt après 1 seconde. Bien que ce dernier puisse mettre un peu plus de 1 seconde
pour s’arrêter, il finira par noter l’interruption et cessera son exécution, ce qui permettra
au thread de se terminer. Cependant, un autre aspect de l’exécution d’une tâche est que
l’on veut savoir si elle lance une exception : si PrimeGenerator lance une exception
non contrôlée avant l’expiration du délai, celle-ci passera sûrement inaperçue puisque
le générateur de nombres premiers s’exécute dans un thread séparé qui ne gère pas
explicitement les exceptions.
Le Listing 7.8 est une tentative d’exécuter un Runnable quelconque pendant un certain
temps. Il lance la tâche dans le thread appelant et planifie une tâche d’annulation pour
l’interrompre après un certain délai. Ceci règle le problème des exceptions non contrôlées
lancées à partir de la tâche puisqu’elles peuvent maintenant être capturées par celui qui
a appelé timedRun().
Chapitre 7 Annulation et arrêt 149
Listing 7.8 : Planification d’une interruption sur un thread emprunté. Ne le faites pas.
private static final ScheduledExecutorService cancelExec = ...;
public static void timedRun(Runnable r, long timeout, TimeUnit unit) {
final Thread taskThread = Thread.currentThread();
cancelExec.schedule(new Runnable() {
public void run() { taskThread.interrupt(); }
}, timeout, unit);
r.run();
}
Cette approche est d’une simplicité séduisante, mais elle viole la règle qui énonce que
l’on devrait connaître la politique d’interruption d’un thread avant de l’interrompre.
timedRun() pouvant être appelée à partir de n’importe quel thread, elle ne peut donc
connaître la politique d’interruption du thread appelant. Si la tâche se termine avant
l’expiration du délai, la tâche d’annulation qui interrompt le thread dans lequel timedRun()
a rendu la main à son appelant peut continuer à fonctionner en dehors de tout contexte.
Nous ne savons pas quel code s’exécutera lorsque cela se passera, mais le résultat ne
sera pas correct (il est possible, mais assez compliqué, d’éliminer ce risque en utilisant
l’objet ScheduledFuture renvoyé par schedule() pour annuler la tâche d’annulation).
En outre, si la tâche ne répond pas aux interruptions, timedRun() ne se terminera pas
tant que la tâche ne s’est pas terminée, ce qui peut être bien après le délai souhaité
(voire jamais). Un service d’exécution temporisé qui ne se termine pas après le temps
indiqué risque d’irriter ses clients.
Le Listing 7.9 corrige le problème de gestion des exceptions de aSecondOfPrimes() et
les défauts de la tentative précédente. Le thread créé pour exécuter la tâche peut avoir sa
propre politique d’exécution et la méthode run() temporisée peut revenir à l’appelant,
même si la tâche ne répond pas à l’interruption. Après avoir lancé le thread de la tâche,
timedRun() exécute un join temporisé avec le thread nouvellement créé. Lorsque ce
join s’est terminé, elle teste si une exception a été lancée à partir de la tâche, auquel cas
elle la relance dans le thread qui a appelé timedRun(). L’objet Throwable sauvegardé
est partagé entre les deux threads et est donc déclaré comme volatile pour pouvoir
être correctement publié du thread de la tâche vers celui de timedRun().
Listing 7.9 : Interruption d’une tâche dans un thread dédié.
public static void timedRun(final Runnable r, long timeout,
TimeUnit unit)
throws InterruptedException {
class RethrowableTask implements Runnable {
private volatile Throwable t;
public void run() {
try { r.run(); }
catch (Throwable t) { this.t = t; }
}
void rethrow() {
if (t != null)
throw launderThrowable(t);
}
}
150 Structuration des applications concurrentes Partie II
Listing 7.9 : Interruption d’une tâche dans un thread dédié. (suite)
RethrowableTask task = new RethrowableTask();
final Thread taskThread = new Thread(task);
taskThread.start();
cancelExec.schedule(new Runnable() {
public void run() { taskThread.interrupt(); }
}, timeout, unit);
taskThread.join(unit.toMillis(timeout));
task.rethrow();
}
Cette version corrige les problèmes des exemples précédents mais, comme elle repose
sur un join temporisé, elle partage une lacune de join : on ne sait pas si l’on est revenu
de son appel parce que le thread s’est terminé normalement ou parce que le délai du
join a expiré1.
7.1.5 Annulation avec Future
Nous avons déjà utilisé une abstraction pour gérer le cycle de vie d’une tâche, traiter les
exceptions et faciliter l’annulation – Future. Si l’on suit le principe général selon lequel
il est préférable d’utiliser les classes existantes de la bibliothèque plutôt que construire
les siennes, nous pouvons écrire timedRun() en utilisant Future et le framework
d’exécution des tâches.
ExecutorService.submit() renvoie un objet Future décrivant la tâche et Future dispose
d’une méthode cancel() qui attend un paramètre booléen, mayInterruptIfRunning.
Cette méthode renvoie une valeur indiquant si la tentative d’annulation a réussi (cette
valeur indique seulement si l’on a été capable de délivrer l’interruption, pas si la tâche
l’a détectée et y a réagi). Si mayInterruptIfRunning vaut true et que la tâche soit en
cours d’exécution dans un thread, celui-ci est interrompu. S’il vaut false, cela signifie
"n’exécute pas cette tâche si elle n’a pas encore été lancée" – on ne devrait l’utiliser que
pour les tâches qui n’ont pas été conçues pour traiter les interruptions.
Comme on ne devrait pas interrompre un thread sans connaître sa politique d’interruption,
quand peut-on appeler cancel() avec un paramètre égal à true ? Les threads d’exécution
des tâches créés par les implémentations standard de Executor utilisant une politique
d’interruption qui autorise l’annulation des tâches avec des interruptions, vous pouvez
positionner mayInterruptIfRunning à true lorsque vous annulez des tâches en passant
par leurs objets Future lorsqu’elles s’exécutent dans un Executor standard. Lorsque
vous voulez annuler une tâche, vous ne devriez pas interrompre directement un thread
d’un pool car vous ne savez pas quelle tâche s’exécute lorsque la demande d’interruption
est délivrée – vous devez passer par le Future de la tâche. C’est encore une autre raison
1. Il s’agit d’un défaut de l’API Thread car le fait que le join se termine avec succès ou non a des
conséquences sur la visibilité de la mémoire dans le modèle mémoire de Java, or join ne renvoie rien
qui puisse indiquer s’il a réussi ou non.
Chapitre 7 Annulation et arrêt 151
pour laquelle il faut coder les tâches pour qu’elles traitent les interruptions comme des
demandes d’annulation : elles peuvent ainsi être annulées via leur Future.
Le Listing 7.10 présente une version de timedRun() qui soumet la tâche à un Executor
Service et récupère le résultat par un appel Future.get() temporisé. Si get() se termine
avec une exception TimeoutException, la tâche est annulée via son Future (pour
simplifier le codage, cette version appelle sans condition Future.cancel() dans un bloc
finally afin de profiter du fait que l’annulation d’une tâche déjà terminée n’a aucun
effet). Si le calcul sous-jacent lance une exception avant l’annulation, celle-ci est relancée
par timedRun(), ce qui est le moyen le plus pratique pour que l’appelant puisse traiter
cette exception. Le Listing 7.10 illustre également une autre pratique : l’annulation des
tâches du résultat desquelles on n’a plus besoin (cette technique était également utilisée
dans les Listing 6.13 et 6.16).
Listing 7.10 : Annulation d’une tâche avec Future.
public static void timedRun(Runnable r, long timeout, TimeUnit unit)
throws InterruptedException {
Future<?> task = taskExec.submit(r);
try {
task.get(timeout, unit);
} catch (TimeoutException e) {
// La tâche sera annulée en dessous
} catch (ExecutionException e) {
// L’exception lancée dans la tâche est relancée
throw launderThrowable (e.getCause());
} finally {
// Sans effet si la tâche s’est déjà terminée
task.cancel(true); // interruption si la tâche s’exécute
}
}
Lorsque Future.get() lance une exception InterruptedException ou Timeout
Exception et que l’on sait que le résultat n’est plus nécessaire au programme, on
annule la tâche avec Future.cancel().
7.1.6 Méthodes bloquantes non interruptibles
De nombreuses méthodes bloquantes de la bibliothèque répondent aux interruptions en
se terminant précocément et en lançant une InterruptedException, ce qui facilite la
création de tâches réactives aux annulations. Cependant, toutes les méthodes ou tous les
mécanismes bloquants ne répondent pas aux interruptions ; si un thread est bloqué en
attente d’une E/S synchrone sur une socket ou en attente d’un verrou interne, l’interruption
ne fera que positionner son indicateur d’interruption. Nous pouvons parfois convaincre
les threads bloqués dans des activités non interruptibles de se terminer par un moyen
ressemblant aux interruptions, mais cela nécessite de savoir plus précisément pourquoi
ils sont bloqués.
152 Structuration des applications concurrentes Partie II
m E/S sockets synchrones de java.io. C’est une forme classique d’E/S bloquantes dans
les applications serveur lorsqu’elles lisent ou écrivent dans une socket. Malheureu-
sement, les méthodes read() et write() de InputStream et OutputStream ne
répondent pas aux interruptions ; cependant, la fermeture de la socket sous-jacente
forcera tout thread bloqué dans read() ou write() à lancer une SocketException.
m E/S synchrones de java.nio. L’interruption d’un thread en attente d’un Interrup-
tibleChannel le force à lancer une exception ClosedByInterruptException et à
fermer le canal (tous les autres threads bloqués sur ce canal lanceront également
ClosedByInterruptException). La femeture d’un InterruptibleChannel force les
threads bloqués dans des opérations sur ce canal à lancer AsynchronousClose
Exception. La plupart des Channel standard implémentent InterruptibleChannel.
m E/S asynchrones avec Selector. Si un thread est bloqué dans Selector.select()
(dans java.nio.channels), un appel à close() le force à se terminer préma-
turément.
m Acquisition d’un verrou. Si un thread est bloqué en attente d’un verrou interne,
vous ne pouvez pas l’empêcher de s’assurer qu’il finira par prendre le verrou et de
progresser suffisamment pour attirer son attention d’une autre façon. Cependant, les
classes Lock explicites disposent de la méthode lockInterruptibly(), qui vous
permet d’attendre un verrou tout en pouvant répondre aux interruptions – voir le
Chapitre 13.
La classe ReaderThread du Listing 7.11 présente une technique pour encapsuler les
annulations non standard. Elle gère une connexion par socket simple, lit dans la socket
de façon synchrone et passe à processBuffer() les données qu’elle a reçues. Pour faci-
liter la terminaison d’une connexion utilisateur ou l’arrêt du serveur, ReaderThread
redéfinit interrupt() pour qu’elle délivre une interruption standard et ferme la socket
sous-jacente. L’interruption d’un ReaderThread arrête donc ce qu’il était en train de
faire, qu’il soit bloqué dans read() ou dans une méthode bloquante interruptible.
Listing 7.11 : Encapsulation des annulations non standard dans un thread par redéfinition
de interrupt().
public class ReaderThread extends Thread {
private final Socket socket;
private final InputStream in;
public ReaderThread(Socket socket) throws IOException {
this.socket = socket;
this.in = socket.getInputStream();
}
public void interrupt() {
try {
socket.close();
}
catch (IOException ignored) { }
finally {
Chapitre 7 Annulation et arrêt 153
super.interrupt();
}
}
public void run() {
try {
byte[] buf = new byte[BUFSZ];
while (true) {
int count = in.read(buf);
if (count < 0)
break;
else if (count > 0)
processBuffer(buf, count);
}
} catch (IOException e) { /* Permet au thread de se terminer */ }
}
}
7.1.7 Encapsulation d’une annulation non standard avec newTaskFor()
La technique utilisée par ReaderThread pour encapsuler une annulation non standard
peut être améliorée en utilisant la méthode de rappel newTaskFor(), ajoutée à Thread
PoolExecutor depuis Java 6. Lorsqu’un Callable est soumis à un ExecutorService,
submit() renvoie un Future pouvant servir à annuler la tâche. newTaskFor() est une
méthode fabrique qui crée le Future représentant la tâche ; elle renvoie un Runnable
Future, une interface étendant à la fois Future et Runnable (et implémentée par Future
Task).
Vous pouvez redéfinir Future.cancel() pour personnaliser la tâche Future. Personna-
liser le code d’annulation permet d’inscrire dans un fichier journal des informations sur
l’annulation, par exemple, et vous pouvez également en profiter pour annuler des activités
qui ne répondent pas aux interruptions. Tout comme ReaderThread encapsule l’annulation
des threads qui utilisent des sockets en redéfinissant interrupt(), vous pouvez faire de
même pour les tâches en redéfinissant Future.cancel().
Le Listing 7.12 définit une interface CancellableTask qui étend Callable en lui ajoutant
une méthode cancel() et une méthode fabrique newTask() pour créer un objet Runnable
Future. CancellingExecutor étend ThreadPoolExecutor et redéfinit newTaskFor()
pour qu’une CancellableTask puisse créer son propre Future.
SocketUsingTask implémente CancellableTask et définit Future.cancel() pour
qu’elle ferme la socket et appelle super.cancel(). Si une SocketUsingTask est annulée
via son Future, la socket est fermée et le thread qui s’exécute est interrompu. Ceci
augmente la réactivité de la tâche à l’annulation : non seulement elle peut appeler en
toute sécurité des méthodes bloquantes interruptibles tout en restant réceptive à l’annu-
lation, mais elle peut également appeler des méthodes d’E/S bloquantes sur des sockets.
154 Structuration des applications concurrentes Partie II
Listing 7.12 : Encapsulation des annulations non standard avec newTaskFor().
public interface CancellableTask <T> extends Callable<T> {
void cancel();
RunnableFuture<T> newTask();
}
@ThreadSafe
public class CancellingExecutor extends ThreadPoolExecutor {
...
protected<T> RunnableFuture <T> newTaskFor(Callable<T> callable) {
if (callable instanceof CancellableTask )
return ((CancellableTask <T>) callable).newTask();
else
return super.newTaskFor(callable);
}
}
public abstract class SocketUsingTask <T>
implements CancellableTask <T> {
@GuardedBy("this") private Socket socket;
protected synchronized void setSocket(Socket s) { socket = s; }
public synchronized void cancel() {
try {
if (socket != null)
socket.close();
} catch (IOException ignored) { }
}
public RunnableFuture <T> newTask() {
return new FutureTask<T>(this) {
public boolean cancel(boolean mayInterruptIfRunning ) {
try {
SocketUsingTask .this.cancel();
} finally {
return super.cancel(mayInterruptIfRunning );
}
}
};
}
}
7.2 Arrêt d’un service reposant sur des threads
Les applications créent souvent des services qui utilisent des threads – comme des
pools de threads – et la durée de vie de ces services est généralement plus longue que
celle de la méthode qui les a créés. Si l’application doit s’arrêter en douceur, il faut
mettre fin aux threads appartenant à ces services. Comme il n’y a pas moyen d’imposer
à un thread de s’arrêter, il faut les persuader de se terminer d’eux-mêmes.
Les bonnes pratiques d’encapsulation enseignent qu’il ne faut pas manipuler un thread
– l’interrompre, modifier sa priorité, etc. – qui ne nous appartient pas. L’API des
threads ne définit pas formellement la propriété d’un thread : un thread est représenté
par un objet Thread qui peut être librement partagé, exactement comme n’importe quel
autre objet. Cependant, il semble raisonnable de penser qu’un thread a un propriétaire,
Chapitre 7 Annulation et arrêt 155
qui est généralement la classe qui l’a créé. Un pool de threads est donc le propriétaire
de ses threads et, s’ils doivent être interrompus, c’est le pool qui devrait s’en occuper.
Comme pour tout objet encapsulé, la propriété d’un thread n’est pas transitive : l’appli-
cation peut posséder le service et celui-ci peut posséder les threads, mais l’application
ne possède pas les threads et ne peut donc pas les arrêter directement. Le service doit
donc fournir des méthodes de cycle de vie pour se terminer lui-même et arrêter également
les threads qu’il possède ; l’application peut alors arrêter le service, qui se chargera lui-
même d’arrêter les threads. La classe ExecutorService fournit les méthodes shutdown()
et shutdownNow() ; les autres services possédant des threads devraient proposer un
mécanisme d’arrêt similaire.
Fournissez des méthodes de cycle de vie à chaque fois qu’un service possédant des
threads a une durée de vie supérieure à celle de la méthode qui l’a créé.
7.2.1 Exemple : service de journalisation
La plupart des applications serveur écrivent dans un journal, ce qui peut être aussi simple
qu’insérer des instructions println() dans le code. Les classes de flux comme Print
Writer étant thread-safe, cette approche simple ne nécessiterait aucune synchronisation
explicite1. Cependant, comme nous le verrons dans la section 11.6, une journalisation
en ligne peut avoir un certain coût en termes de performances pour les applications à
fort volume. Une autre possibilité consiste à mettre les messages du journal dans une
file d’attente pour qu’ils soient traités par un autre thread.
La classe LogWriter du Listing 7.13 montre un service simple de journalisation dans
lequel l’activité d’inscription dans le journal a été déplacée dans un thread séparé. Au
lieu que le thread qui produit le message l’écrive directement dans le flux de sortie,
LogWriter le passe à un thread d’écriture via une BlockingQueue. Il s’agit donc d’une
conception de type "plusieurs producteurs, un seul consommateur" où les activités
productrices créent les messages et l’activité consommatrice les écrit. Si le thread
consommateur va moins vite que les producteurs, la BlockingQueue bloquera ces derniers
jusqu’à ce que le thread d’écriture dans le journal rattrape son retard.
1. Si le même message se décompose en plusieurs lignes, vous pouvez également avoir besoin d’un
verrouillage côté client pour empêcher un entrelacement indésirable des messages des différents
threads. Si deux threads inscrivent, par exemple, des traces d’exécution sur plusieurs lignes dans le
même flux avec une instruction println() par ligne, le résultat serait entrelacé aléatoirement et pourrait
donner une seule grande trace inexploitable.
156 Structuration des applications concurrentes Partie II
Listing 7.13 : Service de journalisation producteur-consommateur sans support de l’arrêt.
public class LogWriter {
private final BlockingQueue <String> queue;
private final LoggerThread logger;
public LogWriter(Writer writer) {
this.queue = new LinkedBlockingQueue <String>(CAPACITY);
this.logger = new LoggerThread(writer);
}
public void start() { logger.start(); }
public void log(String msg) throws InterruptedException {
queue.put(msg);
}
private class LoggerThread extends Thread {
private final PrintWriter writer;
...
public void run() {
try {
while (true)
writer.println(queue.take());
} catch(InterruptedException ignored) {
} finally {
writer.close();
}
}
}
}
Pour qu’un service comme LogWriter soit utile en production, nous avons besoin de
pouvoir terminer le thread d’écriture dans le journal sans qu’il empêche l’arrêt normal
de la JVM. Arrêter ce thread est assez simple puisqu’il appelle take() en permanence
et que cette méthode répond aux interruptions ; si l’on modifie ce thread pour qu’il
s’arrête lorsqu’il capture InterruptedException, son interruption arrêtera le service.
Cependant, faire simplement en sorte que le thread d’écriture se termine n’est pas un
mécanisme d’arrêt très satisfaisant. En effet, cet arrêt brutal supprime les messages qui
pourraient être en attente d’écriture et, ce qui est encore plus important, les threads
producteurs bloqués parce que la file est pleine ne seront jamais débloqués. L’annulation
d’une activité producteur-consommateur exige d’annuler à la fois les producteurs et les
consommateurs. Interrrompre le thread d’écriture règle le problème du consommateur
mais, les producteurs n’étant pas ici des threads dédiés, il est plus difficile de les annuler.
Une autre approche pour arrêter LogWriter consiste à positionner un indicateur "arrêt
demandé" pour empêcher que d’autres messages soient soumis, comme on le montre
dans le Listing 7.14. Lorsqu’il est prévenu de cette demande, le consommateur peut
alors vider la file en inscrivant dans le journal les messages en attente et en débloquant
les éventuels producteurs bloqués dans log(). Cependant, cette approche a des situations
de compétition qui la rendent non fiable. L’implémentation du journal est une séquence
tester-puis-agir : les producteurs pourraient constater que le service n’a pas encore été
arrêté et continuer à placer des messages dans la file après l’arrêt avec le risque de se
Chapitre 7 Annulation et arrêt 157
trouver à nouveau bloqués indéfiniment dans log(). Il existe des astuces réduisant cette
probabilité (en faisant, par exemple, attendre quelques secondes le consommateur avant
de déclarer la file comme épuisée), mais elles changent non pas le problème fondamental,
mais uniquement la probabilité que ce problème survienne.
Listing 7.14 : Moyen non fiable d’ajouter l’arrêt au service de journalisation.
public void log(String msg) throws InterruptedException {
if (!shutdownRequested )
queue.put(msg);
else
throw new IllegalStateException("logger is shut down");
}
Pour fournir un arrêt fiable à LogWriter, il faut régler le problème de la situation de
compétition, ce qui signifie qu’il faut que la soumission d’un nouveau message soit
atomique. Cependant, nous ne voulons pas détenir un verrou pendant que l’on place le
message dans la file car put() pourrait se bloquer. Nous pouvons, en revanche, vérifier
de façon atomique qu’il y a eu demande d’arrêt et incrémenter un compteur, afin de nous
"réserver" le droit de soumettre un message, comme dans le Listing 7.15.
Listing 7.15 : Ajout d’une annulation fiable à LogWriter.
public class LogService {
private final BlockingQueue <String> queue;
private final LoggerThread loggerThread;
private final PrintWriter writer;
@GuardedBy("this") private boolean isShutdown;
@GuardedBy("this") private int reservations;
public void start() { loggerThread.start(); }
public void stop() {
synchronized (this) { isShutdown = true; }
loggerThread.interrupt();
}
public void log(String msg) throws InterruptedException {
synchronized (this) {
if (isShutdown)
throw new IllegalStateException (...);
++reservations;
}
queue.put(msg);
}
private class LoggerThread extends Thread {
public void run() {
try {
while (true) {
try {
synchronized (LogService.this) {
if (isShutdown && reservations == 0)
break;
}
String msg = queue.take();
synchronized (LogService.this) { --reservations ; }
158 Structuration des applications concurrentes Partie II
Listing 7.15 : Ajout d’une annulation fiable à LogWriter. (suite)
writer.println(msg);
} catch (InterruptedException e) { /* réessaie */ }
}
} finally {
writer.close();
}
}
}
}
7.2.2 Méthodes d’arrêt de ExecutorService
Dans la section 6.2.4, nous avons vu que ExecutorService offrait deux moyens de
s’arrêter : un arrêt en douceur avec shutdown() et un arrêt brutal avec shutdownNow().
Dans ce dernier cas, shutdownNow() renvoie la liste des tâches qui n’ont pas encore
commencé après avoir tenté d’annuler toutes les tâches qui s’exécutent.
Ces deux options de terminaison sont des compromis entre sécurité et réactivité : la
terminaison brutale est plus rapide mais plus risquée car les tâches peuvent être inter-
rompues au milieu de leur exécution, tandis que la terminaison normale est plus lente
mais plus sûre puisque ExecutorService ne s’arrêtera pas tant que toutes les tâches en
attente n’auront pas été traitées. Les autres services utilisant des threads devraient fournir
un choix équivalent pour leurs modes d’arrêt.
Les programmes simples peuvent s’en sortir en lançant et en arrêtant un ExecutorService
global à partir de main(). Ceux qui sont plus sophistiqués encapsuleront sûrement un
ExecutorService derrière un service de plus haut niveau fournissant ses propres
méthodes de cycle de vie, comme le fait la variante de LogService dans le Listing 7.16,
qui délègue la gestion de ses propres threads à un ExecutorService. Cette encapsulation
étend la chaîne de propriété de l’application au service et au thread en ajoutant un autre
lien ; chaque membre de cette chaîne gère le cycle de vie des services ou des threads qui
lui appartiennent.
Listing 7.16 : Service de journalisation utilisant un ExecutorService.
public class LogService {
private final ExecutorService exec = newSingleThreadExecutor ();
...
public void start() { }
public void stop() throws InterruptedException {
try {
exec.shutdown();
exec.awaitTermination(TIMEOUT, UNIT);
} finally {
writer.close();
}
}
public void log(String msg) {
try {
exec.execute(new WriteTask(msg));
} catch (RejectedExecutionException ignored) { }
}
}
Chapitre 7 Annulation et arrêt 159
7.2.3 Pilules empoisonnées
Un autre moyen de convaincre un service producteur-consommateur de s’arrêter
consiste à utiliser une pilule empoisonnée, c’est-à-dire un objet reconnaissable placé
dans la file d’attente, qui signifie "quand tu me prends, arrête-toi". Avec une file FIFO,
les pilules empoisonnées garantissent que les consommateurs finiront leur travail sur leur
file avant de s’arrêter, puisque tous les travaux soumis avant la pilule seront récupérés
avant elle ; les producteurs ne devraient pas soumettre de nouveaux travaux après avoir
mis la pilule dans la file. Dans les Listings 7.17, 7.18 et 7.19, la classe IndexingService
montre une version "un producteur-un consommateur" de l’exemple d’indexation du
disque que nous avions présenté dans le Listing 5.8. Elle utilise une pilule empoisonnée
pour arrêter le service.
Listing 7.17 : Arrêt d’un service avec une pilule empoisonnée.
public class IndexingService {
private static final File POISON = new File("");
private final IndexerThread consumer = new IndexerThread();
private final CrawlerThread producer = new CrawlerThread();
private final BlockingQueue <File> queue;
private final FileFilter fileFilter;
private final File root;
class CrawlerThread extends Thread { /* Listing 7.18 */ }
class IndexerThread extends Thread { /* Listing 7.19 */ }
public void start() {
producer.start();
consumer.start();
}
public void stop() { producer.interrupt(); }
public void awaitTermination() throws InterruptedException {
consumer.join();
}
}
Listing 7.18 : Thread producteur pour IndexingService.
public class CrawlerThread extends Thread {
public void run() {
try {
crawl(root);
} catch (InterruptedException e) { /* ne fait rien */ }
finally {
while (true) {
try {
queue.put(POISON);
break;
} catch (InterruptedException e1) { /* réessaie */ }
}
}
}
private void crawl(File root) throws InterruptedException {
...
}
}
160 Structuration des applications concurrentes Partie II
Listing 7.19 : Thread consommateur pour IndexingService.
public class IndexerThread extends Thread {
public void run() {
try {
while (true) {
File file = queue.take();
if (file == POISON)
break;
else
indexFile(file);
}
} catch (InterruptedException consumed) { }
}
}
Les pilules empoisonnées ne conviennent que lorsque l’on connaît le nombre de
producteurs et de consommateurs. L’approche utilisée par IndexingService peut être
étendue à plusieurs producteurs : chacun d’eux place une pilule dans la file et le
consommateur ne s’arrête que lorsqu’il a reçu Nproducteurs pilules. Elle peut également
être étendue à plusieurs consommateurs : chaque producteur place alors N consommateurs
pilules dans la file, bien que cela puisse devenir assez lourd avec un grand nombre de
producteurs et de consommateurs. Les pilules empoisonnées ne fonctionnent correctement
qu’avec des files non bornées.
7.2.4 Exemple : un service d’exécution éphémère
Si une méthode doit traiter un certain nombre de tâches et qu’elle ne se termine que
lorsque toutes ces tâches sont terminées, la gestion du cycle de vie du service peut être
simplifiée en utilisant un Executor privé dont la durée de vie est limitée par cette méthode
(dans ces situations, les méthodes invokeAll() et invokeAny() sont souvent utiles).
La méthode checkMail() du Listing 7.20 teste en parallèle si du nouveau courrier est
arrivé sur un certain nombre d’hôtes. Elle crée un exécuteur privé et soumet une tâche
pour chaque hôte : puis elle arrête l’exécuteur et attend la fin, qui intervient lorsque
toutes les tâches de vérification du courrier se sont terminées1.
Listing 7.20 : Utilisation d’un Executor privé dont la durée de vie est limitée à un appel
de méthode.
boolean checkMail(Set<String> hosts, long timeout, TimeUnit unit)
throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool ();
final AtomicBoolean hasNewMail = new AtomicBoolean(false);
try {
for (final String host : hosts)
exec.execute(new Runnable() {
public void run() {
if (checkMail(host))
hasNewMail.set(true);
1. On utilise un AtomicBoolean au lieu d’un booléen volatile car, pour accéder à l’indicateur
hasNewMail à partir du Runnable interne, celui-ci