CodeGym /Cours /C# SELF /Patrons de sécurité des threads

Patrons de sécurité des threads

C# SELF
Niveau 58, Leçon 4
Disponible

1. Patron «Verrouillage sans verrouillage» (Lock-Free / Wait-Free)

Vous savez déjà que les collections Concurrent sont thread-safe. Mais comment font-elles sans que vous n’entouriez votre code de blocs lock ? La réponse tient dans des algorithmes avancés — lock-free (sans verrous) et wait-free (sans attente).

Brève explication des concepts d’algorithmes lock-free et wait-free

Lock-Free (sans verrous)

Idée : Garantie qu’au moins un thread pourra toujours faire un pas en avant, même si d’autres subissent des retards ou des interruptions.

Différence avec lock : Avec lock les threads concurrents attendent que le verrou soit libéré. Dans les algorithmes lock-free les threads ne se bloquent pas mutuellement au sens classique : en cas de conflit ils retentent simplement l’opération.

Exemple : Une file à la caisse : avec lock vous restez en file. En lock-free vous arrivez, voyez que c’est occupé, et réessayez un peu plus tard — sans «faire la queue» collective.

Wait-Free (sans attente)

Idée : Garantie plus forte : chaque thread progressera en un nombre fini de ses propres pas, indépendamment des autres. Personne ne tourne indéfiniment.

Différence : En lock-free un thread peut relancer indéfiniment une opération à cause des conflits ; en wait-free cela n’arrive pas.

Pratique : Implémenter wait-free est beaucoup plus compliqué, donc on trouve plus souvent des approches lock-free ou hybrides.

2. Comment les collections Concurrent atteignent la sécurité des threads

La brique de base des algorithmes lock-free ce sont les opérations atomiques au niveau processeur. Dans .NET on y accède via la classe System.Threading.Interlocked.

Opérations Interlocked

Opérations atomiques rapides sur les primitives (int, long) : par exemple Interlocked.Increment, Interlocked.Decrement, Interlocked.CompareExchange.

Exemples : Interlocked.Increment(ref value) — incrément atomique ; Interlocked.CompareExchange(ref location, value, comparand) — compare et remplace atomiquement.

CAS (Compare-And-Swap) — Comparer-et-Échanger

L’opération CAS est implémentée en .NET comme Interlocked.CompareExchange. Logique générale :

  1. Lire la valeur courante de la variable.
  2. Calculer la nouvelle valeur basée sur la valeur lue.
  3. Tenter d’écrire cette nouvelle valeur seulement si la variable est toujours égale à la valeur d’origine. Sinon — recommencer.

Exemple : compteur simple lock-free avec Interlocked

using System.Threading;
using System.Threading.Tasks;

class CounterExample
{
    static int regularCounter = 0;
    static int interlockedCounter = 0;

    static void IncrementRegular(int iterations)
    {
        for (int i = 0; i < iterations; i++)
        {
            regularCounter++; // Pas thread-safe !
        }
    }

    static void IncrementInterlocked(int iterations)
    {
        for (int i = 0; i < iterations; i++)
        {
            Interlocked.Increment(ref interlockedCounter); // Atomique !
        }
    }
}

//Dans Main :
Task t1 = Task.Run(() => IncrementRegular(500_000));
Task t2 = Task.Run(() => IncrementRegular(500_000));
Task.WaitAll(t1, t2);
Console.WriteLine($"Compteur ordinaire : {regularCounter}"); // sera presque toujours < 1_000_000

regularCounter = 0; // Réinitialise pour le test suivant
t1 = Task.Run(() => IncrementInterlocked(500_000));
t2 = Task.Run(() => IncrementInterlocked(500_000));
Task.WaitAll(t1, t2);
Console.WriteLine($"Compteur Interlocked : {interlockedCounter}"); // Sera exactement 1_000_000

La méthode Interlocked.Increment garantit l’atomicité de l’incrément : les données ne sont pas perdues même avec des accès concurrents de plusieurs threads.

3. Pourquoi c’est important pour la scalabilité et les performances

Réduit les surcoûts : les verrous classiques (lock) peuvent déclencher des changements de contexte et des attentes côté noyau OS. Les approches lock-free minimisent ces coûts.

Pas de deadlocks : les threads ne se bloquent pas mutuellement — rien qui forme un deadlock.

Meilleure scalabilité : sur des systèmes multi-cœurs les threads se gênent moins, il n’y a pas de «goulot d’étranglement» unique dû à un verrou commun.

Réactivité accrue : personne ne «bloque» pendant de longues attentes.

Vue très rapide de l’implémentation interne de ConcurrentQueue<T>

Simplifié : la queue est composée de segments liés. Lors d’un Enqueue le thread avance atomiquement la «queue tail» via CompareExchange ; lors d’un TryDequeue on avance atomiquement la «head» seulement si elle n’a pas changé. Les vraies implémentations sont plus complexes (résolution du problème ABA, interactions avec le garbage collector), mais la clé c’est l’utilisation d’opérations atomiques au lieu de verrous «lourds».

4. Performances des collections Concurrent

Comparaison des performances avec lock sur des collections classiques

En faible concurrence la différence est petite, et parfois un simple lock sur une collection classique peut être comparable. Mais en forte concurrence les collections Concurrent sont généralement beaucoup plus rapides grâce à l’absence d’attente sur un verrou global.

Exemple : comparaison (idée, sans exécution)

using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Diagnostics; // Pour Stopwatch
using System.Threading.Tasks;

class PerformanceTest
{
    static List<int> regularList = new List<int>();
    static ConcurrentQueue<int> concurrentQueue = new ConcurrentQueue<int>();
    static object lockObject = new object();

    const int Iterations = 1_000_000;
    const int NumTasks = 4; // Nombre de tâches parallèles

    public static void RunTests()
    {
        Console.WriteLine("Test de performance (ajout) :");

        // Test avec List classique et lock
        regularList.Clear();
        Stopwatch sw = Stopwatch.StartNew();
        Parallel.For(0, NumTasks, (i) =>
        {
            for (int j = 0; j < Iterations / NumTasks; j++)
            {
                lock (lockObject)
                {
                    regularList.Add(j);
                }
            }
        });
        sw.Stop();
        Console.WriteLine($"List avec lock : {sw.ElapsedMilliseconds} ms. Count : {regularList.Count}");

        // Test avec ConcurrentQueue
        concurrentQueue.Clear();
        sw = Stopwatch.StartNew();
        Parallel.For(0, NumTasks, (i) =>
        {
            for (int j = 0; j < Iterations / NumTasks; j++)
            {
                concurrentQueue.Enqueue(j);
            }
        });
        sw.Stop();
        Console.WriteLine($"ConcurrentQueue : {sw.ElapsedMilliseconds} ms. Count : {concurrentQueue.Count}");
        // Attendez-vous à ce que ConcurrentQueue soit nettement plus rapide quand NumTasks > 1
    }
}

Conclusion : Si vous voyez un lock autour de vos collections, c’est souvent un signal pour passer aux équivalents Concurrent.

6. Nuances utiles

Impact de la contention sur les performances

La contention — quand beaucoup de threads accèdent simultanément à une même ressource. Plus la concurrence est forte, plus il y aura d’attentes et pire seront les performances.

Les collections Concurrent sont conçues pour réduire la contention : par exemple ConcurrentBag<T> utilise des stockages locaux par thread, et ConcurrentDictionary<TKey, TValue> utilise des verrous segmentés (striped locking).

La clé des performances — réduire la contention : quand c’est possible, séparez les données entre threads ou utilisez plusieurs collections.

Choisir la bonne collection pour un scénario concret

Collection Ordre Quand utiliser Quand ne pas utiliser
ConcurrentQueue<T>
FIFO (First-In, First-Out) Files de tâches, logging, traitement asynchrone d’événements, Producer-Consumer. Si l’ordre n’est pas important, si vous avez besoin de LIFO ou d’une limitation de taille avec blocage.
ConcurrentStack<T>
LIFO (Last-In, First-Out) Historique d’opérations (Undo/Redo), parcours de graphe (DFS), pools d’objets où le dernier ajouté a la priorité. Si FIFO est critique ou si la stabilité de l’ordre est importante.
ConcurrentBag<T>
Non garanti Pools d’objets, quand le producteur et le consommateur sont souvent le même thread ; scénarios TPL privilégiant la localité. Si l’ordre des éléments est important.
ConcurrentDictionary<TKey, TValue>
Non Cache, sessions utilisateurs, comptage de statistiques, agrégation parallèle. Si vous n’avez pas besoin d’un dictionnaire.
BlockingCollection<T> (au-dessus de ConcurrentQueue) FIFO (ou autre collection sous-jacente) Producer-Consumer avec opérations bloquantes et limitation de taille, arrêt propre. Si vous n’avez pas besoin d’opérations bloquantes ou de limitation de taille.

7. Conseils d’optimisation

Évitez les appels fréquents à ToArray() dans les zones «chaudes»

ToArray() crée une nouvelle copie complète de la collection — coûteux en mémoire et en temps. Utilisez-le seulement quand vous avez besoin d’un «instantané» et le moins souvent possible. Pour la taille il y a Count (rappelez-vous que c’est un snapshot au moment de l’appel).

Attention à l’itération sur les collections Concurrent

Les itérateurs ne garantissent pas la stabilité lors de modifications parallèles : vous pouvez manquer des éléments ou obtenir une vue incohérente. Pour une vue stable, faites d’abord un snapshot via ToArray().

// Mauvais : peut manquer des éléments ou voir des changements pendant l’itération
foreach (var item in myConcurrentQueue) { /* ... */ }

// Bien : itération sur un snapshot fixe
var snapshot = myConcurrentQueue.ToArray();
foreach (var item in snapshot) { /* ... */ }

Minimisez le «trafic» via la collection

Groupez les tâches/données : moins d’appels Add/Take — moins de contention potentielle. Par exemple, au lieu de 1000 messages séparés — envoyez un «paquet» de 1000.

Surveillez les sources de concurrence

Si vous observez une dégradation, mesurez où la contention est la plus forte. Peut-être que le design peut être modifié pour que les threads travaillent sur des données locales ou sur des collections différentes.

2
Mission
C# SELF, niveau 58, leçon 4
Bloqué
Compteur thread-safe avec Interlocked
Compteur thread-safe avec Interlocked
1
Étude/Quiz
Collections concurrentes, niveau 58, leçon 4
Indisponible
Collections concurrentes
Collections thread-safe
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION