Maintenant que vous savez comment écrire du code thread-safe, parlons de ce qui constitue un problème de threading. Un problème de threading peut survenir dans les applications multi-threads lorsque deux threads ou plus interagissent de manière inattendue et indésirable. Par exemple, deux threads peuvent se bloquer mutuellement pour accéder à un segment particulier du code.
L’API Concurrency a été créée pour aider à éliminer les problèmes potentiels de threading communs à tous les développeurs. Comme vous l’avez vu, l’API Concurrency crée des threads et gère des interactions complexes entre threads pour vous, souvent en quelques lignes de code seulement.
Bien que l’API Concurrency réduise le potentiel de problèmes de threading, elle ne les élimine pas. En pratique, trouver et identifier les problèmes de threading dans une application est souvent l’une des tâches les plus difficiles qu’un développeur puisse entreprendre.
Comprendre la Vivacité (Liveness)
Comme vous l’avez vu dans ce chapitre, de nombreuses opérations de threads peuvent être effectuées indépendamment, mais certaines nécessitent une coordination. Par exemple, la synchronisation sur une méthode exige que tous les threads qui appellent la méthode attendent que les autres threads terminent avant de continuer. Vous avez également vu plus tôt dans le chapitre que les threads dans un CyclicBarrier
attendront tous que la limite de la barrière soit atteinte avant de continuer.
Que se passe-t-il pour l’application pendant que tous ces threads attendent? Dans de nombreux cas, l’attente est éphémère, et l’utilisateur a très peu l’idée qu’un délai s’est produit. Dans d’autres cas, cependant, l’attente peut être extrêmement longue, peut-être infinie.
La vivacité est la capacité d’une application à pouvoir s’exécuter en temps opportun. Les problèmes de vivacité sont donc ceux dans lesquels l’application devient non réactive ou entre dans une sorte d’état “bloqué”. Plus précisément, les problèmes de vivacité sont souvent le résultat d’un thread entrant dans un état BLOCKING ou WAITING pour toujours, ou entrant/sortant de façon répétée de ces états. Il existe trois types de problèmes de vivacité avec lesquels vous devriez être familier: l’interblocage (deadlock), la famine (starvation) et le verrou actif (livelock).
Interblocage (Deadlock)
L’interblocage se produit lorsque deux threads ou plus sont bloqués pour toujours, chacun attendant l’autre. Nous pouvons illustrer ce principe avec l’exemple suivant. Imaginez que notre zoo a deux renards: Renard et Queue. Renard aime manger d’abord puis boire de l’eau, tandis que Queue aime boire de l’eau d’abord puis manger. De plus, aucun des deux animaux n’aime partager, et ils ne termineront leur repas que s’ils ont un accès exclusif à la nourriture et à l’eau.
Le gardien du zoo place la nourriture d’un côté de l’environnement et l’eau de l’autre côté. Bien que nos renards soient rapides, il leur faut quand même 100 millisecondes pour courir d’un côté de l’environnement à l’autre.
Que se passe-t-il si Renard obtient d’abord la nourriture et Queue obtient d’abord l’eau? L’application suivante modélise ce comportement:
import java.util.concurrent.*;
class Nourriture {}
class Eau {}
public record Renard(String nom) {
public void mangerEtBoire(Nourriture nourriture, Eau eau) {
synchronized(nourriture) {
System.out.println(nom() + " a la Nourriture!");
deplacer();
synchronized(eau) {
System.out.println(nom() + " a l'Eau!");
}
}
}
public void boireEtManger(Nourriture nourriture, Eau eau) {
synchronized(eau) {
System.out.println(nom() + " a l'Eau!");
deplacer();
synchronized(nourriture) {
System.out.println(nom() + " a la Nourriture!");
}
}
}
public void deplacer() {
try { Thread.sleep(100); } catch (InterruptedException e) {}
}
public static void main(String[] args) {
// Créer participants et ressources
var renard = new Renard("Renard");
var queue = new Renard("Queue");
var nourriture = new Nourriture();
var eau = new Eau();
// Traiter les données
var service = Executors.newScheduledThreadPool(10);
try {
service.submit(() -> renard.mangerEtBoire(nourriture,eau));
service.submit(() -> queue.boireEtManger(nourriture,eau));
} finally {
service.shutdown();
}
}
}
Dans cet exemple, Renard obtient la nourriture puis se déplace de l’autre côté de l’environnement pour obtenir l’eau. Malheureusement, Queue a déjà bu l’eau et attend que la nourriture devienne disponible. Le résultat est que notre programme affiche ce qui suit, et il reste bloqué indéfiniment:
Renard a la Nourriture!
Queue a l'Eau!
Cet exemple est considéré comme un interblocage car les deux participants sont bloqués de façon permanente, attendant des ressources qui ne deviendront jamais disponibles.
Famine (Starvation)
La famine se produit lorsqu’un seul thread est perpétuellement privé d’accès à une ressource partagée ou un verrou. Le thread est toujours actif, mais il est incapable de terminer son travail en raison d’autres threads qui prennent constamment la ressource qu’il essaie d’accéder.
Dans notre exemple de renard, imaginez que nous avons une meute de renards très affamés et très compétitifs dans notre environnement. Chaque fois que Renard se lève pour aller chercher de la nourriture, l’un des autres renards le voit et se précipite pour manger avant lui. Renard est libre de se déplacer dans l’enclos, de faire une sieste et de hurler pour appeler un gardien, mais n’est jamais capable d’accéder à la nourriture. Dans cet exemple, Renard subit littéralement et figurativement une famine. C’est une bonne chose que ce ne soit qu’un exemple théorique!
Verrou Actif (Livelock)
Le verrou actif se produit lorsque deux threads ou plus sont conceptuellement bloqués à jamais, bien qu’ils soient chacun toujours actifs et essaient de terminer leur tâche. Le verrou actif est un cas particulier de famine de ressources dans lequel deux threads ou plus tentent activement d’acquérir un ensemble de verrous, sont incapables de le faire, et redémarrent une partie du processus.
Le verrou actif est souvent le résultat de deux threads essayant de résoudre un interblocage. Revenant à notre exemple de renard, imaginez que Renard et Queue tiennent tous les deux leurs ressources de nourriture et d’eau, respectivement. Ils réalisent chacun qu’ils ne peuvent pas finir leur repas dans cet état, alors ils relâchent tous les deux leur nourriture et leur eau, courent du côté opposé de l’environnement et ramassent l’autre ressource. Maintenant, Renard a l’eau, Queue a la nourriture, et aucun des deux ne peut finir son repas!
Si Renard et Queue continuent ce processus indéfiniment, on parle de verrou actif. Renard et Queue sont actifs, courant d’avant en arrière à travers leur zone, mais aucun des deux ne peut terminer son repas. Renard et Queue exécutent une forme de récupération d’interblocage échouée. Chaque renard remarque qu’il entre potentiellement dans un état d’interblocage et répond en libérant toutes ses ressources verrouillées. Malheureusement, le processus de verrouillage et déverrouillage est cyclique, et les deux renards sont conceptuellement bloqués.
En pratique, le verrou actif est souvent un problème difficile à détecter. Les threads dans un état de verrou actif semblent actifs et capables de répondre aux requêtes, même lorsqu’ils sont coincés dans un cycle sans fin.
Gérer les Conditions de Course (Race Conditions)
Une condition de course est un résultat indésirable qui se produit lorsque deux tâches qui devraient être terminées séquentiellement sont terminées en même temps. Nous avons rencontré des exemples de conditions de course plus tôt dans le chapitre lorsque nous avons présenté la synchronisation.
Maintenant, nous fournissons un exemple plus illustratif. Imaginez que deux visiteurs du zoo, Lucie et Sophie, s’inscrivent pour un compte sur le nouveau site web des visiteurs du zoo. Toutes deux veulent utiliser le même nom d’utilisateur, FanDuZoo, et chacune envoie une demande pour créer le compte en même temps.
Résultats Possibles pour Cette Condition de Course
- Les deux utilisateurs peuvent créer des comptes avec le nom d’utilisateur FanDuZoo.
- Aucun utilisateur ne peut créer un compte avec le nom d’utilisateur FanDuZoo, et un message d’erreur est renvoyé aux deux utilisateurs.
- Un utilisateur peut créer un compte avec le nom d’utilisateur FanDuZoo, tandis que l’autre utilisateur reçoit un message d’erreur.
Le premier résultat est vraiment mauvais, car il conduit à des utilisateurs essayant de se connecter avec le même nom d’utilisateur. Quelles données voient-ils lorsqu’ils se connectent? Le deuxième résultat oblige les deux utilisateurs à réessayer, ce qui est frustrant mais au moins ne conduit pas à des données corrompues ou mauvaises.
Le troisième résultat est souvent considéré comme la meilleure solution. Comme dans la deuxième situation, nous préservons l’intégrité des données; mais contrairement à la deuxième situation, au moins un utilisateur peut avancer sur la première demande, évitant des scénarios supplémentaires de condition de course.
Il est important de comprendre que les conditions de course conduisent à des données invalides si elles ne sont pas correctement gérées. Même la solution où les deux participants échouent à progresser est préférable à celle où des données invalides sont autorisées à entrer dans le système.