Maîtrise du Multithreading en C# : Instanciation et Exécution des Threads

Instanciation et Démarrage des Threads

Pour créer un thread en C#, on utilise le constructeur de la classe Thread en lui passant un délégué ThreadStart, qui indique le point d'entrée de l'exécution. Voici la définition de ce délégué :

[ComVisible(true)]
public delegate void ThreadStart();

Une fois le thread instancié, on appelle sa méthode Start() pour lancer son exécution. Le thread restera actif jusqu'à ce que la méthode cible se termine.

Voici un exemple utilisant la syntaxe explicite pour instancier le délégué :

class ThreadDemo
{
    static void Main()
    {
        Thread t = new Thread(new ThreadStart(ExecuteTask));
        t.Start(); 
        ExecuteTask();
        Console.ReadLine();
    }
    
    static void ExecuteTask()
    {
        Console.WriteLine("Tâche exécutée !");
    }
}

Dans ce scénario, le thread t et le thread principal exécutent ExecuteTask de manière concurrente, affichant deux messages à des temps très proches.

Le compilateur C# permet d'inférer le délégué simplement en passant le nom du groupe de méthodes :

class Program
{
    static void Main(string[] args)
    {
        Thread t = new Thread(RunProcess);
        t.Start();
        RunProcess();
        Console.ReadLine();
    } 

    static void RunProcess()
    {
        Console.WriteLine("Processus en cours");
    }
}

Une apporche encore plus concise consiste à utiliser une expression lambda ou une méthode anonyme :

static void Main(string[] args)
{
    Thread t = new Thread(() => Console.WriteLine("Démarrage via Lambda"));
    t.Start();
    Console.ReadLine();
}

Transmission de Données aux Threads

Utilisation des Expressions Lambda

La méthode la plus élégante pour passer des paramètres à un thread est d'utiliser une expression lambda qui encapsule l'appel à la méthode cible :

static void Main(string[] args)
{
    Thread t = new Thread(() => DisplayMessage("Bonjour"));
    t.Start();
    Console.ReadLine();
}

static void DisplayMessage(string content)
{
    Console.WriteLine(content);
}

Passage de Paramètres Multiples

Cette technique permet de transmettre un nombre illimité de paramètres. Vous pouvez également encapsuler plusieurs instructions dans le corps de la lambda :

new Thread(() =>
{
    Console.WriteLine("Étape 1");
    Console.WriteLine("Étape 2");
}).Start();

Le même résultat peut être obtenu avec une méthode anonyme (syntaxe C# 2.0) :

new Thread(delegate()
{
    Console.WriteLine("Étape 1");
    Console.WriteLine("Étape 2");
}).Start();

Utilisation de ParameterizedThreadStart

Une alternative consiste à passer un objet directement via la méthode Start :

static void Main(string[] args)
{
    Thread t = new Thread(ShowData);
    t.Start("Données");
    Console.ReadLine();
}

static void ShowData(object payload)
{
    string data = (string)payload; // Le cast est obligatoire
    Console.WriteLine(data);
}

Cela fonctionne car le constructeur de Thread est surchargé pour accepter ParameterizedThreadStart :

[ComVisible(false)]
public delegate void ParameterizedThreadStart(object obj);

Cependant, cette approche est limitée à un seul paramètre de type object, nécessitant systématiquement un cast, ce qui la rend moins sûre et moins pratique que les lambdas.

Piège des Variables Capturées (Fermetures)

Bien que les expressions lambda soient très utiles, il faut être vigilant lors de la capture de variables, car elles sont partagées. Observez cette boucle :

for(int counter = 0; counter < 5; counter++)
{
    new Thread(() => Console.Write(counter)).Start();
}

La sortie est imprévisible. Le problème réside dans le fait que la variable counter pointe vers la même adresse mémoire pour toutes les itérations. Lorsque les threads s'exécutent, la valeur de counter a probablement déjà changé.

La solution consiste à déclarer une variable locale à l'intérieur de la boucle :

for (int counter = 0; counter < 5; counter++)
{
    int localCounter = counter;
    new Thread(() => Console.Write(localCounter)).Start();
}

Ainsi, localCounter est alloué sur une nouvelle pile à chaque itération, isolant la valeur capturée par chaque thread.

Ce phénomène s'applique aussi aux variables externes modifiées après la création du thread :

string status = "Actif";
Thread threadA = new Thread(() => Console.WriteLine(status));

status = "Inactif";
Thread threadB = new Thread(() => Console.WriteLine(status));

threadA.Start();
threadB.Start();

Ici, "Inactif" sera probablement affiché deux fois, car les deux lambdas capturent la même référence vers status.

Attribution de Noms aux Threads

La propriété Name d'un thread est inestimable pour le débogage, notamment dans la fenêtre "Threads" de Visual Studio. Elle ne peut être définie qu'une seule fois ; toute tentative de modification ultérieure lèvera une exception.

La propriété statique Thread.CurrentThread permet d'obtenir une référence vers le thread en cours d'exécution.

static void Main(string[] args)
{
    Thread.CurrentThread.Name = "Fil Principal";
    Thread t = new Thread(WorkerAction);
    t.Name = "Fil Secondaire";
    t.Start();
    WorkerAction();
    Console.ReadLine();
}

static void WorkerAction()
{
    Console.WriteLine("Action sur : {0}", Thread.CurrentThread.Name);
}

Threads de Premier Plan et d'Arrière-Plan

Par défaut, les threads créés manuellement sont des threads de premier plan (foreground). L'application restera en vie tant qu'au moins un thread de premier plan est actif. Les threads d'arrière-plan (background), en revanche, n'empêchent pas la terminaison du processus : dès que tous les threads de premier plan se terminent, les threads d'arrière-plan sont abruptement arrêtés.

Le statut avant-plan/arrière-plan est indépendant de la priorité du thread. Il se contrôle via la propriété IsBackground.

static void Main(string[] args)
{
    Thread t = new Thread(() => Console.ReadLine());
    if (args.Length > 0)
    {
        // Le thread devient un thread d'arrière-plan.
        // Si le thread principal se termine, l'application s'arrêtera 
        // même si ce thread attend une saisie.
        t.IsBackground = true;
    }
    t.Start();
}

Attention : lorsqu'un thread d'arrière-plan est terminé de force par le CLR, les blocs finally (et par extension les blocs using) ne sont pas exécutés. Si votre thread gère des ressources critiques, il est préférable d'attendre sa fin proprement en utilisant la méthode Join() avec un timeout, ou d'utiliser des mécanismes de synchronisation si vous travaillez avec le pool de threads.

Gestion des Priorités d'Exécution

La priorité d'un thread influence la quantité de temps CPU que le système d'exploitation lui alloue par rapport aux autres threads :

public enum ThreadPriority
{
    Lowest = 0,
    BelowNormal = 1,
    Normal = 2,
    AboveNormal = 3,
    Highest = 4,
}

Augmenter la priorité d'un thread doit être fait avec extrême prudence, car cela peut causer la famine (starvation) des autres threads.

De plus, la priorité d'un thread est plafonnée par la priorité de son processus hôte. Pour des tâches nécessitant une haute réactivité, il faut également élever la priorité du processus :

using (Process currentProc = Process.GetCurrentProcess())
{
    currentProc.PriorityClass = ProcessPriorityClass.High;
}

Attention à ProcessPriorityClass.RealTime. Placer un processus en temps réel peut accaparer tout le CPU, rendant le système d'exploitation lui-même (et même le gestionnaire de tâches) incapable de répondre. Pour les applications temps réel critiques avec une interface graphique, il est recommandé de séparer l'UI et le traitement dans des processus distincts communiquant via la mémoire partagée.

Gestion des Exceptions dans un Contexte Multithread

Les exceptions levées dans un thread ne se propagent pas au thread qui l'a créé. Les blocs try/catch englobant le démarrage d'un thread sont donc inutiles pour intercepter ses erreurs internes :

static void Main(string[] args)
{
    try
    {
        new Thread(TriggerError).Start();
    }
    catch(Exception ex)
    {
        // Ce bloc ne sera JAMAIS exécuté
        Console.WriteLine("Erreur capturée"); 
    }
    Console.ReadLine();
}

static void TriggerError()
{
    throw new NullReferenceException();
}

Pour gérer correctement les erreurs, le bloc try/catch doit entourer l'intégralité du code à l'intérieur de la méthode cible du thread :

class Program
{
    static void Main(string[] args)
    {
        new Thread(TriggerError).Start();
        Console.ReadLine();
    }

    static void TriggerError()
    {
        try
        {
            throw new NullReferenceException("Test");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Erreur gérée : {ex.Message}");
        }
    }
}

Une exception non gérée dans un thread provoquera l'arrêt brutal de l'application (crash). Bien que des événements globaux comme AppDomain.CurrentDomain.UnhandledException ou Application.ThreadException (pour WinForms/WPF) puissent notifier de ces erreurs, ils ne permettent généralement pas d'empêcher la fermeture de l'application.

Notez que les abstractions de plus haut niveau comme le Task Parallel Library (TPL), les délégués asynchrones ou le BackgroundWorker gèrent automatiquement la propagation des exceptions vers le thread appelant, simplifiant ainsi grandement le traitement des erreurs.

Étiquettes: C# multithreading System.Threading ThreadStart Lambda

Publié le 8 juillet à 05h02