CodeGym /Cours /C# SELF /Problème des ressources partagées

Problème des ressources partagées

C# SELF
Niveau 56 , Leçon 0
Disponible

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 :

  1. Le thread LIT la valeur depuis la mémoire (counter).
  2. Il augmente cette valeur de 1 (dans un registre du CPU).
  3. 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 !

Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION