DEV Community

benjamin duroule
benjamin duroule

Posted on

[FR] Multithreading et Programmation Asynchrone en .NET : Comprendre les bases et éviter les pièges

La programmation concurrente est essentielle pour améliorer la performance et la réactivité des applications modernes. En .NET, on peut exécuter des tâches en parallèle grâce au multithreading et gérer des opérations asynchrones via async/await.

Dans cet article, nous allons explorer :

  • Les différences entre multithreading et async
  • Les classes Thread, Task et ThreadPool
  • Les concepts de sections critiques, deadlocks et mutex
  • Les bonnes pratiques pour éviter les erreurs courantes

Multithreading

Qu'est-ce que le multithreading ? Le multithreading fait référence au fait de pouvoir exécuter plusieurs suites d’instructions en simultané/parallèle, mais aussi d’échanger des données entre les threads. Le multithreading peut s’avérer indispensable, notamment pour gérer de grosses volumétries de données, des applications en temps réel, des mises à jour des cours boursiers ou encore le suivi des transactions en finance de marché.

Synchrone et asynchrone

Souvent, le multithreading est confondu avec le concept de méthode asynchrone. Pourtant, ces deux approches répondent à des problématiques différentes. En C#, une méthode asynchrone exécute ses instructions sans bloquer le thread appelant, en permettant au programme de continuer son exécution pendant qu'une tâche en arrière-plan se termine. Contrairement à une méthode synchrone, qui exécute les instructions une par une dans l'ordre, une méthode asynchrone peut démarrer plusieurs opérations en parallèle et reprendre leur résultat une fois disponibles. Elle retourne généralement une Task ou une Task<T>, qui représente une promesse de résultat futur, permettant d’utiliser await pour suspendre temporairement l'exécution jusqu'à ce que la tâche soit terminée.

Thread et processus

Maintenant que nous avons vu la différence entre le multithreading et les méthodes asynchrones, nous allons nous intéresser à ce qui différencie un thread d'un processus. Un processus est un programme en cours d’exécution, il exécute les instructions du programme et dispose d’un espace d’adressage en mémoire vive (RAM) pour contenir le heap. Un thread, en revanche, est un segment d’un processus. Un processus peut contenir plusieurs threads, qui partagent le même espace mémoire mais disposent de leur propre pile d’exécution.

source: GitHub: Yavuzhan-Baykara

Thread et Task

En C#, il existe deux moyens de faire du multithreading : Thread et Task.

  • Task est une classe de C# qui permet de créer une tâche qui sera exécutée de manière asynchrone. Contrairement à Thread, Task ne crée pas de nouveau thread mais va chercher un thread disponible dans le thread pool. Task permet aussi de vérifier si une tâche est terminée et de retourner une valeur. Task.Delay() ne consomme pas de ressources CPU, il lance simplement une minuterie, tandis que Task.Run() exécute un code séparément. En résumé, Task est une abstraction qui permet de planifier et exécuter du code sans gérer directement les threads sous-jacents.

  • Thread est une classe qui exécute un bloc d’instructions dans un contexte indépendant du thread principal du programme. Il dispose de sa propre zone mémoire (stack), tandis que le heap est partagé entre tous les threads du processus. Thread est plus bas niveau que Task et ne réutilise pas les threads du pool mais en crée de nouveaux, ce qui peut avoir un impact sur les performances.

// on creer un nouvelle obj ThreadStart pour une methode qui ne prend pas d'argument
Thread t = new Thread(new ThreadStart(function));
// ou
Thread t = new Thread(function);
t.Start();

// pour une methode qui prend un argument on vas creer un delegate : public  delegate  void  ParameterizedThreadStart(object obj)
Thread t = new Thread(function);
t.Start(parametre);

Enter fullscreen mode Exit fullscreen mode

On peut également avoir un certain nombre d’informations sur le thread en cours via le code qu’il exécute, mais aussi créer des pools de threads et lancer en arrière-plan :

public static void Main() {
    ThreadPool.QueueUserWorkItem(ShowThreadInformation);
    var th1 = new Thread(ShowThreadInformation);
    th1.Start();
    var th2 = new Thread(ShowThreadInformation);
    th2.IsBackground = true; th2.Start();
    Thread.Sleep(500); ShowThreadInformation(null);
}

private static void ShowThreadInformation(Object state) {
    lock (obj) {
        var th = Thread.CurrentThread;
        Console.WriteLine("Managed thread #{0}: ", th.ManagedThreadId);
        Console.WriteLine(" Background thread: {0}", th.IsBackground);
        Console.WriteLine(" Thread pool thread: {0}", th.IsThreadPoolThread);
        Console.WriteLine(" Priority: {0}", th.Priority); Console.WriteLine(" Culture: {0}", th.CurrentCulture.Name);
        Console.WriteLine(" UI culture: {0}", th.CurrentUICulture.Name);
        Console.WriteLine();
    }
}

Enter fullscreen mode Exit fullscreen mode

Section critique

Quand on fait du multithreading, il peut arriver qu'on ne veuille pas que plusieurs threads accèdent à une ressource en même temps pour des raisons que nous allons détailler dans la suite de cet article. C'est ce qu'on appelle une section critique, c'est-à-dire une portion de code où il ne peut y avoir plus d’un thread simultanément. On peut s’assurer de cela en utilisant un mutex, qui est une classe permettant de synchroniser l’accès à une ressource protégée en interdisant l’accès aux autres threads.

private static int activeConnections = 0; // Ressource partagée
private static object lockObject = new object(); // Verrou pour la section critique
Thread[] threads = new Thread[5];

public static void Connect()
{
    lock (lockObject) // Section critique
    {
        activeConnections++; // Mise à jour protégée
        Console.WriteLine($"Nouvelle connexion. Connexions actives : {activeConnections}");
    }

    // Simule une connexion active
    Thread.Sleep(1000);

    lock (lockObject) // Section critique
    {
        activeConnections--; // Mise à jour protégée
        Console.WriteLine($"Déconnexion. Connexions actives : {activeConnections}");
    }
}

for (int i = 0; i < threads.Length; i++) {
    threads[i] = new Thread(Connect);
    threads[i].Start();
}

foreach (Thread t in threads) {
    t.Join();
}    
Enter fullscreen mode Exit fullscreen mode

Dans cet exemple qui simule une connexion à un serveur, on peut voir que si plusieurs threads mettent à jour une variable partagée sans synchronisation, cela peut entraîner des erreurs de comptage (race condition).

Deadlock / liveLock

Cependant, les sections critiques sont à manier avec soin car elles peuvent engendrer des problèmes comme le deadlock ou interblocage, qui est la situation dans laquelle deux threads s’attendent mutuellement. Prenons un exemple pour mieux comprendre comment cela peut se produire.

static object lock1 = new object();
static object lock2 = new object();

static void Thread1()
{
    lock (lock1) // Verrouillage de lock1
    {
        Thread.Sleep(100); // Simule un délai
        lock (lock2) // Attente de lock2 (bloqué par Thread2)
        {
            Console.WriteLine("Thread 1 a les deux verrous");
        }
    }
}

static void Thread2()
{
    lock (lock2) // Verrouillage de lock2
    {
        Thread.Sleep(100); // Simule un délai
        lock (lock1) // Attente de lock1 (bloqué par Thread1)
        {
            Console.WriteLine("Thread 2 a les deux verrous");
        }
    }
}

Thread t1 = new Thread(Thread1);
Thread t2 = new Thread(Thread2);

t1.Start();
t2.Start();
Enter fullscreen mode Exit fullscreen mode

On peut voir que deux threads essaient d’accéder à deux verrous (lock1 et lock2) dans un ordre inversé, provoquant une attente infinie. Pour éviter ce problème, il faut toujours verrouiller les ressources dans le même ordre ou utiliser Monitor.TryEnter avec un timeout. Mais cette pratique est également à risque, car libérer le thread trop tôt peut provoquer un LiveLock, c'est-à-dire que deux threads tentent d'éviter un conflit en relâchant immédiatement un verrou s'il est indisponible. Mais comme ils le font simultanément, ils ne progressent jamais.

static object lock1 = new object();
static object lock2 = new object();

static void Thread1()
{
    while (true)
    {
        bool gotLock1 = false;
        bool gotLock2 = false;

        try
        {
            gotLock1 = Monitor.TryEnter(lock1);
            gotLock2 = Monitor.TryEnter(lock2);

            if (gotLock1 && gotLock2)
            {
                Console.WriteLine("Thread 1 : Exécute la section critique.");
                break; // Sortie si les deux verrous sont acquis
            }
        }
        finally
        {
            if (gotLock1) Monitor.Exit(lock1);
            if (gotLock2) Monitor.Exit(lock2);
        }

        Console.WriteLine("Thread 1 : Évite le blocage, retente...");
        Thread.Sleep(50); // Sans délai aléatoire, LiveLock possible
    }
}

static void Thread2()
{
    while (true)
    {
        bool gotLock1 = false;
        bool gotLock2 = false;

        try
        {
            gotLock2 = Monitor.TryEnter(lock2);
            gotLock1 = Monitor.TryEnter(lock1);

            if (gotLock1 && gotLock2)
            {
                Console.WriteLine("Thread 2 : Exécute la section critique.");
                break;
            }
        }
        finally
        {
            if (gotLock1) Monitor.Exit(lock1);
            if (gotLock2) Monitor.Exit(lock2);
        }

        Console.WriteLine("Thread 2 : Évite le blocage, retente...");
        Thread.Sleep(50); // Sans délai aléatoire, LiveLock possible
    }
}
Enter fullscreen mode Exit fullscreen mode

Objets mutables / immuables

Un objet mutable est un objet qui peut être modifié après sa création, contrairement à un objet immuable dont l’état ne peut pas changer. Pour modifier une valeur dans un objet immuable, il faut créer une nouvelle instance avec la nouvelle valeur au lieu d’altérer l’objet existant. Cela peut également éviter les problèmes de partage de ressources en multithreading.

Appel bloquant

En dehors des problèmes de partage des ressources, un thread peut se retrouver bloqué via ce qu'on appelle un appel bloquant. Un appel bloquant survient lorsqu'un thread exécute une opération qui l’empêche de continuer tant que l’opération n’est pas terminée. Cela peut être un appel système, un accès réseau, une lecture de fichier ou un verrou. Par exemple, la fonction read() en C bloque l'exécution du programme tant que les données ne sont pas disponibles.

Exemples courants d’appels bloquants :

  • Lecture de fichier : read(), File.ReadAllBytes() en C#
  • Accès réseau : Socket.Receive(), HttpClient.Send() (sans async)
  • Verrous : lock, Monitor.Enter(), Mutex.WaitOne()
  • Entrée utilisateur : Console.ReadLine()

Mutex

Nous avons vu jusqu'à présent qu'on peut bloquer un bloc d’instructions via lock, mais il existe d'autres méthodes comme Mutex. Mutex est une classe de C# .NET bloquant l’accès à une section protégée, permettant à un seul thread à la fois d’y accéder, bloquant les autres à l’entrée du Mutex. Un Mutex revient à utiliser lock. On définit le début de la section protégée en mettant "MutexInstance".WaitOne(), et la fin en mettant "MutexInstance".ReleaseMutex().

public class testMutex {
    private  static Mutex mut = new Mutex();

    public void CreatThred() {
        for (int i = 0; i < number_of_thread; i++) {
            Thread newThread = new Thread(new ThreadStart(methodName));
            newThread.Start();
        }
    }

    public void methodName() {
        // before protected section
        mut.WaitOne();
        // protected section
        mut.ReleaseMutex();
        // end of protected section
    }
}

Enter fullscreen mode Exit fullscreen mode

On peu egalement utiliser WaitOne() pour set un timeout qui si la section protege prend plus de temps que celui indiquer (en ms) alors renvoie false, et le thread qui attand la section n’aquiere pas le mutex et donc n’appelle pas ReleaseMutex() qui est appeler uniquement par le tread qui est entrer.

public class testMutex {
    private  static Mutex mut = new Mutex();

    public void CreatThred() {
        for (int i = 0; i < number_of_thread; i++) {
            Thread newThread = new Thread(new ThreadStart(methodName));
            newThread.Start();
        }
    }

    public void methodName() {
        // before protected section
        if (mut.WaitOne(1000)) {
            // protected section
            mut.ReleaseMutex();
            // end of protected section
        } else {
            // timeout
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Semaphore

Jusqu'à maintenant, nous avons vu comment bloquer l'accès à une ressource en permettant à un seul thread à la fois d'y accéder, mais il est possible de limiter le nombre de threads pouvant y accéder simultanément. Pour cela, on utilise un sémaphore, qui agit comme un compteur contrôlant le nombre maximal de threads pouvant entrer dans une section critique. Lorsqu'un thread veut accéder à la ressource, il doit attendre qu'un emplacement soit disponible en appelant la méthode Wait() ou WaitAsync().

Une fois sa tâche terminée, il libère l'accès avec Release(), permettant à un autre thread d'entrer. Cette approche est utile pour limiter les accès concurrents à une base de données, réguler les appels à une API ou gérer un pool de connexions. Contrairement à un verrou classique qui exclut tous les autres threads, un sémaphore autorise un certain nombre de threads définis lors de son initialisation, équilibrant ainsi contrôle et parallélisme.

static SemaphoreSlim semaphore = new SemaphoreSlim(3); // Max 3 requêtes en parallèle
static HttpClient client = new HttpClient();

static async Task FetchData(int id)
{
    await semaphore.WaitAsync(); // Attend si le quota est atteint
    try
    {
        Console.WriteLine($"Requête {id} envoyée...");
        var response = await client.GetStringAsync("https://api.example.com/data");
        Console.WriteLine($"Requête {id} terminée.");
    }
    finally
    {
        semaphore.Release(); // Libère une place pour un autre thread
    }
}

static async Task Main()
{
    Task[] tasks = new Task[10];
    for (int i = 0; i < 10; i++)
    {
        tasks[i] = FetchData(i);
    }

    await Task.WhenAll(tasks); // Attendre toutes les tâches
    Console.WriteLine("Toutes les requêtes sont terminées.");
}
Enter fullscreen mode Exit fullscreen mode

dans cette exemple on peut voir que nous devons faire 10 appels HTTP, mais pour éviter de surcharger l’API, on limite à 3 appels simultanés.

Conclusion et Bonnes Pratiques

Le multithreading et la programmation asynchrone sont des outils puissants pour améliorer la réactivité et la performance des applications .NET, mais ils doivent être utilisés avec précaution. Une mauvaise gestion des accès concurrents peut entraîner des erreurs comme les race conditions, les deadlocks ou encore une consommation excessive de ressources. Il est donc essentiel de comprendre quand utiliser Task, Thread ou encore SemaphoreSlim pour contrôler le nombre de threads simultanés. En adoptant de bonnes pratiques comme l’utilisation de verrous (lock, Mutex, Semaphore), le respect des règles d’immuabilité ou encore la gestion des appels bloquants, on peut tirer parti du multithreading sans en subir les pièges.

Image of Datadog

Master Mobile Monitoring for iOS Apps

Monitor your app’s health with real-time insights into crash-free rates, start times, and more. Optimize performance and prevent user churn by addressing critical issues like app hangs, and ANRs. Learn how to keep your iOS app running smoothly across all devices by downloading this eBook.

Get The eBook

Top comments (0)

Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

Rather than just generating snippets, our agents understand your entire project context, can make decisions, use tools, and carry out tasks autonomously.

Read full post