Bonnes pratiques de multithreading en pratique — édition .NET : ce qu'il faut décider avant d'ajouter des threads
· Go Komura · Windows, Multithreading, C#, .NET, Application métier, Investigation de bugs, Conception
« Le traitement était lent, alors on a lancé des threads pour paralléliser, et maintenant le résultat de l’agrégation dérape de temps en temps. » « On a ajouté un traitement en arrière-plan, et l’application s’est mise à geler une fois par mois. » « On nous dit que ça ne se reproduit pas en exécution de débogage, mais chez le client, ça arrive bel et bien. » — ce qui rend la programmation multithread effrayante, c’est qu’elle semble fonctionner correctement juste après avoir été écrite. Les bugs de concurrence dépendent du timing : ils passent entre les mailles des tests et ne montrent le bout de leur nez qu’en production.
D’un autre côté, maintenant que le multicœur est devenu la norme, même dans les applications métier il existe indéniablement des cas où le multithreading est incontournable, motivé par des exigences comme « faire tourner un traitement lourd sans geler l’interface » ou « traiter plusieurs équipements ou fichiers en parallèle ». L’important est de fixer les principes de conception avant d’ajouter des threads. Les bugs de multithreading ne se corrigent pas en débogage : c’est la conception qui doit en supprimer la possibilité même.
Cet article est l’édition .NET de notre série pratique sur le multithreading. Destiné aux développeurs qui développent des applications métier sous Windows et pour qui le multithreading est devenu nécessaire, il organise les principes de conception valables indépendamment du langage ou de l’OS, ainsi que l’outillage concret en C#/.NET, à partir de sources primaires datant d’août 2026. Les principes eux-mêmes ne changent ni sous Linux ni en C++. Pour du code natif, reportez-vous à l’« édition C++ » et à l’« édition C », qui déclinent les mêmes principes dans les outils de chaque langage ; pour Java, voir l’« édition Java ».
1. Conclusion, d’abord
- Ne pas créer ses propres threads est la toute première bonne pratique. Appuyez-vous sur des API de plus haut niveau — Task, le pool de threads, la classe
Parallel— plutôt que surnew Thread, et laissez le runtime gérer le nombre de threads.12 - La première chose à éliminer lors de la parallélisation est « l’état mutable partagé ». Les endroits où plusieurs threads écrivent dans la même variable sont la source de la concurrence problématique ; avant même de protéger avec un verrou, réduisez le partage lui-même par la division, l’immuabilité ou la transmission des données.3
- Disciplinez vos verrous. Décidez une correspondance biunivoque entre « quelle donnée » et « quel verrou » la protège, et utilisez pour le verrouillage un objet dédié, invisible de l’extérieur.
lock(this)etlock(typeof(X))sont interdits. À partir de .NET 9, utilisez le type dédiéSystem.Threading.Lock.4 - Faites transiter les données entre threads par une file d’attente. Une architecture producteur/consommateur utilisant
System.Threading.Channelsou les collections concurrentes est plus simple à concevoir qu’un maillage de verrous, et rend les frontières plus claires.56 - Concevez la façon de s’arrêter dès le départ. L’annulation coopérative via
CancellationTokenest la seule bonne réponse pour l’arrêt ;Thread.Abortlève une exception d’exécution sur .NET (la famille Core).78 - L’interface utilisateur appartient exclusivement au thread d’interface utilisateur. Ni les contrôles WinForms ni les éléments WPF ne doivent être touchés depuis un thread autre que celui qui les a créés. Depuis un autre thread, passez par
Control.Invoke/Dispatcherpour en faire la demande.910 - « Paralléliser » ne signifie pas toujours « plus rapide ». Une boucle dont le travail par itération est petit peut, à l’inverse, ralentir à cause du surcoût de la parallélisation. Mesurez systématiquement avant d’adopter cette approche.3
2. Pourquoi le multithreading est-il difficile — conditions de concurrence et interblocages
Les problèmes qu’introduit le multithreading se ramènent, au fond, à deux catégories.4
Une condition de concurrence (race condition) est un bug où le résultat change selon l’ordre dans lequel plusieurs threads atteignent une portion de code donnée. L’exemple classique est l’incrémentation d’un compteur partagé : la ligne count++ se décompose en réalité en trois étapes — « lecture → addition → réécriture ». Si deux threads exécutent simultanément ces trois étapes, l’addition de l’un est écrasée par la réécriture de l’autre, et cette addition est perdue. Le résultat change à chaque exécution, et on ne peut pas prédire lequel sortira.4
sequenceDiagram
participant A as Thread A
participant M as Variable partagée count
participant B as Thread B
Note over M: count = 10
A->>M: Lecture (10)
B->>M: Lecture (10)
A->>A: Addition locale (11)
B->>B: Addition locale (11)
A->>M: Réécriture (11)
B->>M: Réécriture (11)
Note over M: Deux additions effectuées mais count = 11<br/>L'addition du thread A a été perdue
Figure 1 : une condition de concurrence typique où une addition sur un compteur partagé est perdue. Si un autre thread s’intercale entre les trois étapes de count++, celui qui réécrit en dernier écrase l’autre.
Un interblocage (deadlock) est un état où deux threads attendent chacun le verrou détenu par l’autre, si bien qu’aucun des deux ne peut avancer. Le thread A détient le verrou 1 et attend le verrou 2, le thread B détient le verrou 2 et attend le verrou 1 — cela suffit à figer les deux threads pour toujours.4
flowchart LR
A["Thread A<br/>détient le verrou 1"] -->|"attend la libération du verrou 2"| B["Thread B<br/>détient le verrou 2"]
B -->|"attend la libération du verrou 1"| A
Figure 2 : l’attente circulaire d’un interblocage. Dès que les flèches d’attente forment une boucle, tous les threads pris dans cette boucle s’arrêtent pour toujours.
Ce qui rend les deux problèmes délicats, c’est qu’ils dépendent du timing. Il est banal qu’un entrelacement (une combinaison d’ordres d’exécution) qui ne se produit qu’une fois sur plusieurs dizaines de milliers sur une machine de développement survienne tous les jours chez un client, dont le nombre de cœurs et le timing diffèrent. « Ça ne se reproduit plus avec un débogueur attaché », « ça a disparu en ajoutant des logs » — cela vient du fait que l’observation elle-même change le timing, un comportement typique des bugs de concurrence.
C’est précisément pour cela que tous les principes qui suivent pointent dans une seule direction. « Réduire les endroits qui ont besoin de synchronisation » avant même « bien synchroniser » — voilà l’épine dorsale de la conception multithread.
3. Principe 1 : ne pas créer ses propres threads
3.1. S’appuyer sur Task et le pool de threads
Créer un thread directement avec new Thread(...) est, dans le .NET d’aujourd’hui, un dernier recours exceptionnel. Depuis .NET Framework 4, le moyen recommandé pour du code multithread et parallèle est la TPL (Task Parallel Library), c’est-à-dire l’ensemble d’API centré sur Task. La TPL ajuste dynamiquement le degré de parallélisme en fonction des processeurs disponibles, et prend en charge tous les aspects de bas niveau — division du travail, ordonnancement vers le pool de threads, prise en charge de l’annulation, gestion d’état.1
Le pool de threads est une infrastructure que .NET lui-même utilise abondamment — pour l’exécution des Task, l’achèvement des E/S asynchrones, les callbacks de minuteries — et tant que vous y soumettez de courtes tâches, le développeur n’a pas besoin de gérer le cycle de vie des threads.2
// Un traitement lourd en CPU, en arrière-plan
var result = await Task.Run(() => HeavyCalculation(input));
// Lancer plusieurs traitements indépendants en parallèle et tous les attendre (quand il y en a peu)
// NB : cette forme suppose que ProcessAsync est une méthode asynchrone dominée par de l'E/S.
// WhenAll se contente d'« attendre des Task déjà en cours d'exécution », donc si vous voulez
// paralléliser du calcul CPU, enveloppez chaque traitement dans Task.Run(() => Calc(x))
// pour le poser sur le pool de threads
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));
// Si le nombre d'éléments est élevé, plafonnez le nombre d'exécutions simultanées
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
async (x, ct) => await ProcessAsync(x, ct));
// Deux points essentiels : connecter le token de l'appelant à ParallelOptions
// (l'oublier laisse le ct du corps toujours à None), et transmettre ce ct au corps
// lui-même (ne pas le jeter)
Un point de vigilance : Task.WhenAll(items.Select(...)) démarre le traitement de tous les éléments d’un seul coup, au moment de l’énumération. Pour un travail fixe de quelques unités à quelques dizaines, ce n’est pas un problème, mais utilisé sur une collection nombreuse, cela épuise d’un coup les sockets, les connexions à la base de données et la mémoire. Pour un traitement dont le nombre d’éléments n’est pas connu à l’avance, plafonnez le nombre d’exécutions simultanées comme dans le Parallel.ForEachAsync ci-dessus, ou contrôlez le débit avec un canal (channel) borné décrit plus loin.
Créer son propre thread ne se justifie presque exclusivement que lorsque des propriétés du thread lui-même sont une exigence — « avoir une boucle de messages dédiée », « devoir spécifier un appartement de thread (STA) », « tourner en continu pendant toute la durée de vie de l’application ».
3.2. Le parallélisme de données avec Parallel.For / ForEach
Pour du parallélisme de données — « appliquer le même traitement à chaque élément d’une collection pour accélérer l’ensemble » —, utilisez Parallel.For / Parallel.ForEach plutôt que de répartir vous-même la boucle sur des threads. La TPL se charge de diviser la source de données (partitionnement) et de rééquilibrer la charge ; pour une boucle basique, aucun verrou n’est nécessaire non plus.11
Cependant, la documentation officielle mentionne explicitement deux pièges.3
- Ne pensez pas que le parallélisme est toujours plus rapide. Une boucle avec peu d’itérations, ou dont le traitement par passage est léger, peut ralentir parce que le surcoût de la parallélisation dépasse le corps du travail. La performance dépend de nombreux facteurs, décidez donc toujours après avoir mesuré.
- Ne faites pas attendre les itérations entre elles. Il n’y a aucune garantie que chaque itération de
Parallel.Fors’exécute réellement en parallèle. Un code où une itération attend qu’un événement soit défini par une autre itération peut s’interbloquer selon l’ordonnancement.
3.3. Les traitements « d’attente » vont vers l’E/S asynchrone, pas vers des threads
Les traitements dominés par une attente d’E/S — fichier, réseau, base de données — ne sont pas des candidats à l’ajout de threads. Occuper un thread entier pendant l’attente n’est que du gaspillage ; avec de l’E/S asynchrone via async/await, aucun thread n’est consommé pendant l’attente. Cette distinction — paralléliser ce qui est lié au CPU, rendre asynchrone ce qui est lié à l’E/S — est la première ligne à tracer à l’entrée de la conception multithread.
flowchart TB
S["Il y a un traitement à rendre concurrent"] --> Q1{"Qu'est-ce qui domine ?"}
Q1 -->|"Attente d'E/S dominante<br/>fichier, réseau, base de données"| ASYNC["E/S asynchrone via async/await<br/>n'ajoute pas de thread"]
Q1 -->|"Calcul consommateur de CPU"| Q2{"Quelle forme prend le travail ?"}
Q2 -->|"Appliquer le même traitement<br/>à tous les éléments d'une collection"| PAR["Parallel.For / ForEach"]
Q2 -->|"Un bloc de traitement<br/>indépendant en arrière-plan"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"Boucle de messages, appartement STA, etc. :<br/>une propriété du thread lui-même est exigée"| TH["new Thread<br/>(dernier recours exceptionnel)"]
Figure 3 : l’arborescence de décision avant de « lancer un thread ». La plupart des traitements métier aboutissent à l’une des trois premières issues, et n’atteindre new Thread reste un cas exceptionnel.
Les décisions pratiques concernant async/await sont traitées dans « Tableau de décision pratique pour async/await en C# », et la façon dont le pool de threads et l’E/S asynchrone s’articulent en dessous est détaillée dans « IOCP et le pool de threads .NET ».
4. Principe 2 : minimiser l’état mutable partagé
Une concurrence problématique ne survient que lorsque « plusieurs threads » et « des données mutables partagées » sont réunis. Le nombre de threads est fixé par les exigences, donc ce que la conception peut réduire, c’est le partage. Il existe trois moyens.
4.1. Diviser — chaque thread ne touche que ses propres données
La solution la plus simple et la plus puissante est de diviser les données par thread. Pour une agrégation dans une boucle parallèle, plutôt que d’écrire à chaque fois dans une variable de total partagée, utilisez la surcharge de Parallel.For qui reçoit un état local au thread, laissez chaque thread construire un sous-total localement, et ne fusionnez qu’une seule fois à la fin. Les écritures dans le partagé passent ainsi de « à chaque itération » à « une fois par thread », ce qui réduit d’un ou deux ordres de grandeur le coût de synchronisation et la fenêtre de concurrence.3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // valeur initiale locale au thread
(i, state, local) => local + Weigh(items[i]), // chaque itération n'ajoute qu'à son propre local
local => Interlocked.Add(ref total, local)); // fusion une seule fois par thread
flowchart TB
SRC["Tableau de données (à traiter)"] --> T1["Thread 1<br/>traite sa part et<br/>n'ajoute qu'à son sous-total local"]
SRC --> T2["Thread 2<br/>traite sa part et<br/>n'ajoute qu'à son sous-total local"]
SRC --> T3["Thread 3<br/>traite sa part et<br/>n'ajoute qu'à son sous-total local"]
T1 --> M["Fusion : avec Interlocked.Add,<br/>répercuté sur le total une seule fois par thread"]
T2 --> M
T3 --> M
Figure 4 : agrégation locale au thread. Pendant le traitement, chaque thread ne touche que ses propres données, ce qui élimine toute concurrence problématique ; l’écriture dans le partagé ne survient qu’une fois par thread, au moment de la fusion.
4.2. Rendre immuable — ce qui n’est pas réécrit peut être partagé
Des données qui ne sont que lues sont sûres à lire simultanément depuis n’importe quel nombre de threads. Des valeurs de configuration, des données maîtresses ou des entrées de calcul peuvent être partagées librement, sans synchronisation, en ne les réécrivant pas après leur construction (en les rendant immuables). En C#, les types record et les propriétés init soutiennent cette conception. Décider que « lorsqu’une modification devient nécessaire, on crée une nouvelle instance et on la substitue, plutôt que de réécrire » suffit à éliminer un état mutable de plus à protéger.
Cependant, « paraître en lecture seule » et « être immuable » sont deux choses différentes. Une interface en lecture seule comme IReadOnlyList<T> signifie seulement « pas de réécriture via cette interface » — cela n’empêche pas de réécrire, via une autre référence, le List<T> sous-jacent. La garantie de record / init est elle aussi superficielle : elle ne protège pas les objets référencés par les propriétés. Pour des données que vous voulez vraiment partager en toute sécurité entre threads, utilisez les collections immuables de System.Collections.Immutable comme ImmutableArray<T>, ou transmettez une copie au moment du partage pour couper court à tout chemin de réécriture. Dans ce dernier cas, la condition est que le type T des éléments soit lui-même immuable. Une collection immuable ne protège que « l’ordonnancement » : les références vers des objets d’éléments mutables restent partagées telles quelles, donc si le contenu d’un élément peut être réécrit par un autre chemin, la concurrence problématique subsiste. Rendez immuable jusqu’aux feuilles du graphe d’objets, ou transmettez une copie profonde.
4.3. Transmettre — envoyer via une file plutôt que partager
Il reste malgré tout nécessaire de faire circuler des données entre threads. Dans ce cas, plutôt que « toucher une variable partagée des deux côtés », adoptez une architecture producteur/consommateur où une file d’attente est placée entre les deux, l’un écrivant, l’autre lisant.
Le premier choix en .NET est System.Threading.Channels. C’est une FIFO où le producteur écrit les données de façon asynchrone et le consommateur les lit de façon asynchrone, toute la complexité de la synchronisation étant gérée par le canal (channel).5
var channel = Channel.CreateBounded<WorkItem>(100); // capacité 100, applique une contre-pression
// Côté producteur
await channel.Writer.WriteAsync(item, ct); // si plein, attend qu'une place se libère
// …une fois que tous les producteurs ont fini d'écrire :
channel.Writer.Complete(); // déclare « il n'y en aura plus ». Sans cela, la boucle du lecteur ne peut jamais se terminer
// Côté consommateur
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
Process(item);
}
flowchart LR
P1["Producteur 1<br/>WriteAsync"] --> CH["Canal borné (capacité 100)<br/>File FIFO<br/>synchronisation gérée par le canal"]
P2["Producteur 2<br/>WriteAsync"] --> CH
CH --> C1["Consommateur 1<br/>ReadAllAsync"]
CH --> C2["Consommateur 2<br/>ReadAllAsync"]
CH -.->|"fait attendre l'écriture si plein<br/>(contre-pression)"| P1
CH -.->|"fait attendre la lecture si vide"| C1
Figure 5 : une architecture producteur/consommateur avec un canal interposé. Aucun des deux côtés ne touche directement une variable partagée ; la synchronisation et le contrôle de capacité sont tous deux confiés au canal.
Ce qui compte en pratique, c’est de choisir un canal borné (bounded), à capacité limitée. Le comportement par défaut une fois la limite atteinte est que « le côté écriture attend qu’une place se libère », ce qui constitue une contre-pression (backpressure) naturelle. Utiliser une file illimitée dans une configuration où la production est plus rapide que la consommation devient une bombe à retardement qui « fonctionne mais dont la mémoire ne cesse de croître ».5
Dans le monde synchrone, ce qui joue le même rôle qu’un canal borné est BlockingCollection<T> avec une capacité spécifiée. Sa limite de capacité empêche le producteur de trop dépasser le consommateur, et il bloque et fait attendre le consommateur lorsqu’il est vide — il combine blocage et contrôle de capacité.12 En revanche, ConcurrentQueue<T> / ConcurrentStack<T> sont des collections rapides qui obtiennent la sécurité entre threads uniquement via des opérations Interlocked, sans verrou6, mais ce sont de simples files thread-safe dépourvues de limite de capacité ou de mécanisme « attendre quand c’est vide ». Considérez-les comme des composants, non comme les acteurs principaux de la transmission de travail. Notez que BlockingCollection<T> n’est pas conçu pour un accès asynchrone : si vous le combinez avec async/await, choisissez Channel<T>.12
Attention aussi à l’idée reçue selon laquelle « remplacer un dictionnaire par ConcurrentDictionary le rend thread-safe ». Même si chaque opération individuelle est thread-safe, une opération composite comme « vérifier l’existence puis ajouter » reste sujette à la concurrence problématique (utilisez des méthodes dédiées aux opérations composites comme GetOrAdd). Ce GetOrAdd a lui aussi une réserve : si la valeur finalement stockée est unique, la fonction de fabrique qui la construit peut être appelée plusieurs fois en cas de concurrence. Y mettre un effet de bord (ouvrir une connexion, créer un fichier, etc.) provoque des fuites dues à des exécutions dupliquées — utilisez donc une fonction sans effet de bord, ou stockez un Lazy<T> comme valeur pour une initialisation que vous voulez garantir unique. Changer le type de collection ne remplace pas la réduction de l’état mutable partagé.
5. Principe 3 : discipliner les verrous
Même en réduisant l’état mutable partagé, on ne peut souvent pas le ramener à zéro. Pour ce qui reste partagé, on utilise un contrôle d’exclusion (un verrou), mais le verrou n’est pas un outil pour « entourer d’un lock, par précaution, tout endroit qui semble suspect ». Il y a quatre règles de discipline.
5.1. Décider « ce que l’on protège » et verrouiller avec un objet dédié
Pensez l’unité de verrouillage non pas en termes de « portion de code », mais de « données ». Faites correspondre un objet de verrouillage à chaque ensemble de données mutables à protéger, et prenez ce même verrou à chaque endroit qui touche ces données — la réalité des bugs de concurrence, c’est que cette table de correspondance s’effrite.
L’objet servant de verrou doit être une instance dédiée, non exposée à l’extérieur. lock(this) partage le verrou avec tout code externe pouvant référencer votre instance, et lock(typeof(X)) le partage avec l’ensemble du domaine d’application — chacun est un terreau d’interblocages. À partir de .NET 9 / C# 13, il est recommandé d’utiliser comme objet de verrouillage une instance du type dédié System.Threading.Lock.4
public class OrderBook
{
private readonly Lock _gate = new(); // .NET 9+ (avant cela, readonly object)
private readonly List<Order> _orders = []; // les données protégées par _gate
public void Add(Order order)
{
lock (_gate) { _orders.Add(order); }
}
}
L’instruction lock de C# garantit que le verrou est libéré de façon certaine, même en cas d’exception. Sa façon d’être développée dépend du type de l’objet de verrouillage : pour un objet ordinaire, elle prend la forme d’un appel à Monitor.Exit dans un finally ; pour le type Lock, celle d’un EnterScope() suivi de sa mise au rebut (dispose).413 Autrement dit, un champ de type Lock est un mécanisme distinct de Monitor, et si seule une partie du code écrit à la main Monitor.Enter(_gate), l’exclusion mutuelle avec lock (_gate) ne tient plus. Quel que soit le type, il est plus sûr d’abandonner l’écriture manuelle de Monitor.Enter / Exit et de s’en tenir systématiquement à la syntaxe lock.4
5.2. Ne rien faire de « long » ou « d’externe » pendant qu’un verrou est détenu
Plus le temps de détention d’un verrou est court, mieux c’est ; la seule chose autorisée pendant qu’un verrou est détenu, c’est de lire ou d’écrire les données protégées. Faire une E/S en gardant le verrou, appeler du code externe via un événement ou un callback : ces façons d’écrire n’allongent pas seulement la durée de détention, elles créent aussi un chemin où le code appelé tente de prendre un autre verrou et provoque un interblocage. Préparer en dehors du verrou, et ne faire que la substitution à l’intérieur, est la forme de base.
Notez qu’on ne peut pas faire d’await à l’intérieur d’un lock (cela provoque une erreur de compilation). Ce n’est pas une restriction, mais une protection : Monitor a une affinité de thread — c’est le thread qui a pris le verrou qui doit le libérer —, ce qui est incompatible avec du code asynchrone où le thread peut changer avant et après un await. Pour l’exclusion mutuelle dans du code asynchrone, utilisez un SemaphoreSlim avec un compte initial de 1.14
private readonly SemaphoreSlim _asyncGate = new(1, 1);
public async Task SaveAsync(Data data, CancellationToken ct)
{
await _asyncGate.WaitAsync(ct);
try { await WriteToFileAsync(data, ct); }
finally { _asyncGate.Release(); }
}
5.3. Toujours acquérir plusieurs verrous dans le même ordre
Lorsqu’il y a deux verrous ou plus, le schéma classique d’interblocage survient quand l’ordre d’acquisition s’inverse selon le thread. La parade est simple : établissez la règle que tous les threads acquièrent les verrous dans le même ordre. Là où l’ordre ne peut pas être garanti, utilisez la surcharge avec délai d’expiration de Monitor.TryEnter — si l’acquisition échoue, relâchez et recommencez (ou journalisez l’anomalie) : cela transforme un blocage éternel en un échec détectable.4
5.4. Interlocked pour les mises à jour simples, ReaderWriterLockSlim quand les lectures dominent
Pour une mise à jour atomique d’une variable unique — incrémenter/décrémenter un compteur, échanger un drapeau — la classe Interlocked (Increment / Add / CompareExchange) est plus rapide que lock. En l’absence de concurrence, elle se réduit à un simple préfixe d’instruction CPU.4 À l’inverse, Interlocked s’arrête là : il ne peut pas maintenir la cohérence de plusieurs variables ensemble. Une structure sans verrou (lock-free) faite maison, combinée à volatile, est un outil d’expert exigeant une compréhension approfondie du modèle mémoire, et n’a pas sa place dans une application métier.
Pour des données partagées « souvent lues mais rarement écrites », ReaderWriterLockSlim — qui n’exclut que les écritures et laisse passer les lectures simultanément — est aussi une option.13
6. Principe 4 : concevoir la façon de s’arrêter dès le départ
La première question à poser lors d’une revue de conception multithread est : « comment est-ce que ça s’arrête ? » On peut écrire du code qui démarre sans y penser, mais du code qui s’arrête en toute sécurité ne naît que si on le conçoit.
6.1. L’annulation coopérative (CancellationToken) est la seule bonne réponse
Le modèle d’arrêt de .NET est entièrement unifié autour de l’annulation coopérative. Le côté qui arrête crée un CancellationTokenSource et transmet son Token à chaque traitement. Lorsqu’il veut arrêter, il appelle Cancel(). Le côté traitement surveille le token et se termine en faisant son nettoyage à un endroit qui lui convient — comme c’est coopératif et non forcé, le traitement peut se terminer en conservant un état cohérent.7
private CancellationTokenSource? _cts;
private Task? _worker;
public void Start()
{
if (_worker is { IsCompleted: false }) // Refuse un double Start pendant que le worker tourne
throw new InvalidOperationException("Le worker est déjà en cours d'exécution.");
if (_worker is { IsFaulted: true }) // Ne pas recréer en étouffant l'échec précédent
throw new InvalidOperationException("Le worker précédent a échoué.", _worker.Exception);
_cts = new CancellationTokenSource();
var token = _cts.Token; // Capturé d'abord dans une variable locale pour ne pas entrer en concurrence avec un redémarrage après arrêt
_worker = Task.Run(() => WorkLoop(token), token);
}
private void WorkLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // Surveillance par polling
{
ProcessNextItem(ct); // Transmet ct aux appels bloquants pour une interruption immédiate
}
}
public async Task StopAsync()
{
var cts = _cts; // Fixés dans des variables locales pour ne pas se tromper de cible à arrêter
var worker = _worker; // même si les champs sont remplacés pendant l'attente
if (cts is null || worker is null) return;
Exception? cancelFailure = null;
try { cts.Cancel(); } // Un callback enregistré sur le token peut lever une exception
catch (Exception ex) { cancelFailure = ex; } // On la capture, et on la signale seulement après avoir rejoint le worker
try
{
try { await worker; } // Le join doit toujours avoir lieu, que Cancel ait réussi ou non, et observe aussi un échec en cours de route
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // Seul l'arrêt que nous avons demandé est traité comme « normal »
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // Ne perdre ni l'un ni l'autre des deux échecs
}
}
finally
{
cts.Dispose(); // La source, une fois le join effectué, est mise au rebut (libération des ressources OS comme WaitHandle).
if (ReferenceEquals(_cts, cts))
{
_cts = null; // Empêche un StopAsync ultérieur d'utiliser une source déjà mise au rebut
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
Notez que ce Start / StopAsync est une configuration minimale qui suppose des appels séquentiels depuis un même thread (comme le thread d’interface utilisateur). Si plusieurs threads peuvent manipuler simultanément le cycle de vie, sérialisez Start / StopAsync eux-mêmes avec un SemaphoreSlim ou équivalent — ce serait absurde que les opérations de gestion du worker soient elles-mêmes sujettes à concurrence avant même de protéger le worker.
Ce petit exemple contient aussi des astuces qui comptent en pratique. D’abord, Start refuse un double appel pendant l’exécution. Écraser inconditionnellement _cts et _worker ferait perdre la référence au worker précédent, et un « thread errant » — qu’on ne peut plus ni arrêter ni rejoindre — continuerait de tourner en parallèle. Pour une API de cycle de vie (Start/Stop), la base est de se protéger soi-même en garantissant « un seul à la fois ». S’ajoutent trois autres points. Premièrement, l’API d’arrêt attend l’achèvement. Cancel() se contente de « demander » l’annulation ; au moment où il rend la main, le worker peut encore être en plein milieu de ProcessNextItem. Faire un Stop() qui se contente de demander puis rendre la main créerait une nouvelle concurrence problématique : le worker tournerait encore au moment où l’appelant commence son propre nettoyage. Deuxièmement, conservez la Task au lieu de la jeter. Avec _ = Task.Run(...), si le worker meurt d’une exception, personne ne s’en aperçoit. Troisièmement, ne référencez pas _cts.Token à l’intérieur de la lambda : capturez-le d’abord dans une variable locale avant de le transmettre. Le référencer dans la lambda le fait évaluer au moment de l’exécution, ce qui provoque, en cas de redémarrage juste après un arrêt, une confusion où l’ancien worker capte le nouveau token. En transmettant aussi ce token comme deuxième argument de Task.Run, la Task est classée comme « annulée (Canceled) » plutôt que « en échec (Faulted) » lorsque le traitement se termine via ThrowIfCancellationRequested ou l’OperationCanceledException d’une API prenant en charge l’annulation (dans cet exemple, sortir normalement de la condition de boucle est traité comme un achèvement normal). Autre point : le catch de StopAsync ne capture, via son filtre when, que l’annulation issue de son propre token. Si l’on étouffait OperationCanceledException sans condition, même un véritable échec levé par un autre token interne au traitement (comme un délai d’expiration par élément) apparaîtrait comme « normal parce qu’arrêté ». Notez que cette identification par correspondance de token s’effondre si WorkLoop utilise en interne un token lié (la composition par liaison du 6.1) : une exception portant le token lié arrive alors. Dans cette configuration, choisissez explicitement, en tant que décision de conception, entre appeler ct.ThrowIfCancellationRequested() à la sortie de WorkLoop pour « traduire » vers le token extérieur avant de sortir, ou assouplir le filtre en when (cts.IsCancellationRequested) en tranchant que « une annulation pendant une demande d’arrêt est normale ».
flowchart TB
OWNER["Côté qui arrête"] -->|"appelle Cancel() une fois"| CTS["CancellationTokenSource"]
CTS -->|"transmet le Token"| W1["Traitement worker 1"]
CTS -->|"transmet le Token"| W2["Traitement worker 2"]
CTS -->|"transmet le Token"| W3["API d'une bibliothèque<br/>compatible avec l'annulation"]
W1 -->|"vérifie IsCancellationRequested<br/>nettoie et se termine de lui-même"| E1["Terminaison normale"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= traité comme une annulation achevée"]
W3 -->|"interrompu immédiatement même en attente"| E3["Annulation achevée"]
Figure 6 : le schéma de l’annulation coopérative. Le côté qui arrête se contente d’appeler Cancel() ; c’est chaque traitement qui décide lui-même « quand et comment se terminer ». C’est pourquoi il peut s’arrêter en conservant un état cohérent.
Le comportement attendu côté bibliothèque est également établi. Une opération annulable doit fournir une méthode publique recevant un CancellationToken, et une boucle de calcul doit vérifier périodiquement IsCancellationRequested ou appeler ThrowIfCancellationRequested(). Cette dernière lève une OperationCanceledException, que Task traite comme une « annulation achevée » et non comme un « échec ». Si vous voulez arrêter à la fois sur un token transmis de l’extérieur et pour des raisons internes (comme un délai d’expiration), composez-les avec un token lié.7
6.2. Considérez que Thread.Abort n’existe pas
Thread.Abort, qui « tuait de l’extérieur un thread récalcitrant », se contente de lever une PlatformNotSupportedException sur .NET Core / .NET 5 et ultérieur, et n’est donc plus utilisable. Injecter une exception dans un thread sans savoir où il en est de son exécution risque d’interrompre la libération de ressources ou de corrompre son état. Si vous devez forcer l’arrêt d’un code tiers qui ne répond pas (ou ne peut pas être écrit pour répondre) à l’annulation coopérative, l’indication officielle est de l’exécuter dans un processus séparé et de l’arrêter avec Process.Kill.8
6.3. Pour attendre, utilisez un handle d’attente plutôt que le polling
Le code qui « attend dans une boucle avec Sleep(100) jusqu’à ce qu’un drapeau se lève » gaspille à la fois du CPU et de la réactivité. Pour les signaux entre threads, des primitives de synchronisation comme ManualResetEventSlim ou SemaphoreSlim permettent de mettre le thread en veille correctement jusqu’à ce qu’il devienne signalé.13 La distinction entre précision des minuteries et attente d’événement sous Windows est détaillée dans « Pourquoi préférer l’attente d’événement à Sleep(1) sous Windows ».
7. Le cas particulier du thread d’interface utilisateur — la règle des applications de bureau Windows
Les applications de bureau Windows sont soumises, en plus des principes généraux, à une autre contrainte forte : la règle selon laquelle seul le thread qui l’a créée (le thread d’interface utilisateur) peut toucher l’interface.
Les contrôles WinForms ne sont pas thread-safe, et les manipuler depuis plusieurs threads pousse le contrôle dans un état incohérent, cause de concurrence problématique, d’interblocages et de gels. Windows exige de l’application qu’elle prépare un unique thread dédié à la réception des messages système, et la création comme la manipulation de l’interface doivent être concentrées sur ce thread.9 WPF a exactement la même structure : seul le thread d’interface utilisateur peut modifier les éléments d’interface.10
Lorsqu’un autre thread veut mettre à jour l’interface, il ne doit pas la toucher directement : il faut convertir cela en « une demande adressée au thread d’interface utilisateur ».
flowchart LR
OS["Windows<br/>souris, clavier, réaffichage"] --> Q["File de messages<br/>du thread d'interface utilisateur"]
BG["Thread en arrière-plan<br/>(traitement lourd, communication)"] -->|"demande via Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["Thread d'interface utilisateur<br/>le seul à pouvoir toucher les contrôles"]
BG -.->|"toucher directement un contrôle"| NG["Interdit<br/>cause de concurrence, d'interblocages, de gels"]
Figure 7 : convertir la mise à jour de l’interface en « demande ». Le travail du thread en arrière-plan s’arrête à faire déposer sa demande dans la file de messages ; c’est toujours le thread d’interface utilisateur lui-même qui touche les contrôles.
| Framework | Moyen de faire la demande |
|---|---|
| WinForms | Control.Invoke (synchrone) / Control.BeginInvoke (asynchrone) / à partir de .NET 9, Control.InvokeAsync9 |
| WPF | Dispatcher.Invoke (synchrone) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (asynchrone)10 |
Parmi ces moyens, la forme synchrone (Control.Invoke / Dispatcher.Invoke) exige de la prudence. Si le worker appelle Invoke alors que le thread d’interface utilisateur attend justement, de façon synchrone, l’achèvement de ce worker, cela devient un interblocage où chacun attend l’autre (exactement l’attente circulaire du chapitre 2). Prenez par défaut la forme asynchrone (BeginInvoke / InvokeAsync) pour les notifications et rapports de progression venant de l’arrière-plan, et réservez la forme synchrone aux situations où l’on peut affirmer avec certitude que le thread d’interface utilisateur ne l’attend pas.
En pratique, il existe une réponse encore meilleure. Si vous écrivez avec async/await un traitement démarré sur le thread d’interface utilisateur, await capture le SynchronizationContext de ce thread et reprend automatiquement le traitement qui suit sur ce même thread, ce qui réduit fortement les cas où il faut écrire Invoke à la main. Ce n’est cependant pas une propriété inconditionnelle. Du code entré depuis un callback en arrière-plan, ou la continuation après un ConfigureAwait(false), ne revient pas au thread d’interface utilisateur — si vous touchez à l’interface par ce chemin, une répartition (dispatch) explicite reste nécessaire. Adopter la forme « le traitement lourd va vers Task.Run ou de l’E/S asynchrone, et le report du résultat à l’écran se fait dans la suite de l’await » est la structure de base des applications Windows modernes. La relation entre le thread d’interface utilisateur et async/await est résumée en une seule image dans « Synthèse en une page d’async et du thread d’interface utilisateur sous WPF/WinForms ».
Par ailleurs, lorsque COM est impliqué — intégration Office, composants hérités — une couche supplémentaire s’ajoute : le modèle de thread propre à COM (STA/MTA). L’accident classique « on a créé l’objet COM sur le thread d’interface utilisateur, mais l’appel depuis un autre thread a gelé l’application » relève de cette couche, expliquée dans « Les bases de STA/MTA en COM ».
8. Pour le code natif (C++/C)
Les principes exposés jusqu’ici — ne pas créer de threads directement, réduire l’état mutable partagé, discipliner les verrous, concevoir la façon de s’arrêter — restent valables tels quels en code natif. Ce qui change, c’est l’outillage. En C++, ce sont RAII et std::jthread / std::mutex / std::atomic ; en C, ce sont l’API Win32 _beginthreadex, les verrous SRW, les variables de condition, et le motif de l’événement d’arrêt, qui en sont les équivalents. Chacun de ces sujets, y compris les pièges propres au langage (le destructeur de std::thread, le danger de TerminateThread, DllMain et le verrou du chargeur, etc.), est traité dans l’« édition C++ » et l’« édition C » de cette série.
9. Vérification et débogage — se préparer en partant du principe que « ça ne se reproduira pas »
On ne peut pas espérer que les tests détectent les bugs de multithreading, car un test unitaire ordinaire compte comme un succès une exécution qui, par chance, n’a pas rencontré de concurrence problématique. Pensez la préparation en trois couches.
La première ligne de défense est constituée des principes de conception exposés jusqu’ici. Entre une application avec 5 éléments d’état mutable partagé et une autre avec 50, le nombre d’endroits à suspecter diffère d’un facteur dix. En revue, on vérifie sous forme de tableau : « quelles sont les données mutables partagées », « quel verrou protège chacune », « l’ordre d’acquisition des verrous est-il unique », « où se situe le chemin d’arrêt ». Une conception pour laquelle ce tableau ne peut pas être rempli n’est pas encore terminée, même si elle fonctionne.
Deuxièmement, rendez les anomalies observables au lieu de les masquer. Détecter une anomalie d’attente de verrou avec le délai d’expiration de Monitor.TryEnter et la journaliser4, enregistrer plutôt qu’étouffer les exceptions non observées d’un traitement soumis au pool de threads, préparer, en cas de blocage, la prise d’un dump complet permettant d’examiner la pile de tous les threads — la lutte contre les bugs « qui ne surviennent que rarement » se joue sur la quantité d’informations que l’on parvient à extraire de la seule fois où le bug s’est produit. La mise en place des dumps et des journaux est traitée dans « Concevoir la conservation des journaux et des dumps lors du plantage d’une application Windows ».
Troisièmement, secouez le système sous charge. Faire tourner longtemps avec un degré de parallélisme supérieur au nombre de cœurs, randomiser l’ordre de traitement, insérer des délais artificiels : ces tests de stress, qui augmentent sur une machine de développement la probabilité de « tomber » sur un entrelacement problématique, sont un moyen concret de débusquer la concurrence avant livraison. Un bug qui disparaît en exécution de débogage se reproduit souvent en build Release sous forte charge.
10. Conclusion — liste de contrôle avant d’ajouter des threads
Au fond, les bonnes pratiques de la programmation multithread ne sont pas « l’art de bien écrire la synchronisation », mais « une conception qui permet de se passer d’en écrire ». Si vous pouvez répondre aux huit questions suivantes avant de vous lancer, vous évitez presque tous les gros accidents.
- Ce traitement est-il lié au CPU ou à l’E/S (dans ce second cas, la réponse est async/await, pas un thread) ?
- Suis-je sur le point d’écrire
new Thread(ne peut-on pas l’exprimer avec Task,Parallelou le pool de threads) ? - Quelles sont les données mutables partagées entre threads, peut-on les énumérer ?
- Ce partage peut-il être éliminé par « division », « immuabilité » ou « transmission via une file d’attente » ?
- Pour chaque donnée partagée restante, un verrou correspondant unique est-il défini ?
- L’ordre d’acquisition des verrous est-il unique sur tous les threads ? Y a-t-il un appel externe pendant qu’un verrou est détenu ?
- Un
CancellationTokenest-il transmis à tous les traitements de longue durée ? Peut-on expliquer le chemin d’arrêt ? - Le code qui touche l’interface utilisateur est-il concentré sur le thread d’interface utilisateur ?
Les bugs de multithreading n’apparaissent pas le jour où on les écrit ; ils montrent les crocs chez le client, une fois qu’on les a oubliés. Autrement dit, passer cette liste de contrôle au stade de la conception permet d’éliminer, avant même d’écrire le code, le type de panne le plus coûteux qui soit : « ça plante de temps en temps », « ça gèle une fois par mois ».
Articles connexes
- Bonnes pratiques de multithreading en pratique — édition C++
- Bonnes pratiques de multithreading en pratique — édition C
- Bonnes pratiques de multithreading en pratique — édition Java
- Tableau de décision pratique pour async/await en C# - Task.Run et ConfigureAwait
- Synthèse en une page d’async et du thread d’interface utilisateur sous WPF/WinForms
- Windows I/O en profondeur (3ᵉ partie) ── le port d’achèvement d’E/S (IOCP) et le pool de threads .NET
- Les bases de STA/MTA en COM - le modèle de thread et comment éviter les blocages
- Pourquoi préférer l’attente d’événement à Sleep(1) sous Windows
- Les pièges de la mémoire partagée et les bonnes pratiques
Domaines de conseil associés
合同会社小村ソフト (Komura Software LLC) prend en charge la revue de conception d’applications métier incluant la mise en multithreading, l’investigation des causes de bugs peu reproductibles du type « ça plante de temps en temps », « ça gèle » (analyse de dumps, localisation des points de concurrence), et le conseil technique pour paralléliser ou rendre asynchrones des applications existantes. Vous pouvez nous solliciter dès le stade de « pouvez-vous vérifier si cette conception risque une concurrence problématique ? ».
- Conseil technique et revue de conception
- Investigation de bugs et analyse de causes
- Développement d’applications Windows
- Contact
Références
-
Microsoft Learn, Task Parallel Library (TPL). Sur le fait que la TPL est le moyen recommandé pour du code multithread et parallèle depuis .NET Framework 4, qu’elle ajuste dynamiquement le degré de parallélisme en fonction des processeurs disponibles, qu’elle prend en charge la division du travail, l’ordonnancement vers le pool de threads, la prise en charge de l’annulation et la gestion d’état, que dans une boucle dont le travail par itération est petit le surcoût de parallélisation peut ralentir l’exécution, et qu’une compréhension de base des verrous, des interblocages et des conditions de concurrence reste recommandée même en utilisant la TPL. ↩ ↩2
-
Microsoft Learn, The managed thread pool. Sur le fait que la classe ThreadPool fournit un pool de threads de travail géré par le système, permettant au développeur de se concentrer sur les tâches de l’application plutôt que sur la gestion des threads, et sur le fait que .NET utilise abondamment le pool de threads pour les opérations de la TPL, l’achèvement des E/S asynchrones, les callbacks de minuteries, les attentes enregistrées et les connexions socket. ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. Sur le fait qu’une boucle parallèle peut être plus lente qu’une boucle séquentielle et qu’il faut toujours mesurer, sur le fait qu’il faut éviter les écritures dans la mémoire partagée à l’intérieur d’une boucle parallèle et que la surcharge utilisant un état local au thread est recommandée, et sur le fait qu’il n’existe aucune garantie que chaque itération de For/ForEach s’exécute en parallèle, un code qui synchronise des itérations entre elles pouvant s’interbloquer. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices. Sur la définition de la condition de concurrence (exemple de l’incrémentation d’un compteur décomposée en lecture, addition, réécriture, avec perte par écrasement) et de l’interblocage, sur le fait qu’il faut utiliser l’annulation coopérative plutôt que Thread.Abort, sur le fait qu’il ne faut pas verrouiller sur un type ou sur this et qu’à partir de .NET 9/C# 13 il faut utiliser une instance dédiée de System.Threading.Lock, sur le fait que l’instruction lock de C# garantit Monitor.Exit dans un finally, sur la détection d’interblocage via le délai d’expiration de Monitor.TryEnter, sur le fait que la classe Interlocked est plus rapide pour les changements d’état simples, et sur la ligne directrice de conception selon laquelle les données statiques devraient être thread-safe par défaut tandis que les données d’instance ne le sont pas par défaut. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Microsoft Learn, System.Threading.Channels library. Sur le fait qu’un canal est une FIFO de modèle producteur/consommateur qui gère la synchronisation en interne, que CreateBounded permet de créer un canal à capacité limitée, que le comportement par défaut une fois la limite atteinte est l’attente (Wait) côté écriture, avec la possibilité de choisir un FullMode comme DropOldest, et qu’une contre-pression s’applique lorsque l’écriture est plus rapide que la lecture. ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. Sur le fait que les collections de System.Collections.Concurrent obtiennent la sécurité entre threads via des verrous à granularité fine ou des mécanismes sans verrou, et sur le fait que ConcurrentQueue et ConcurrentStack sont implémentées sans verrou, via des opérations Interlocked, et supportent des ajouts/suppressions fréquents depuis plusieurs threads. ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. Sur la procédure du modèle d’annulation coopérative via CancellationTokenSource et CancellationToken, sur le fait que l’annulation est coopérative et non forcée, la façon de s’arrêter étant décidée côté auditeur (listener), sur les trois moyens de surveillance — polling, enregistrement de callback, handle d’attente —, sur le fait que le Task traite l’OperationCanceledException levée par ThrowIfCancellationRequested comme une annulation achevée, sur la composition de plusieurs tokens via un token lié, et sur le fait qu’une bibliothèque devrait fournir des méthodes publiques recevant un CancellationToken. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. Sur le fait qu’il faut utiliser CancellationToken pour arrêter un thread, que sur .NET Core et .NET 5 et ultérieur Thread.Abort lève une PlatformNotSupportedException et qu’à partir de .NET 5 un avertissement d’obsolescence à la compilation (SYSLIB0006) apparaît également, et sur le fait que pour forcer l’arrêt d’un code tiers ne répondant pas à l’annulation coopérative, il faut l’exécuter dans un processus séparé et utiliser Process.Kill. ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. Sur le fait que l’accès aux contrôles WinForms n’est pas thread-safe et que des opérations depuis plusieurs threads provoquent un état incohérent, de la concurrence, des interblocages et des gels, que tous les contrôles doivent être créés et accédés sur le même thread, Windows exigeant un thread d’interface utilisateur dédié à la livraison des messages système, et que depuis un autre thread, il faut appeler de façon sûre via Control.Invoke, Control.InvokeAsync (à partir de .NET 9), ou BackgroundWorker. ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). Sur le fait que sous WPF, seul un unique thread peut modifier l’interface, qu’un thread en arrière-plan doit enregistrer un élément de travail auprès du Dispatcher du thread d’interface utilisateur pour en faire la demande, que Dispatcher.Invoke est synchrone tandis qu’InvokeAsync et BeginInvoke sont asynchrones, et que le Dispatcher traite le travail via une file d’attente à priorités. ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). Sur le fait que Parallel.For / Parallel.ForEach offrent le parallélisme de données avec une écriture presque identique à celle d’une boucle for, qu’ils ne nécessitent ni création de thread ni mise en file d’attente manuelle et qu’une boucle basique ne requiert pas de verrou non plus, et sur le fait que la TPL divise la source de données et redistribue la charge entre plusieurs threads en cas de déséquilibre. ↩
-
Microsoft Learn, BlockingCollection<T> Class. Sur le fait que BlockingCollection est une implémentation producteur/consommateur dotée de blocage et de limitation de capacité, que la limitation de capacité empêche le producteur de trop dépasser le consommateur, et sur le fait qu’elle n’est pas conçue pour un accès asynchrone, l’usage de Channel<T> étant recommandé pour un modèle producteur/consommateur asynchrone. ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives. Sur le fait que Monitor fournit une exclusion mutuelle via un objet de verrouillage et possède une affinité de thread, que sous C# il faut utiliser l’instruction lock plutôt que Monitor directement, que ReaderWriterLockSlim exclut les écritures tout en permettant un accès simultané en lecture, et que SemaphoreSlim est un sémaphore léger réservé à un seul processus tandis que Semaphore, nommé, peut servir à la synchronisation entre processus. ↩ ↩2 ↩3
-
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. Sur le fait que l’instruction lock et le type Lock de C# ont une affinité de thread et ne peuvent donc pas être utilisés à travers un await (le thread exécutant la continuation pouvant changer avant et après l’await), sur le fait que pour l’exclusion mutuelle dans du code asynchrone il faut utiliser un SemaphoreSlim de compte 1 avec WaitAsync et un Release dans un try/finally, et sur le fait qu’un Channel borné peut remplacer cela pour un usage de limitation de débit (throttling). ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Bonnes pratiques de multithreading en pratique — édition C++ : éliminer les accidents structurellement avec RAII et jthread
En C++, le multithreading est un monde où une course de données devient un comportement indéfini. Cet article couvre le piège du destruct...
Bonnes pratiques du multithreading en pratique — édition Java : les usages à l'ère des threads virtuels
En Java, la bonne pratique consiste à ne jamais créer de threads directement, mais à s'appuyer sur ExecutorService et les threads virtuel...
Bonnes pratiques du multithreading en pratique — édition langage C — écrire en toute sécurité à la manière de l'API Win32
Le multithreading en C avec Win32 repose sur des règles éprouvées : création de threads avec _beginthreadex, verrous SRW et variables de ...
Utiliser WMI/CIM depuis C# et PowerShell — Guide pratique pour la récupération d'informations matérielles, la surveillance des processus et l'interrogation à distance
La récupération du numéro de série d'un PC, la surveillance de l'espace disque libre et la détection du démarrage de processus reposent c...
Comment choisir la communication inter-processus sous Windows ── Tableau de décision : tubes nommés / TCP / gRPC / mémoire partagée / COM
Comment choisir le moyen de faire communiquer des applications Windows entre elles ? Cet article organise les tubes nommés, le TCP local,...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Applications métier, intégration d'équipements et outils de communication, des besoins au développement.
Conseil technique et revue de conception
Clarification de la stratégie de modification, de la conception et du traitement des actifs existants.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Pourquoi faut-il éviter lock(this) ou lock(typeof(MyClass)) ?
- Parce que l'objet servant de verrou devient visible depuis du code autre que le vôtre. this est votre instance elle-même, donc tout code externe pouvant référencer cette instance peut verrouiller le même objet, ce qui cause une concurrence ou des interblocages non voulus. typeof(MyClass) est encore plus dangereux : comme il n'existe qu'un seul objet Type par domaine d'application, cela partage le verrou avec du code totalement sans rapport. Utilisez pour le verrouillage un objet dédié, non exposé à l'extérieur. À partir de .NET 9 / C# 13, il est recommandé d'utiliser comme objet de verrouillage une instance du type dédié System.Threading.Lock.
- Combien de threads peut-on créer au maximum ? Quel est le nombre optimal de threads ?
- La réponse moderne est de « ne pas décider soi-même du nombre de threads ». En utilisant Task ou la classe Parallel, le pool de threads ajuste automatiquement le degré de parallélisme selon le nombre de cœurs CPU et la charge. Une conception qui répète new Thread par soi-même a tendance à être surdimensionnée ou sous-dimensionnée selon le nombre de cœurs de la machine du client. Ce qu'il faut avoir en tête comme repère n'est pas un nombre, mais le type de travail : un calcul qui sature le CPU ne va pas plus vite en étant parallélisé au-delà du nombre de cœurs, et pour un traitement dominé par l'attente d'E/S, la bonne réponse n'est de toute façon pas d'ajouter des threads, mais de passer à de l'E/S asynchrone avec async/await.
- Ajouter volatile rend-il le code thread-safe ?
- Non. Ce que garantit volatile, c'est un ordonnancement — que l'accès à ce champ ne soit pas réordonné par rapport aux opérations mémoire voisines (une sémantique d'acquisition/libération) — et non l'atomicité d'une opération composite du type « lire, calculer, réécrire ». Par exemple, faire ++ depuis plusieurs threads sur un compteur volatile int fait quand même perdre des additions. Pour incrémenter/décrémenter un compteur ou faire une comparaison-échange, utilisez la classe Interlocked ; s'il faut protéger plusieurs variables ensemble, utilisez lock. volatile n'est envisageable que dans des cas simples, presque limités à un drapeau d'arrêt du type « un seul thread écrit, les autres ne font que lire » — et même ce drapeau s'exprime aujourd'hui de façon standard avec CancellationToken.
- Comment distinguer si un bug qui ne survient que rarement est dû au multithreading ?
- Trois signes doivent éveiller le soupçon : « la même opération se reproduit tantôt oui, tantôt non », « ça ne se reproduit plus dès qu'on attache un débogueur ou qu'on ajoute des logs », « ça ne survient que sous forte charge ou juste après le démarrage ». Un bug dépendant du timing se caractérise par un résultat qui change à chaque exécution, ce qui est la définition même d'une condition de concurrence. Pour le circonscrire, commencez par recenser les données mutables partagées et dressez, pour chacune, un tableau indiquant « quel verrou la protège ». Le moindre accès non protégé devient un suspect. En cas de blocage, récupérez la pile de tous les threads et vérifiez si les attentes de verrous forment un cycle entre elles. Suspendez l'exécution dans le débogueur Visual Studio et examinez les « piles parallèles », ou, en production, prélevez un dump pour l'analyser.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.