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
etThreadPool
- 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.
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 queTask.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 queTask
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);
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();
}
}
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();
}
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();
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
}
}
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
}
}
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
}
}
}
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.");
}
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.
Top comments (0)