1. Introduction
Dans une application multithread, une ressource partagée est tout ce à quoi deux threads ou plus peuvent accéder en même temps. Ça peut être :
- Une variable (par exemple, un compteur global ou une liste).
- Un objet (par exemple, une collection d'utilisateurs).
- Un fichier ou un socket réseau.
- Toute structure de données modifiée par différents threads.
Dans nos applications console on rencontrera le plus souvent des variables et des objets qui sont "sharés" entre les threads.
Analogie
Imaginez deux personnes qui essaient d'écrire en même temps dans le même cahier sans se mettre d'accord sur l'ordre. Au mieux — l'écriture sera bancale, au pire — quelqu'un écrasera les données de l'autre. En programmation c'est exactement la même chose, sauf que ces "personnes" sont des threads.
Brève vue des ressources typiques sujettes aux races
Le tableau ci-dessous montre les ressources les plus fréquentes, dangereuses pour un accès concurrent depuis plusieurs threads :
| Ressource | Groupes de problèmes | Exemple |
|---|---|---|
| Variables de type int | Incrémentation/décrémentation incorrecte | Compteurs, indices |
| Collections partagées | Perte/corruption d'éléments, exceptions | Liste partagée de commandes |
| Objets | Modifications d'état non consistantes | Drapeaux, propriétés |
| Fichiers | Corruption des données, lecture/écriture incorrecte | Fichiers de log, configuration |
2. Condition de course : comment ça se manifeste ?
Exemple : compteur de visites
Supposons qu'on veuille compter combien de fois un utilisateur a cliqué sur un bouton (ou, dans notre exemple, combien de fois différents threads ont incrémenté une variable). Version simple du code :
int counter = 0;
void Increment() {
counter++;
}
Maintenant on crée deux threads, dans chacun desquels Increment() est appelé 100 000 fois :
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"On attendait : 200000, obtenu : {counter}");
}
}
Combien de fois logiquement counter devrait-il être incrémenté ? 200000 ! Mais si vous lancez ce code plusieurs fois, vous verrez presque sûrement des nombres différents : 185000, 192500, 198765… Pourquoi ?
3. Pourquoi counter++ n'est pas une opération atomique ?
Comment counter++ fonctionne réellement
En C# et d'autres langages de haut niveau, le programme est traduit en un ensemble d'instructions machine. L'opérateur counter++, malheureusement, ne se transforme pas en une seule commande magique "ajoute 1 à la variable". Voici ce qui se passe réellement :
- Le thread LIT la valeur depuis la mémoire (counter).
- Il augmente cette valeur de 1 (dans un registre du CPU).
- Il écrit la nouvelle valeur de retour en mémoire (counter).
Si deux threads font ça presque en même temps, ils peuvent lire la même ancienne valeur, l'augmenter, et tous les deux écrire le résultat, perdant ainsi un incrément.
Scénario de race
Supposons que counter valait 1000. Les deux threads lisent cette valeur (étape 1), l'augmentent chacun à 1001 (étape 2), puis écrivent tous les deux 1001 (étape 3). Terrible : un incrément est juste perdu !
Visualisation de la race
| Moment dans le temps | Thread 1 | Thread 2 | Valeur de counter |
|---|---|---|---|
| 1 | Lecture 1000 | 1000 | |
| 2 | Lecture 1000 | 1000 | |
| 3 | Incrément à 1001 | Incrément à 1001 | 1000 (pas encore écrit) |
| 4 | Écriture 1001 | 1001 | |
| 5 | Écriture 1001 | 1001 |
Au final, pour deux incréments la valeur n'a augmenté que d'1 !
4. Encore quelques exemples : des "bugs invisibles"
Et si la race condition n'implique pas des nombres ?
Imaginons maintenant plusieurs threads ajoutant des éléments dans la même liste :
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
static List<int> numbers = new List<int>();
static void AddNumbers()
{
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
static void Main()
{
Thread t1 = new Thread(AddNumbers);
Thread t2 = new Thread(AddNumbers);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"On attendait : 20000, obtenu : {numbers.Count}");
}
}
Ce code peut aussi produire des résultats différents à chaque exécution : parfois le programme va planter (exception), parfois vous verrez moins d'éléments que prévu.
Pourquoi ? Parce que la collection List<T> n'est pas thread-safe par défaut. Donc quand deux threads appellent Add en même temps, la structure interne de la liste peut être corrompue.
5. Atomicité des opérations
Qu'est-ce qu'une opération atomique ?
Une opération atomique est une opération qui s'exécute entièrement, sans possibilité d'être interrompue par un autre thread au milieu. C'est un peu comme une "transaction" : ou tout se passe, ou rien ne se passe.
- Les affectations de type int myVar = 42; sont atomiques sur la plupart des plateformes (sauf s'il s'agit d'un objet gigantesque).
- Mais counter++ n'est pas atomique — c'est trois actions séquentielles.
Opérations atomiques spéciales
Dans .NET il existe des classes spéciales pour les opérations atomiques : par exemple, Interlocked. On verra cette approche dans les prochaines leçons.
Exemple d'incrément atomique avec Interlocked.Increment :
using System.Threading;
int counter = 0;
Interlocked.Increment(ref counter); // opération atomique !
6. Pourquoi il est difficile d'attraper une race condition ?
La race condition est dangereuse parce que :
- Elle peut se manifester seulement sous forte charge.
- On ne la capture pas à 100%, mais à 5% ou même à 0,01% des cas.
- Elle plante "aléatoirement" et apparaît là où personne ne s'y attend.
Comment reconnaître le problème ?
Si à chaque exécution du programme vous obtenez des résultats différents (et incorrects), il faut soupçonner une condition de course.
Blagues de développeurs
"Si un bug apparaît rarement et se corrige en ajoutant Thread.Sleep(50) — vous avez des problèmes plus sérieux que vous ne le pensez."
7. Nuances utiles
Synchronisation
Pour protéger les sections critiques (les portions de code qui manipulent les ressources partagées), il faut les synchroniser. Mais c'est le sujet des prochaines leçons. Pour l'instant l'important est d'apprendre à remarquer et expliquer le problème.
Erreurs typiques des débutants
Beaucoup de débutants se disent : "J'ai counter++ — qu'est-ce qui pourrait mal tourner ?" Hélas, dès que vous avez plus d'un thread, tout peut mal tourner ! Même des choses apparemment simples : lecture et écriture de variables, ajout d'éléments à une liste, modification de l'état d'un objet, et bien d'autres.
Place des races de données en développement réel
Dans les applications multithread modernes (par exemple, dans des API serveur, le traitement de requêtes web, les jeux et les applis mobiles) il y a presque toujours des ressources partagées. Sans synchronisation, les races de données entraînent un traitement incorrect des commandes, des plantages, des fuites mémoire et d'énormes difficultés de débogage.
Lors des entretiens pour des postes middle/senior on vous demandera forcément : "Qu'est-ce qu'une condition de course ? Comment l'éviter ?" Si vous pouvez donner les exemples ci-dessus — et expliquer la mécanique — les recruteurs seront contents !
GO TO FULL VERSION