Nous commençons ce chapitre en passant en revue la terminologie courante associée aux threads. Un thread est la plus petite unité d’exécution qui peut être planifiée par le système d’exploitation. Un processus est un groupe de threads associés qui s’exécutent dans le même environnement partagé. Il s’ensuit donc qu’un processus mono-thread est celui qui contient exactement un thread, tandis qu’un processus multi-thread prend en charge plus d’un thread.
Par environnement partagé, nous entendons que les threads du même processus partagent le même espace mémoire et peuvent communiquer directement entre eux. Référez-vous à la Figure 13.1 pour une vue d’ensemble des threads et de leur environnement partagé au sein d’un processus.
Cette figure montre un processus unique avec trois threads. Elle montre également comment ils sont mappés à un nombre arbitraire de n processeurs disponibles dans le système. Gardez ce diagramme à l’esprit lorsque nous discuterons des planificateurs de tâches plus loin dans cette section.
Dans ce chapitre, nous parlons beaucoup de tâches et de leurs relations avec les threads. Une tâche est une unité unique de travail effectuée par un thread. Tout au long de ce chapitre, une tâche sera généralement implémentée comme une expression lambda. Un thread peut compléter plusieurs tâches indépendantes, mais une seule tâche à la fois.
Par mémoire partagée dans la Figure 13.1, nous faisons généralement référence aux variables statiques ainsi qu’aux variables d’instance et locales transmises à un thread. Oui, vous voyez enfin comment les variables statiques peuvent être utiles pour effectuer des tâches complexes et multi-threads ! Rappelez-vous que les méthodes et variables statiques sont définies sur un objet de classe unique que toutes les instances partagent. Par exemple, si un thread met à jour la valeur d’un objet statique, cette information est immédiatement disponible pour que d’autres threads dans le processus la lisent.
Comprendre la Concurrence des Threads
La propriété d’exécuter plusieurs threads et processus en même temps est appelée concurrence. Comment le système décide-t-il quoi exécuter lorsqu’il y a plus de threads disponibles que de processeurs ? Les systèmes d’exploitation utilisent un planificateur de threads pour déterminer quels threads doivent être en cours d’exécution, comme illustré dans la Figure 13.1. Par exemple, un planificateur de threads peut employer un planning à tourniquet (round-robin) dans lequel chaque thread disponible reçoit un nombre égal de cycles de processeur pour s’exécuter, les threads étant visités dans un ordre circulaire.
Lorsque le temps alloué à un thread est terminé mais que le thread n’a pas fini son traitement, un changement de contexte se produit. Un changement de contexte est le processus de stockage de l’état actuel d’un thread et de restauration ultérieure de l’état du thread pour continuer l’exécution. Sachez qu’un coût est souvent associé à un changement de contexte en raison du temps perdu et de la nécessité de recharger l’état d’un thread. Les planificateurs de threads intelligents font de leur mieux pour minimiser le nombre de changements de contexte tout en maintenant une application fonctionnelle.
Enfin, un thread peut interrompre ou remplacer un autre thread s’il a une priorité de thread plus élevée que l’autre thread. Une priorité de thread est une valeur numérique associée à un thread qui est prise en considération par le planificateur de threads lors de la détermination des threads qui doivent être en cours d’exécution. En Java, les priorités de thread sont spécifiées comme des valeurs entières.
Création d’un Thread
L’une des façons les plus courantes de définir une tâche pour un thread est d’utiliser l’instance Runnable
. Runnable
est une interface fonctionnelle qui ne prend aucun argument et ne renvoie aucune donnée.
@FunctionalInterface public interface Runnable {
void run();
}
Avec cela, il est facile de créer et de démarrer un thread. En fait, vous pouvez le faire en une ligne de code en utilisant la classe Thread
:
new Thread(() -> System.out.print("Bonjour")).start();
System.out.print("Monde");
La première ligne crée un nouvel objet Thread
puis le démarre avec la méthode start()
. Ce code affiche-t-il BonjourMonde
ou MondeBondour
? La réponse est que nous ne le savons pas. En fonction de la priorité/du planificateur de thread, les deux sont possibles. N’oubliez pas que l’ordre d’exécution des threads n’est souvent pas garanti.
Regardons un exemple plus complexe :
Runnable imprimerInventaire = () -> System.out.println("Impression de l'inventaire du zoo");
Runnable imprimerRegistres = () -> {
for (int i = 0; i < 3; i++)
System.out.println("Impression du registre: " + i);
};
Étant donné ces instances, quelle est la sortie du code suivant ?
System.out.println("début");
new Thread(imprimerInventaire).start();
new Thread(imprimerRegistres).start();
new Thread(imprimerInventaire).start();
System.out.println("fin");
La réponse est qu’elle est inconnue jusqu’à l’exécution. Voici une sortie possible :
début
Impression du registre: 0
Impression de l'inventaire du zoo
fin
Impression du registre: 1
Impression de l'inventaire du zoo
Impression du registre: 2
Cet exemple utilise un total de quatre threads : le thread utilisateur main()
et trois threads supplémentaires créés. Chaque thread créé est exécuté comme une tâche asynchrone. Par asynchrone, nous entendons que le thread exécutant la méthode main()
n’attend pas les résultats de chaque thread nouvellement créé avant de continuer. Par exemple, les créations de threads suivantes peuvent être exécutées avant que le thread créé précédemment ne termine. L’opposé de ce comportement est une tâche synchrone dans laquelle le programme attend (ou bloque) que le thread termine son exécution avant de passer à la ligne suivante. La grande majorité des appels de méthode utilisés jusqu’à ce chapitre ont été synchrones.
Bien que l’ordre d’exécution des threads soit indéterminé une fois que les threads ont été démarrés, l’ordre au sein d’un même thread est toujours linéaire. En particulier, la boucle for()
est toujours ordonnée. De plus, début
apparaît toujours avant fin
.
Appel de run() au lieu de start()
Soyez attentif au code qui tente de démarrer un thread en appelant run()
au lieu de start()
. L’appel de run()
sur un Thread
ou un Runnable
ne démarre pas un nouveau thread. Bien que les extraits de code suivants se compilent, aucun n’exécutera une tâche sur un thread séparé :
System.out.println("début");
new Thread(imprimerInventaire).run();
new Thread(imprimerRegistres).run();
new Thread(imprimerInventaire).run();
System.out.println("fin");
Contrairement à l’exemple précédent, chaque ligne de ce code attendra que la méthode run()
soit complète avant de passer à la ligne suivante. De plus, contrairement au programme précédent, la sortie de cet exemple de code sera la même à chaque exécution.
Plus généralement, nous pouvons créer un Thread
et sa tâche associée de deux façons en Java :
- Fournir un objet
Runnable
ou une expression lambda au constructeurThread
. - Créer une classe qui étend
Thread
et remplace la méthoderun()
.
Tout au long de ce livre, nous préférons créer des tâches avec des expressions lambda. Après tout, c’est beaucoup plus facile, surtout quand on arrive à l’API de Concurrence ! La création d’une classe qui étend Thread
est relativement rare et ne devrait être faite que dans certaines circonstances, comme si vous avez besoin de remplacer d’autres méthodes de thread.
Distinguer les Types de Threads
Cela pourrait vous surprendre que toutes les applications Java, y compris toutes celles que nous avons présentées dans ce livre, soient multi-threads car elles incluent des threads système. Un thread système est créé par la Machine Virtuelle Java (JVM) et s’exécute en arrière-plan de l’application. Par exemple, le ramasse-miettes est géré par un thread système créé par la JVM.
Alternativement, un thread défini par l’utilisateur est créé par le développeur de l’application pour accomplir une tâche spécifique. La majorité des programmes que nous avons présentés jusqu’à présent ne contenaient qu’un seul thread défini par l’utilisateur, qui appelle la méthode main()
. Pour simplifier, nous appelons communément les programmes qui ne contiennent qu’un seul thread défini par l’utilisateur des applications mono-thread.
Les threads système et définis par l’utilisateur peuvent tous deux être créés comme des threads démon. Un thread démon est un thread qui n’empêchera pas la JVM de se terminer lorsque le programme se termine. Une application Java se termine lorsque les seuls threads qui s’exécutent sont des threads démon. Par exemple, si le ramasse-miettes est le seul thread restant en cours d’exécution, la JVM s’arrêtera automatiquement.
Regardons un exemple. Que pensez-vous que cela affiche ?
public class Zoo {
public static void pause() { // Définit la tâche du thread
try {
Thread.sleep(10_000); // Attendre 10 secondes
} catch (InterruptedException e) {}
System.out.println("Thread terminé !");
}
public static void main(String[] unused) {
var travail = new Thread(() -> pause()); // Crée le thread
travail.start(); // Démarre le thread
System.out.println("Méthode principale terminée !");
}
}
Le programme affichera deux instructions à environ 10 secondes d’intervalle :
Méthode principale terminée !
--- attente de 10 secondes ---
Thread terminé !
C’est vrai. Même si la méthode main()
est terminée, la JVM attendra que le thread utilisateur soit terminé avant de terminer le programme. Et si nous changeons travail
pour qu’il soit un thread démon en ajoutant ceci à la ligne 11 ?
travail.setDaemon(true);
Le programme affichera la première instruction et se terminera sans jamais afficher la seconde ligne.
Méthode principale terminée !
N’oubliez pas que par défaut, les threads définis par l’utilisateur ne sont pas des démons, et le programme attendra qu’ils se terminent.
Gérer le Cycle de Vie d’un Thread
Après qu’un thread a été créé, il est dans l’un des six états, illustrés dans la Figure 13.2. Vous pouvez interroger l’état d’un thread en appelant getState()
sur l’objet thread.
Chaque thread est initialisé avec un état NEW
. Dès que start()
est appelé, le thread passe à un état RUNNABLE
. Cela signifie-t-il qu’il est réellement en cours d’exécution ? Pas exactement : il peut être en cours d’exécution, ou non. L’état RUNNABLE
signifie simplement que le thread est capable d’être exécuté. Une fois que le travail du thread est terminé ou qu’une exception non capturée est lancée, l’état du thread devient TERMINATED
, et aucun autre travail n’est effectué.
Lorsqu’il est dans un état RUNNABLE
, le thread peut passer à l’un des trois états où il met en pause son travail : BLOCKED
, WAITING
, ou TIMED_WAITING
. Cette figure inclut les transitions communes entre les états des threads, mais il existe d’autres possibilités. Par exemple, un thread dans un état WAITING
peut être déclenché par notifyAll()
. De même, un thread qui est interrompu par un autre thread sortira de TIMED_WAITING
et retournera directement dans RUNNABLE
.
Nous couvrons certaines (mais pas toutes) de ces transitions dans ce chapitre. Certaines méthodes liées aux threads — telles que wait()
, notify()
, et join()
— sont difficiles à utiliser correctement. Vous devriez les éviter et utiliser l’API de Concurrence autant que possible. Il faut beaucoup de compétence (et un peu de chance !) pour utiliser ces méthodes correctement.
Sondage avec Sleep
Même si la programmation multi-thread vous permet d’exécuter plusieurs tâches en même temps, un thread a souvent besoin d’attendre les résultats d’un autre thread pour continuer. Une solution consiste à utiliser le sondage. Le sondage est le processus de vérification intermittente des données à un intervalle fixe.
Disons que vous avez un thread qui modifie une valeur de compteur statique partagée, et votre thread main()
attend que le thread atteigne 1 million :
public class VerifierResultats {
private static int compteur = 0;
public static void main(String[] args) {
new Thread(() -> {
for(int i = 0; i < 1_000_000; i++) compteur++;
}).start();
while(compteur < 1_000_000) {
System.out.println("Pas encore atteint");
}
System.out.println("Atteint: "+compteur);
}
}
Combien de fois ce programme affiche-t-il Pas encore atteint
? La réponse est que nous ne le savons pas ! Il pourrait afficher 0, 10, ou un million de fois. Utiliser une boucle while()
pour vérifier les données sans une sorte de délai est considéré comme une mauvaise pratique de codage car cela mobilise les ressources du processeur sans raison.
Nous pouvons améliorer ce résultat en utilisant la méthode Thread.sleep()
pour implémenter le sondage et dormir pendant 1 000 millisecondes, soit 1 seconde :
public class VerifierResultatsAvecSleep {
private static int compteur = 0;
public static void main(String[] a) {
new Thread(() -> {
for(int i = 0; i < 1_000_000; i++) compteur++;
}).start();
while(compteur < 1_000_000) {
System.out.println("Pas encore atteint");
try {
Thread.sleep(1_000); // 1 SECONDE
} catch (InterruptedException e) {
System.out.println("Interrompu !");
}
}
System.out.println("Atteint: "+compteur);
}
}
Bien qu’une seconde puisse sembler une petite quantité, nous avons maintenant libéré le processeur pour faire d’autres travaux au lieu de vérifier la variable compteur infiniment dans une boucle. Notez que le thread main()
alterne entre TIMED_WAITING
et RUNNABLE
lorsque sleep()
est entré et quitté, respectivement.
Combien de fois la boucle while()
s’exécute-t-elle dans cette classe révisée ? Toujours inconnu ! Bien que le sondage empêche le processeur d’être submergé par une boucle potentiellement infinie, il ne garantit pas quand la boucle se terminera. Par exemple, le thread séparé pourrait perdre du temps de processeur au profit d’un processus de priorité plus élevée, ce qui entraînerait plusieurs exécutions de la boucle while()
avant qu’il ne termine.
Un autre problème à prendre en compte est la variable compteur partagée. Que se passe-t-il si un thread lit la variable compteur tandis qu’un autre thread l’écrit ? Le thread qui lit la variable partagée peut se retrouver avec une valeur invalide ou inattendue. Nous discutons de ces problèmes en détail dans la section à venir sur l’écriture de code thread-safe.
Interrompre un Thread
Bien que notre solution précédente ait empêché le processeur d’attendre indéfiniment dans une boucle while()
, cela s’est fait au prix de l’insertion de délais d’une seconde dans notre programme. Si la tâche prend 2,1 secondes à s’exécuter, le programme utilisera les 3 secondes complètes, gaspillant 0,9 seconde.
Une façon d’améliorer ce programme est de permettre au thread d’interrompre le thread main()
quand il a terminé :
public class VerifierResultatsAvecSleepEtInterrupt {
private static int compteur = 0;
public static void main(String[] a) {
final var threadPrincipal = Thread.currentThread();
new Thread(() -> {
for(int i = 0; i < 1_000_000; i++) compteur++;
threadPrincipal.interrupt();
}).start();
while(compteur < 1_000_000) {
System.out.println("Pas encore atteint");
try {
Thread.sleep(1_000); // 1 SECONDE
} catch (InterruptedException e) {
System.out.println("Interrompu !");
}
}
System.out.println("Atteint: "+compteur);
}
}
Cette version améliorée inclut à la fois sleep()
, pour éviter de monopoliser le processeur, et interrupt()
, pour que le travail du thread se termine sans retarder le programme. Comme auparavant, l’état de notre thread main()
alterne entre TIMED_WAITING
et RUNNABLE
. L’appel de interrupt()
sur un thread dans l’état TIMED_WAITING
ou WAITING
fait que le thread main()
redevient RUNNABLE
, déclenchant une InterruptedException
. Le thread peut également passer à un état BLOCKED
s’il a besoin de réacquérir des ressources lorsqu’il se réveille.
L’appel de interrupt()
sur un thread déjà dans un état RUNNABLE
ne change pas l’état. En fait, cela ne change le comportement que si le thread vérifie périodiquement la valeur de Thread.isInterrupted()
.