1. Exemples plus complexes de Race Condition
La Race Condition est un des types d'erreurs les plus sournois. Pourquoi ? Parce qu'elles surviennent en général rarement, dépendent de la vitesse du processeur, des latences de l'OS, des commutations aléatoires entre threads, et se reproduisent au moment où vous voulez déjà partir chez vous. Aucun analyseur statique ne les attrape. Les tests unitaires ne les détectent pas (sauf si vous êtes devin), mais un jour... elles se manifestent en production.
Pour éviter ces mauvaises surprises, il faut bien comprendre comment naissent les Race Conditions et que faire.
Exemple 1. Une banque sans éthique bancaire
Supposons que nous avons une classe de compte bancaire très simple :
public class Account
{
public int Balance = 0;
public void Deposit(int amount)
{
Balance += amount;
}
public void Withdraw(int amount)
{
Balance -= amount;
}
}
Imaginez que deux threads essaient en même temps de créditer le compte de 100 et 200 euros, puis chacun retire successivement 50 euros. Dans un monde mono-thread, le solde serait toujours : (0 + 100 + 200 - 50 - 50) = 200.
Et en réalité multithread ? Faisons l'expérience :
var acc = new Account();
var t1 = new Thread(() => {
acc.Deposit(100);
acc.Withdraw(50);
});
var t2 = new Thread(() => {
acc.Deposit(200);
acc.Withdraw(50);
});
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine(acc.Balance); // Et ici chaque exécution peut donner une réponse différente !
Explication :
Si les deux threads lisent Balance comme 0, puis l'un écrit 100, l'autre — 200, mais qu'il y a des interversions entre ces actions — le solde peut «perdre» ou «ajouter» de l'argent. Une banque sans verrous — le rêve d'un fraudeur !
Exemple 2. Variable-flag
Une erreur très fréquente — utiliser une variable-flag comme indicateur d'état :
bool isReady = false;
void Worker()
{
while (!isReady)
{
// on attend...
}
// faire quelque chose
}
Un autre thread peut écrire isReady = true. À première vue, cela semble sûr : qu'est-ce qui peut mal tourner ? En fait, même la lecture et l'écriture d'un bool peuvent être dangereuses ! Raisons : optimisations du compilateur, cache du processeur, reorder des instructions sur un système multiprocesseur.
Que peut-il se passer ?
Un thread peut ne jamais remarquer le changement, restant dans la boucle infinie, même si l'autre thread a déjà mis isReady = true depuis longtemps. Pour transmettre des «flags» entre threads, utilisez toujours des primitives spéciales (volatile, Interlocked, events, etc. — voir la documentation officielle).
Exemple 3. Vérification sur null et création d'objet
Imaginez une classe singleton :
public class Singleton
{
private static Singleton _instance;
public static Singleton Instance
{
get
{
if (_instance == null)
{
_instance = new Singleton();
}
return _instance;
}
}
}
Le code semble trivial ? Mais si plusieurs threads appellent Instance en même temps, plusieurs instances peuvent être créées ! C'est l'exemple classique du double-check locking sans synchronisation : le champ _instance est initialisé en course si on ne protège pas par lock.
2. Comment la Race Condition apparaît dans le code
Opération «Lire-modifier-écrire» — anatomie du mal
Reprenons notre incrément : counter++
En termes processeur, c'est :
- Lire depuis la mémoire : register = counter;
- Incrémenter : register = register + 1;
- Écrire de retour : counter = register;
Que se passe-t-il si deux threads exécutent ce bloc presque simultanément ?
- Les deux threads lisent counter = 0.
- Les deux incrémentent leur registre interne à 1.
- Les deux écrivent de retour 1.
Résultat : pour deux incréments la valeur n'a augmenté que de 1 ! Un des incréments a été «mangé» — et personne ne s'en est rendu compte.
Visualisation de la «collision» de deux threads
Thread A | Thread B | counter-------------------------------------------
Lit (0) | | 0
| Lit (0) | 0
Incrémente (1) | | 0
| Incrémente (1) | 0
Écrit (1) | | 1
| Écrit (1) | 1 <-- Oups ! On attendait 2, obtenu 1
Pourquoi la Race Condition est-elle si difficile à attraper ?
- La Race Condition se «masque». Vous rajoutez quelques Console.WriteLine — et l'erreur peut disparaître. Simplement parce que les threads prennent un autre chemin.
- L'erreur dépend du nombre de cœurs, de leur charge, de la version de l'OS et de .NET. Hier tout fonctionnait, aujourd'hui tout se casse.
- Le résultat est variable : l'erreur ne se manifeste pas toujours, seulement dans des «cas particuliers».
- Déboguer ça — quel plaisir…
3. Race Condition dans les collections et classes .NET
La Race Condition n'intervient pas uniquement avec des variables. Les collections, queues et même des classes standard .NET ne sont pas toujours protégées.
Exemple : List<T> n'est pas thread-safe
Si deux threads appellent simultanément .Add() sur une List<T> ordinaire, les conséquences peuvent être catastrophiques :
var list = new List<int>();
var tasks = new List<Task>();
for (int t = 0; t < 10; t++)
{
int threadNum = t;
tasks.Add(Task.Run(() => {
for (int i = 0; i < 1000; i++)
list.Add(threadNum * 1000 + i);
}));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // Très probable que ce soit < 10000
Ici la Race Condition survient dans la méthode Add(), parce que la collection peut agrandir son tableau interne, copier des éléments, et pendant ce temps un autre thread ajoute quelque chose... Résultat — données perdues, exceptions ou collection corrompue.
Exemple : Plusieurs threads utilisent le même indexeur
var array = new int[10];
void Worker(int index, int value)
{
array[index] = value;
}
Parallel.Invoke(
() => Worker(5, 1),
() => Worker(5, 2)
);
Au final, dans l'élément array[5] peut se retrouver soit 1, soit 2 — selon lequel des threads a écrit en dernier. Parfois ce comportement est même voulu, mais le plus souvent c'est la source de bugs subtils et difficiles à attraper.
4. Nuances utiles
Race Condition et opérations non atomiques
Faites particulièrement attention si une opération semble «petite» mais n'est pas atomique.
- i++, i--
- a = b
- myObject.Property = value
- «J'ai vérifié le flag — j'ai changé la valeur»
- «J'ai obtenu l'objet — j'ai modifié son état interne»
Tout cela séparément n'est pas atomique ! D'autres threads peuvent «s'intercaler» entre les micro-étapes.
Mythes et pièges des Race Conditions
Mythe 1 : «Si la variable est un int, alors c'est sûr, c'est un primitif».
Réalité : Même les opérations sur int ne garantissent pas l'atomicité, si elles ne sont pas marquées volatile et si elles ne passent pas par une API spécialisée.
Mythe 2 : «Si j'écris dans une collection et qu'un autre thread ne fait que lire — tout va bien».
Réalité : Non ! Dans .NET les collections ne garantissent pas l'intégrité lors d'un accès concurrent lecture/écriture ; on peut obtenir des objets «cassés» ou des exceptions bizarres.
Mythe 3 : «Les Race Conditions n'apparaissent que dans des services fortement chargés».
Réalité : Même dans de petits utilitaires, jeux, applications desktop on peut rencontrer des états de course. Simplement ils se manifestent plus rarement ou pas immédiatement.
Comment se protéger des Race Conditions ?
- Utiliser des primitives de synchronisation (lock, Mutex, Monitor, etc.).
- Pour les opérations de base d'incrément/décrément sur des numériques — utiliser Interlocked (doc).
- Pour les collections — utiliser des classes thread-safe dédiées (ConcurrentBag, ConcurrentDictionary, etc. — voir la documentation).
- Ne pas utiliser des variables-flag et des états sans protection (volatile, events, primitives de synchronisation).
Quand survient une Race Condition
| Scénario | Peut-il y avoir Race Condition ? | Comment se protéger |
|---|---|---|
| Plusieurs threads lisent les données | Non (si pas de modification) | - |
| Un thread écrit, un autre lit | Oui | |
| Plusieurs threads écrivent | Oui | |
| Plusieurs threads modifient une collection | Oui | Collections thread-safe |
| Plusieurs threads utilisent un flag | Oui | volatile, lock, events |
L'atomicité n'est pas une panacée
Parfois même des opérations atomiques ne protègent pas contre des «courses» logiques.
Exemple :
if (!cache.ContainsKey(key))
{
cache[key] = GetData(key);
}
Si deux threads entrent dans ce if en même temps, les deux verront que la clé est absente, et les deux créeront une nouvelle valeur — le second écrasera le résultat du premier ! Ici ni les opérations atomiques sur int, ni Interlocked ne sauveront — il faut un verrou complet ou une fonction spécialisée (GetOrAdd dans ConcurrentDictionary).
4. Erreurs typiques des étudiants en travaillant avec les Race Conditions
Erreur n°1 : penser que lock n'est nécessaire que pour «quelque chose de gros».
En réalité même une simple variable peut casser sans protection.
Erreur n°2 : verrouiller le mauvais objet.
Par exemple, une string ou un objet accessible depuis l'extérieur de la classe. Au final la synchronisation ne fonctionne pas comme prévu.
Erreur n°3 : se fier au fait que le résultat est «presque toujours correct».
Si un bug se manifeste rarement — ça ne veut pas dire qu'on peut laisser l'accès non protégé.
Erreur n°4 : utiliser des méthodes async sans synchronisation.
On espère «que ça passera» et on obtient des erreurs chaotiques dans des endroits inattendus.
Erreur n°5 : écrire un Singleton avec double-check sans lock.
Au lieu d'un seul objet en mémoire il peut y avoir plusieurs copies, et le débogage devient un cauchemar.
GO TO FULL VERSION