1. Découverte de la condition de concurrence (race condition)
Rappelons ce qu’est une condition de concurrence (race condition) : une situation où le résultat d’un programme dépend de l’ordre dans lequel les threads accèdent à des données ou à des ressources partagées. Si l’ordre d’exécution change, le résultat devient imprévisible. C’est comme si vous essayiez, avec un ami, d’éditer en même temps le même document : celui qui tape le plus vite « gagne », et le texte final peut s’avérer très étrange.
En Java (et dans tout autre langage prenant en charge le multithreading), une race condition apparaît lorsque plusieurs threads lisent et/ou modifient simultanément la même variable sans synchronisation adéquate.
Pourquoi une race condition survient-elle ?
Les threads Java fonctionnent en parallèle. Si deux threads accèdent simultanément à une même variable (par exemple, incrémentent un compteur partagé), ils peuvent se « marcher dessus ». Même si l’opération semble atomique (par exemple, counter++), en réalité, ce n’est pas le cas !
Comment fonctionne counter++ ?
L’opération d’incrément comprend plusieurs étapes :
- Lire la valeur actuelle de la variable en mémoire.
- Augmenter cette valeur de un.
- Écrire la nouvelle valeur en mémoire.
Si, au même moment, un autre thread fait aussi counter++, ils peuvent tous deux lire la même valeur, l’augmenter et écrire le même résultat — au final, un incrément est « perdu ».
2. Exemple de race condition : incrément d’un compteur
Écrivons un petit programme qui lance plusieurs threads, chacun augmentant le compteur partagé de 1. A priori, si nous lançons 1000 threads, la valeur finale du compteur devrait être 1000. Vérifions !
public class RaceConditionDemo {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
int threads = 1000;
Thread[] threadArray = new Thread[threads];
for (int i = 0; i < threads; i++) {
threadArray[i] = new Thread(() -> {
counter++; // Opération DANGEREUSE !
});
threadArray[i].start();
}
// Nous attendons la fin de tous les threads
for (int i = 0; i < threads; i++) {
threadArray[i].join();
}
System.out.println("Attendu: " + threads);
System.out.println("Obtenu: " + counter);
}
}
Sortie attendue :
Attendu: 1000
Obtenu: 843
La valeur peut varier à chaque exécution : parfois 900, parfois 700, et parfois 1000 — mais très rarement.
Pourquoi cela se produit-il ?
Les threads lisent simultanément la valeur de counter, l’augmentent et la réécrivent. Si deux threads lisent la même valeur, l’augmentent puis l’écrivent, un incrément est perdu. En conséquence, la valeur finale est toujours inférieure à celle attendue.
3. Autre exemple : une banque sans synchronisation
Imaginons que nous ayons un compte bancaire et que deux threads retirent de l’argent en même temps.
public class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) {
// Simulation d'un travail long
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
}
public class BankDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.withdraw(100));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Attendu: 0 ou 100");
System.out.println("Solde réel: " + account.getBalance());
}
}
Parfois, les deux threads verront qu’il y a 100 sur le compte et retireront tous deux. Le solde deviendra alors -100 ! (Dans la vraie vie, cela n’arrive pas, mais en code — tout à fait.)
4. Nuances utiles
Conséquences d’une race condition
Une condition de concurrence, ce ne sont pas seulement des résultats « étranges ». C’est un véritable casse-tête pour le développeur, parce que :
- Les erreurs n’apparaissent pas toujours. Parfois, le programme fonctionne correctement, parfois non. Tout dépend de la façon dont les threads « ont eu le temps » d’exécuter leurs actions.
- Les tests ne garantissent pas le succès. Vous pouvez exécuter le programme de nombreuses fois — tout peut bien se passer, puis soudain tout se casse.
- Les erreurs sont difficiles à capturer. Le comportement dépend de la vitesse du processeur, de la charge du système, des autres programmes en cours d’exécution.
- Des défaillances critiques peuvent survenir : perte de données, calculs incorrects, chute de l’application.
Exemples concrets
- Applications financières : calcul erroné du solde, doubles débits.
- Serveurs : pertes de messages, traitement incorrect des requêtes.
- Jeux : « téléportation » des personnages, attribution incorrecte de points.
Pourquoi les tests ne protègent-ils pas contre une race condition ?
Une race condition est un « Heisenbug » (un bug qui disparaît quand on essaie de l’attraper). Même si vous exécutez vos tests mille fois sans voir d’erreur, cela ne signifie pas qu’elle n’existe pas ! Tout dépend de la manière dont l’OS planifie l’exécution des threads. Parfois tout se passe bien, parfois les threads « entrent en collision » et le problème survient.
Comment éviter une race condition ?
- Synchronisation : utilisez le mot-clé synchronized pour les méthodes ou blocs de code afin qu’un seul thread puisse modifier les données partagées à un instant donné.
- Opérations atomiques : utilisez les classes du package java.util.concurrent.atomic (par exemple, AtomicInteger), qui assurent des opérations sûres sans synchronisation explicite.
- Immutabilité : si un objet ne peut pas être modifié, une race condition est impossible.
Exemple avec synchronisation
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
Désormais, si plusieurs threads appellent increment(), un seul thread pourra exécuter cette méthode à un instant donné.
5. Erreurs typiques lors du travail avec des variables partagées entre threads
Erreur n°1 : confiance naïve dans la sécurité des opérations simples.
Beaucoup pensent que counter++ est une seule opération et qu’il ne peut rien arriver de grave. En réalité, c’est trois opérations, et un autre thread peut « s’intercaler » entre elles.
Erreur n°2 : utiliser des variables ordinaires pour échanger des données entre threads.
Si plusieurs threads écrivent et lisent une même variable sans synchronisation — bonjour, race condition !
Erreur n°3 : s’attendre à ce que l’erreur se manifeste toujours.
Une race condition peut ne se manifester que parfois, ce qui la rend particulièrement sournoise. Ne comptez pas sur le fait que « si tout marchait en test, tout ira bien ».
Erreur n°4 : ignorer la synchronisation avec les collections.
Les collections ordinaires comme ArrayList ne sont pas thread-safe. Si plusieurs threads ajoutent ou suppriment des éléments, des pannes et même des crashs sont possibles.
Erreur n°5 : essayer de « réparer » une race condition avec des délais.
Par exemple, via Thread.sleep(10) ou d’autres pauses « magiques ». Cette approche ne résout pas le problème, elle ne fait que le masquer. La vraie solution — synchronisation ou opérations atomiques.
GO TO FULL VERSION