CodeGym /Cours /C# SELF /Introduction aux collections

Introduction aux collections Concurrent

C# SELF
Niveau 58 , Leçon 0
Disponible

1. Contexte du problème

Dans une application mono-thread, des collections comme List<T>, Dictionary<T> fonctionnent de manière prévisible. Mais dès que plusieurs threads accèdent simultanément à la même collection, un problème familier apparaît : une race condition.

Si plusieurs threads essaient de lire et/ou d'écrire dans la même collection sans synchronisation appropriée, vous pouvez obtenir :

  • Données incorrectes : un élément a pu être supprimé par un thread pendant qu'un autre essayait de le mettre à jour.
  • Perte de données : un thread a ajouté un élément, et un autre l'a écrasé sans connaître l'enregistrement précédent.
  • Exceptions : la collection peut se retrouver dans un état invalide, et vous obtiendrez une InvalidOperationException (par exemple, "Collection was modified; enumeration operation may not execute.") ou même une NullReferenceException.

Exemple 1 : Race condition dans List<T> (simple incrément)

Deux threads incrémentent simultanément le même élément de la liste.

using System.Collections.Generic;
using System.Threading.Tasks; // Pour Task.Run

class RaceConditionExample
{
    static List<int> numbers = new List<int> { 0 }; // Liste avec un seul élément

    static void Main(string[] args)
    {
        Console.WriteLine("Valeur initiale : " + numbers[0]); // 0

        // On lance deux threads, chacun incrémente numbers[0]
        Task task1 = Task.Run(() => IncrementNumbers(500_000));
        Task task2 = Task.Run(() => IncrementNumbers(500_000));

        Task.WaitAll(task1, task2); // On attend que les deux threads finissent

        Console.WriteLine("Valeur finale : " + numbers[0]); // On attend 1_000_000, mais...
        // Le résultat sera presque toujours inférieur à 1_000_000 !
    }

    static void IncrementNumbers(int count)
    {
        for (int i = 0; i < count; i++)
        {
            // Cette opération "numbers[0]++" consiste en réalité en 3 étapes :
            // 1. Lire numbers[0]
            // 2. Incrémenter la valeur de 1
            // 3. Écrire la nouvelle valeur dans numbers[0]
            numbers[0]++; 
        }
    }
}

Pourquoi c'est une race ? Si le thread A lit numbers[0] (valeur 0), puis le thread B lit aussi numbers[0] (toujours 0) avant qu'A ait écrit 1, les deux threads vont incrémenter 0 en 1 et écrire 1. Un incrément est perdu. L'opération numbers[0]++ n'est pas atomique.

Exemple 2 : InvalidOperationException lors de la modification d'un Dictionary

Un thread itère sur le dictionnaire, un autre le modifie.

using System.Collections.Generic;
using System.Threading; // Pour Thread.Sleep

class DictionaryRaceExample
{
    static Dictionary<int, string> users = new Dictionary<int, string>();

    static void Main(string[] args)
    {
        // Initialisation du dictionnaire
        for (int i = 0; i < 5; i++) users.Add(i, $"User {i}");

        // Thread lecteur
        Thread readerThread = new Thread(() =>
        {
            try
            {
                foreach (var user in users) // Itération sur le dictionnaire
                {
                    Console.WriteLine($"Lecteur : {user.Key} - {user.Value}");
                    Thread.Sleep(10); // Simulation de travail
                }
            }
            catch (InvalidOperationException ex)
            {
                Console.WriteLine($"Lecteur : ERREUR ! {ex.Message}");
            }
        });

        // Thread écrivain
        Thread writerThread = new Thread(() =>
        {
            Thread.Sleep(5); // On laisse le lecteur commencer un peu
            for (int i = 5; i < 10; i++)
            {
                users.Add(i, $"New User {i}"); // On ajoute des éléments
                Console.WriteLine($"Écrivain : A ajouté User {i}");
                Thread.Sleep(15);
            }
        });

        readerThread.Start();
        writerThread.Start();

        readerThread.Join(); // On attend la fin des threads
        writerThread.Join();
        Console.WriteLine("Exemple terminé.");
    }
}

Pourquoi l'erreur arrive-t-elle ? Dictionary<TKey, TValue> (comme List<T>) n'est pas conçu pour des lectures et écritures simultanées par différents threads sans synchronisation. Quand le thread écrivain change la structure interne, le thread lecteur continue le foreach sur des données déjà modifiées, ce qui mène à une InvalidOperationException.

2. Pourquoi des lock simples ne sont pas toujours optimaux ?

L'idée de « tout entourer d'un lock » semble simple, mais a des inconvénients :

// Mauvais exemple : trop de locking
// (Juste pour démonstration, à ne pas faire !)
static object _lock = new object();
static List<int> _sharedList = new List<int>();

void AddItem(int item)
{
    lock (_lock)
    {
        _sharedList.Add(item);
    }
}

int GetItemCount()
{
    lock (_lock)
    {
        return _sharedList.Count;
    }
}
  • Performance (goulot d'étranglement) : lock bloque l'accès à toute la collection. Avec 100 threads, 99 attendent un seul, même si les opérations ne se contredisent pas.
  • Complexité : il faut se souvenir de mettre le lock partout où on utilise la collection. Un endroit oublié — et la race revient.
  • Deadlocks : plusieurs lock sur différents objets peuvent facilement provoquer un deadlock.
  • Itérateurs : foreach ne sauve pas la situation si un autre thread modifie la collection.

C'est pourquoi .NET a introduit des collections spécialisées thread-safe.

Opérations atomiques

Collection thread-safe — garantit un fonctionnement correct lors d'accès simultanés depuis plusieurs threads sans locks externes par l'utilisateur. L'essentiel : les opérations atomiques : l'action est effectuée entièrement ou pas du tout — les autres threads ne voient pas d'"états intermédiaires".

  • Ajout, suppression, lecture — se comportent comme s'ils s'exécutaient un par un.
  • En interne, on utilise des techniques bas-niveau : opérations interlockées (Interlocked), Compare-And-Swap (CAS), des locks fins — au lieu d'un verrou global sur toute la collection.

3. Aperçu de System.Collections.Concurrent

L'espace de noms System.Collections.Concurrent fournit un ensemble de collections conçues depuis le départ pour le multithreading. Leur philosophie : parallélisme maximal et blocages minimaux.

  • Performance : elles montent en charge avec l'augmentation du nombre de cores.
  • Simplicité : pas besoin de mettre manuellement des lock autour de chaque opération.
  • Moins d'erreurs : la plupart des problèmes liés à la synchronisation manuelle disparaissent.
  • Optimisées pour la concurrence : elles fonctionnent efficacement lors d'ajouts/suppressions simultanés.

4. Principales classes

ConcurrentQueue<T> (queue thread-safe)

Principe : FIFO — "first in, first out". Scénarios : producer–consumer, logging, queues de tâches.

using System.Collections.Concurrent;

ConcurrentQueue<string> messageQueue = new ConcurrentQueue<string>();

void Producer() => messageQueue.Enqueue("Message 1");

void Consumer()
{
    if (messageQueue.TryDequeue(out string message))
    {
        Console.WriteLine($"Traité : {message}");
    }
    else
    {
        Console.WriteLine("La file est vide.");
    }
}

ConcurrentStack<T> (stack thread-safe)

Principe : LIFO — "last in, first out". Scénarios : historique d'actions, parcours DFS, pools d'objets.

using System.Collections.Concurrent;

ConcurrentStack<int> historyStack = new ConcurrentStack<int>();

void PushAction(int value) => historyStack.Push(value);

void PopAction()
{
    if (historyStack.TryPop(out int action))
    {
        Console.WriteLine($"Action annulée : {action}");
    }
    else
    {
        Console.WriteLine("La pile est vide.");
    }
}

ConcurrentBag<T> (« sac » thread-safe)

Collection non ordonnée, l'ordre n'est pas garanti. Optimisée pour le scénario "un thread prend souvent ce qu'il a lui-même mis". Idéale pour les pools.

using System.Collections.Concurrent;

ConcurrentBag<System.Guid> objectPool = new ConcurrentBag<System.Guid>();

void AddObject() => objectPool.Add(System.Guid.NewGuid());

void TakeObject()
{
    if (objectPool.TryTake(out System.Guid obj))
    {
        Console.WriteLine($"Objet pris : {obj}");
    }
    else
    {
        Console.WriteLine("Le pool est vide.");
    }
}

ConcurrentDictionary<TKey, TValue> (dictionnaire thread-safe)

Supporte des opérations atomiques d'ajout, de mise à jour et de lecture par clé. Idéal pour caches, sessions, compteurs.

using System.Collections.Concurrent;

ConcurrentDictionary<string, int> userScores = new ConcurrentDictionary<string, int>();

void UpdateScore(string user, int score)
{
    // Ajoute atomiquement si absent, ou met à jour si présent
    userScores.AddOrUpdate(user, score, (key, existingVal) => existingVal + score);
    Console.WriteLine($"Score de {user} : {userScores[user]}");
}

void GetScore(string user)
{
    if (userScores.TryGetValue(user, out int score))
    {
        Console.WriteLine($"Score actuel de {user} : {score}");
    }
    else
    {
        Console.WriteLine($"Utilisateur {user} non trouvé.");
    }
}

5. Quand utiliser ces collections plutôt que les normales ?

  • Application multithread : si l'application est mono-thread, les collections normales sont plus rapides (pas de surcharge).
  • Une collection partagée entre plusieurs threads : c'est un signe clé d'utiliser System.Collections.Concurrent.
  • Besoin de haute performance et scalabilité : ces collections sont conçues pour minimiser les attentes.
  • Souhait de simplifier le code : pas besoin de mettre des lock manuels autour de chaque opération.
  • Besoin d'opérations atomiques : ajout/suppression/lecture ne laissent pas la collection dans un état inconsistent.

N'utilisez pas les collections Concurrent quand :

  • L'application est strictement mono-thread.
  • Vous avez besoin d'une "transactionnalité" de plusieurs opérations liées (il faudra peut-être une synchronisation externe ou d'autres mécanismes).
  • L'ordre strict d'extraction est important là où il n'est pas garanti (par exemple, dans ConcurrentBag<T>).
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION