Bonnes pratiques du multithreading : édition .NET — ce qu'il faut décider avant d'ajouter des threads
· Mis à jour le: · Go Komura · Windows, Multithreading, C#, .NET, Application métier, Investigation de bugs, Conception
Historique des révisions (première version, publiée le 2 Aug 2026)
- Première publication
Citer cet article(DOI (archive enregistrée): 10.5281/zenodo.22175822)
Les DOI ci-dessous renvoient à des versions déjà archivées et peuvent différer du texte actuel. Pour citer le texte actuel, utilisez l’URL de cette page.
Go Komura (2026). Bonnes pratiques du multithreading : édition .NET — ce qu'il faut décider avant d'ajouter des threads. KomuraSoft LLC. https://comcomponent.com/fr/blog/multithreading-best-practices-dotnet/
- DOI (archive enregistrée)
- 10.5281/zenodo.22175822
- DOI (dernière version enregistrée)
- 10.5281/zenodo.22175823
« Le traitement était lent, alors on a lancé des threads pour paralléliser, et maintenant le total dérape de temps en temps. » « On a ajouté un traitement en arrière-plan, et l’application se fige une fois par mois. » « On nous dit que ça ne se reproduit pas sous le débogueur, mais chez le client, ça arrive bel et bien. » Ce qui rend la programmation multithread effrayante, c’est qu’elle a l’air correcte au moment où on vient de l’écrire. Les bugs de condition de concurrence dépendent du timing : ils passent entre les mailles des tests et ne montrent le bout de leur nez qu’en production.
En même temps, maintenant que le multicœur est devenu la norme, il existe indéniablement des cas, même dans les applications métier, où le multithreading est incontournable — des exigences comme « faire tourner un traitement lourd sans figer 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 la série pratique sur le multithreading. Il s’adresse aux développeurs qui construisent des applications métier sous Windows et se trouvent dans le besoin d’ajouter du multithreading, et rassemble les principes de conception qui tiennent quel que soit le langage ou l’OS, avec les outils concrets de C#/.NET, d’après des sources primaires à la date d’août 2026. Les principes eux-mêmes ne changent pas sous Linux ni en C++. Si vous écrivez du code natif, voir « l’édition C++ » et « l’édition langage C », qui projettent les mêmes principes sur les outils de chaque langage ; si vous écrivez en Java, voir « l’édition Java ».
1. D’abord la conclusion
- La première bonne pratique est de ne pas créer de threads soi-même. Utilisez des API de plus haut niveau telles que Task, le pool de threads et Parallel plutôt que
new Thread, et laissez la gestion du nombre de threads au runtime.12 - La première chose à couper en parallélisant est « l’état mutable partagé ». Les endroits où plusieurs threads écrivent dans la même variable sont là où naissent les courses, donc avant de les protéger par des verrous, réduisez le partage lui-même par découpage, immuabilité et remise.3
- Donnez une discipline aux verrous. Décidez, un pour un, quel verrou protège quelles données, et faites de la cible du verrou un objet dédié non visible 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 remises de données entre threads par une file. Un arrangement producteur/consommateur bâti sur
System.Threading.Channelsou une collection concurrente est plus simple à concevoir que de semer des verrous partout, et il donne aussi une frontière claire.56 - Concevez d’abord comment ça s’arrête. L’annulation coopérative via
CancellationTokenest la seule bonne réponse pour l’arrêt, etThread.Abortlève une exception d’exécution sur .NET (la lignée Core).78 - L’UI appartient exclusivement au thread d’UI. Ni les contrôles WinForms ni les éléments WPF ne peuvent être touchés depuis un autre thread que celui qui les a créés. Depuis un autre thread, faites la demande via
Control.Invoke/Dispatcher.910 - « Parallèle veut dire plus rapide » ne tient pas toujours. Une boucle dont le travail par itération est petit finit plus lente à cause du surcoût de parallélisation. Mesurez toujours avant de l’adopter.3
Dans le diagramme, un trait continu marque une relation qui vaut toujours et un trait pointillé une relation conditionnelle (les conditions figurent dans l’explication de chaque relation sur la page de détail). La liste complète des relations (28 au total, avec preuve et niveau de certitude) et les définitions des concepts principaux sont rassemblées sur la page de détail de la carte des connaissances (en japonais). Données : JSON-LD / Turtle
2. Pourquoi le multithreading est difficile — conditions de concurrence et interblocages
Réduits à l’essentiel, le multithreading introduit deux sortes de problèmes.4
Une condition de concurrence (race condition) est un bug dont le résultat change selon l’ordre dans lequel plusieurs threads atteignent un morceau de code donné. L’exemple classique est l’incrément d’un compteur partagé : la ligne unique count++ se décompose en réalité en trois étapes, lecture, addition et réécriture. Lorsque deux threads exécutent ces trois étapes en même temps, l’addition de l’un est écrasée par la réécriture de l’autre et est perdue. Le résultat change à chaque exécution, et on ne peut pas prédire lequel on obtient.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: count = 11 alors qu'on a incrémenté deux fois<br/>l'addition du thread A a été perdue
Figure 1 : La condition de concurrence typique où une addition à un compteur partagé est perdue. Lorsqu’un autre thread s’intercale entre les trois étapes de count++, celui qui réécrit plus tard écrase l’autre
Un interblocage est un état dans lequel deux threads attendent chacun le verrou que l’autre détient, et aucun 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 seul les arrête tous les deux 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, chaque thread à l’intérieur de la boucle s’arrête pour toujours
Le point délicat, c’est que les deux dépendent du timing. Un entrelacement (une combinaison d’ordres d’exécution) qui n’apparaît qu’une fois sur des dizaines de milliers d’exécutions sur la machine de développement peut parfaitement arriver tous les jours sur une machine client avec un autre nombre de cœurs et un autre timing. « Ça ne se reproduit pas quand j’attache le débogueur » et « ça a disparu dès que j’ai ajouté des journaux » sont aussi des comportements typiques d’un bug de course, parce que l’observer change le timing.
C’est précisément pourquoi chaque principe qui suit pointe dans une seule direction. Avant de « synchroniser correctement », réduire les endroits qui ont besoin de synchronisation — c’est le principe de base de la conception multithread.
3. Principe 1 : ne pas créer de threads soi-même
3.1. S’appuyer sur Task et le pool de threads
Créer un thread directement avec new Thread(...) est un dernier recours exceptionnel dans le .NET d’aujourd’hui. Depuis .NET Framework 4, le moyen recommandé d’écrire du code multithread et parallèle est la TPL (Task Parallel Library), la famille d’API centrée sur Task. La TPL ajuste le degré de parallélisme dynamiquement aux processeurs disponibles et prend en charge toutes les corvées de bas niveau : découper le travail, l’ordonnancer sur le pool de threads, prendre en charge l’annulation et gérer l’état.1
Le pool de threads est une infrastructure que .NET lui-même utilise largement — exécuter des tâches, achever des E/S asynchrones, invoquer des callbacks de timer — et tant que vous mettez en file de courts morceaux de travail, vous n’avez pas à gérer vous-même les durées de vie des threads.2
// Faire tourner un travail CPU lourd en arrière-plan
var result = await Task.Run(() => HeavyCalculation(input));
// Faire tourner plusieurs opérations indépendantes en parallèle et toutes les attendre (quand il y en a peu)
// * Cette forme est pour le cas où ProcessAsync est une méthode asynchrone liée aux E/S.
// WhenAll ne fait qu'« attendre des Task déjà en cours », donc si vous voulez que du calcul
// CPU tourne en parallèle, enveloppez chacun dans Task.Run(() => Calc(x)) pour le mettre sur le pool
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));
// S'il y a beaucoup d'éléments, plafonner le nombre qui tourne à la fois en les poussant
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
async (x, ct) => await ProcessAsync(x, ct));
// Deux points : connecter le jeton de l'appelant à ParallelOptions
// (l'oublier et le ct du corps est toujours None), et passer ce ct aussi dans le corps (ne pas le jeter)
Il y a une mise en garde. Task.WhenAll(items.Select(...)) démarre le traitement de chaque élément d’un coup dès que la séquence est énumérée. Ce n’est pas un problème pour un ensemble fixe de quelques ou quelques dizaines de travaux, mais utilisé contre une grande collection cela épuise sockets, connexions de base de données et mémoire d’un seul coup. Pour un travail dont le nombre d’éléments n’est pas prévisible, plafonnez le nombre qui tourne à la fois comme dans le Parallel.ForEachAsync ci-dessus, ou contrôlez le débit avec le canal borné décrit plus loin.
Les threads à soi ne se justifient à peu près que dans les cas où une propriété du thread lui-même est l’exigence : il a sa propre boucle de messages, il a besoin d’un appartement de thread spécifique (STA), ou il continue de tourner pendant toute la durée de vie de l’application.
3.2. Le parallélisme de données va à Parallel.For / ForEach
Pour le parallélisme de données — « appliquer la même opération à chaque élément d’une collection et rendre le tout plus rapide » — utilisez Parallel.For / Parallel.ForEach plutôt que de remettre vous-même des morceaux de la boucle à des threads. La TPL gère le partitionnement de la source de données et le rééquilibrage de la charge, et une boucle de base n’a besoin d’aucun verrou.11
Il y a toutefois deux pièges que la documentation officielle énonce.3
- Ne supposez pas que parallèle est toujours plus rapide. Une boucle avec peu d’itérations, ou dont le travail par itération est léger, devient plus lente parce que le surcoût de parallélisation dépasse le travail lui-même. La performance dépend de nombreux facteurs, donc mesurez toujours avant de décider.
- Ne faites pas s’attendre les itérations mutuellement. Il n’y a aucune garantie que chaque itération de
Parallel.Fors’exécute réellement en parallèle. Du code où une itération attend qu’une autre positionne un événement s’interbloque, selon l’ordonnancement.
3.3. Envoyer le travail d’« attente » vers l’E/S asynchrone, pas vers des threads
Le travail qui attend surtout des E/S — fichiers, réseau, base de données — n’est pas un candidat pour plus de threads. Occuper un thread pendant qu’il attend est du pur gaspillage, et l’E/S asynchrone avec async/await ne consomme aucun thread pendant l’attente. Tracer cette distinction (paralléliser le travail lié au CPU, rendre asynchrone le travail lié aux E/S) est la première ligne à tracer à l’entrée de la conception multithread.
flowchart TB
S["Il y a du travail à exécuter en parallèle"] --> Q1{"Que fait surtout le travail ?"}
Q1 -->|"Surtout de l'attente d'E/S<br/>fichiers, réseau, base de données"| ASYNC["E/S asynchrone avec async/await<br/>ne pas ajouter de threads"]
Q1 -->|"Calcul lié au CPU"| Q2{"Quelle est la forme du travail ?"}
Q2 -->|"Appliquer la même opération à<br/>chaque élément d'une collection"| PAR["Parallel.For / ForEach"]
Q2 -->|"Un morceau indépendant de<br/>traitement en arrière-plan"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"Une propriété du thread lui-même est requise<br/>telle qu'une boucle de messages ou STA"| TH["new Thread<br/>(dernier recours exceptionnel)"]
Figure 3 : La branche à prendre avant de « lancer un thread ». Presque tout le traitement métier atterrit dans l’une des trois sorties du haut, et seuls des cas exceptionnels atteignent new Thread
Les jugements pratiques autour d’async/await sont traités dans « Tableau de décision pratique pour C# async/await », et la façon dont le pool de threads et l’E/S asynchrone se connectent en dessous est détaillée dans « Les profondeurs de l’E/S Windows (partie 3) — ports de completion d’E/S (IOCP) et pool de threads .NET ».
4. Principe 2 : minimiser l’état mutable partagé
Une course n’arrive que lorsque « plusieurs threads » et « des données mutables partagées » se réunissent. Le nombre de threads est dicté par les exigences, donc ce que la conception peut couper, c’est le partage. Il y a trois moyens.
4.1. Découper — chaque thread ne touche que ses propres données
Le geste le plus simple et le plus puissant est de répartir les données par thread. Pour une agrégation dans une boucle parallèle, au lieu d’écrire dans un total partagé à chaque passage, utilisez la surcharge de Parallel.For qui vous remet un état local au thread, de sorte que chaque thread construise un sous-total localement et le fusionne une seule fois à la fin. Les écritures vers la valeur partagée passent de « une fois par itération » à « une fois par thread », et le coût de synchronisation comme la fenêtre de course se réduisent d’ordres de grandeur.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'additionne que dans son propre local
local => Interlocked.Add(ref total, local)); // la fusion a lieu une fois par thread
flowchart TB
SRC["Tableau de données (le travail à traiter)"] --> T1["Thread 1<br/>traite sa part et<br/>n'additionne que dans son sous-total local"]
SRC --> T2["Thread 2<br/>traite sa part et<br/>n'additionne que dans son sous-total local"]
SRC --> T3["Thread 3<br/>traite sa part et<br/>n'additionne que dans son sous-total local"]
T1 --> M["Fusion : Interlocked.Add replie dans<br/>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, donc il n’y a pas de place pour une course, et les écritures vers la valeur partagée n’ont lieu qu’une fois par thread à la fusion
4.2. Rendre immuable — ce qui n’est jamais réécrit est sûr à partager
Les données qui ne sont jamais que lues sont sûres à lire depuis n’importe quel nombre de threads à la fois. Les valeurs de configuration, les données de référence et les entrées d’un calcul peuvent se partager librement sans synchronisation en ne les réécrivant jamais après construction, c’est-à-dire en les rendant immuables. En C#, les types record et les propriétés init soutiennent cette conception. Décider simplement que « lorsqu’un changement est nécessaire, on ne réécrit pas — on construit une nouvelle instance et on la permute » retire encore un morceau d’état mutable à protéger.
Mais « a l’air en lecture seule » et « est immuable » sont deux choses distinctes. Une interface en lecture seule telle que IReadOnlyList<T> signifie seulement « on ne peut pas réécrire via cette interface » ; elle n’empêche pas la List<T> derrière d’être réécrite via une autre référence. Les garanties de record / init sont aussi superficielles et ne protègent pas les objets qu’une propriété désigne. Pour des données que vous voulez vraiment partager en sécurité entre threads, utilisez une collection immuable de System.Collections.Immutable telle que ImmutableArray<T>, ou remettez une copie au moment du partage, coupant le chemin d’écriture lui-même. Cela tient à la condition que le type d’élément T soit lui-même immuable. Une collection immuable ne protège que « l’ordre » ; les références vers des objets éléments mutables sont encore partagées telles quelles, donc si le contenu d’un élément peut être réécrit par un autre chemin, la course demeure. Rendez le graphe d’objets immuable jusqu’à ses feuilles, ou remettez une copie profonde.
4.3. Remettre — envoyer via une file au lieu de partager
Même ainsi, des données doivent circuler entre threads. Dans ce cas, plutôt que de « toucher une variable partagée des deux côtés », utilisez un arrangement producteur/consommateur qui place une file au milieu, qu’un côté écrit et l’autre lit.
Le premier choix en .NET est System.Threading.Channels. C’est un FIFO dans lequel le producteur écrit des données de façon asynchrone et le consommateur les lit de façon asynchrone, et le canal gère toutes les corvées de synchronisation.5
var channel = Channel.CreateBounded<WorkItem>(100); // capacité 100, qui applique une backpressure
// Côté producteur
await channel.Writer.WriteAsync(item, ct); // s'il est plein, attendre qu'une place se libère
// ...une fois que chaque producteur a fini d'écrire :
channel.Writer.Complete(); // déclarer que « plus rien ne vient ». 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/>la synchronisation est 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 -.->|"s'il est plein, faire attendre l'écriture<br/>(backpressure)"| P1
CH -.->|"s'il est vide, faire attendre la lecture"| C1
Figure 5 : Arrangement producteur/consommateur avec un canal au milieu. Les deux côtés ne touchent pas directement de variable partagée, et le rendez-vous comme le contrôle de capacité sont laissés au canal
En pratique, l’important est de choisir un canal à capacité limitée (bounded). Le comportement par défaut une fois la limite atteinte est « le côté écriture attend qu’une place se libère », ce qui devient une backpressure naturelle. Dans une configuration où la production est plus rapide que la consommation, une file sans limite devient une bombe à retardement : ça tourne, mais la mémoire ne cesse de croître.5
Dans le monde synchrone, le même rôle qu’un canal borné est tenu par un BlockingCollection<T> auquel on a donné une capacité. Il empêche le producteur de trop distancer le consommateur grâce à la limite de capacité, et bloque le consommateur lorsqu’il est vide. 12 En revanche ConcurrentQueue<T> / ConcurrentStack<T> sont des collections rapides rendues thread-safe par des opérations Interlocked sans verrou,6 mais elles n’ont ni limite de capacité ni mécanisme « attendre quand c’est vide » : ce sont de simples files thread-safe. Voyez-les comme des pièces, pas comme le protagoniste de la remise de travail. Notez que BlockingCollection<T> n’est pas conçu pour un accès asynchrone, donc si vous combinez avec async/await, choisissez Channel<T>.12
Notez aussi la croyance « j’ai remplacé le dictionnaire par ConcurrentDictionary, donc c’est thread-safe ». Même si chaque opération individuelle est thread-safe, une opération composée telle que « vérifier l’existence puis ajouter » reste en course (utilisez les méthodes prévues pour les opérations composées, comme GetOrAdd). GetOrAdd a aussi un mais : la valeur stockée est unique, mais la fonction de fabrique qui produit la valeur peut être appelée plusieurs fois en cas de contention. Mettre des effets de bord dans la fabrique (ouvrir une connexion, créer un fichier, etc.) fuit par exécution dupliquée, donc faites-en une fonction sans effet de bord, ou, pour une initialisation qui doit n’avoir lieu qu’une fois, stockez un Lazy<T> comme valeur. Changer le type de collection ne remplace pas la réduction de l’état mutable partagé.
5. Principe 3 : donner une discipline aux verrous
Même en réduisant l’état mutable partagé, on ne peut souvent pas le ramener à zéro. Pour le partage qui reste, on utilise l’exclusion mutuelle (verrous), mais un verrou n’est pas un outil pour « entourer de lock tout ce qui a l’air louche ». Il y a quatre disciplines.
5.1. Décider « ce que l’on protège », et verrouiller avec un objet dédié
L’unité du verrou se pense en « données », pas en « intervalle de code ». Faites correspondre un objet de verrouillage à chaque ensemble de données mutables à protéger, et prenez ce même verrou partout où ces données sont touchées — c’est lorsque ce tableau de correspondance se brise que les bugs de course existent.
L’objet cible du verrou est une instance dédiée, non exposée à l’extérieur. lock(this) partage le verrou avec tout code externe qui peut référencer votre instance, lock(typeof(X)) avec tout le domaine d’application : chacun est un terreau d’interblocage. À partir de .NET 9 / C# 13, la recommandation est d’utiliser une instance du type dédié System.Threading.Lock comme objet de verrouillage.4
public class OrderBook
{
private readonly Lock _gate = new(); // .NET 9+ (auparavant readonly object)
private readonly List<Order> _orders = []; // données que _gate protège
public void Add(Order order)
{
lock (_gate) { _orders.Add(order); }
}
}
L’instruction lock de C# garantit que le verrou est libéré même en cas d’exception. La façon dont elle se déplie dépend du type de l’objet de verrouillage : un objet ordinaire appelle Monitor.Exit dans un finally, un type Lock utilise EnterScope() et sa destruction.413 Autrement dit, un champ de type Lock n’est pas le même mécanisme que Monitor, et si une partie du code écrit à la main Monitor.Enter(_gate), l’exclusion mutuelle avec lock (_gate) ne tient plus. Quel que soit le type, abandonnez Monitor.Enter / Exit écrits à la main et unifiez toujours sur la syntaxe lock.4
5.2. Ne pas faire « ce qui prend du temps » ni « ce qui est externe » pendant qu’on tient un verrou
Plus le temps de détention d’un verrou est court, mieux c’est, et ce que l’on a le droit de faire pendant qu’on le tient, c’est seulement la lecture et l’écriture des données qu’il protège. Faire de l’E/S tout en tenant le verrou, appeler du code externe via un événement ou un callback : ces écritures allongent le temps de détention et créent des chemins où l’appelé tente de prendre un autre verrou, d’où un interblocage. La forme de base est de préparer hors du verrou, et de ne faire que permuter à l’intérieur.
Notez que l’on ne peut pas await à l’intérieur d’un lock (cela devient une erreur de compilation). Ce n’est pas une restriction, c’est une protection : Monitor a une affinité de thread, le thread qui a pris le verrou doit le libérer, ce qui ne cohabite pas avec du code asynchrone où le thread peut changer de part et d’autre d’un await. Pour l’exclusion en 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. Plusieurs verrous, toujours dans le même ordre
Lorsque deux verrous ou plus existent, l’inversion de l’ordre d’acquisition selon les threads est le schéma classique d’interblocage. La contre-mesure est simple : faire de « tous les threads prennent les verrous dans le même ordre » une règle. Là où l’ordre ne peut pas être garanti, utilisez la surcharge temporisée de Monitor.TryEnter : si le verrou ne se prend pas, relâchez et recommencez (ou enregistrez l’anomalie), ce qui transforme un hang éternel en un échec détectable.4
5.4. Interlocked pour une mise à jour simple, ReaderWriterLockSlim si les lectures dominent
Les mises à jour atomiques d’une variable unique, comme l’incrément/décrément d’un compteur ou le basculement d’un drapeau, sont plus rapides avec la classe Interlocked (Increment / Add / CompareExchange) qu’avec lock. Sans contention, cela se résume à un préfixe d’instruction CPU unique.4 Inversement, Interlocked s’arrête là : on ne peut pas s’en servir pour rendre plusieurs variables cohérentes ensemble. Une structure lock-free maison combinée à volatile est un outil d’expert qui exige une compréhension profonde du modèle mémoire, et ce n’est pas ce qu’il faut écrire dans une application métier.
Pour des données partagées « souvent lues, rarement écrites », il existe aussi ReaderWriterLockSlim, qui n’exclut que les écritures et laisse les lectures passer en même temps.13
6. Principe 4 : concevoir d’abord comment arrêter
La première question à poser dans une revue de conception multithread est « comment est-ce que ça s’arrête ? ». On peut écrire du code qui démarre, mais du code qui s’arrête en 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 unifié autour de l’annulation coopérative. Le côté qui arrête crée un CancellationTokenSource et passe son Token à chaque traitement. Quand on veut arrêter, on appelle Cancel(). Le côté traitement surveille le jeton et, à un moment commode pour lui, fait le ménage et se termine — c’est de la coopération, pas de la force, donc 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 }) // refuser un second Start pendant l'exécution
throw new InvalidOperationException("Le travailleur 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 travailleur précédent a échoué.", _worker.Exception);
_cts = new CancellationTokenSource();
var token = _cts.Token; // capturer d'abord en local pour ne pas entrer en course avec un reStart après arrêt
_worker = Task.Run(() => WorkLoop(token), token);
}
private void WorkLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // surveiller par polling
{
ProcessNextItem(ct); // passer ct aux appels bloquants pour une interruption immédiate
}
}
public async Task StopAsync()
{
var cts = _cts; // pendant l'attente, même si le champ est permuté,
var worker = _worker; // figer en local la cible à arrêter pour ne pas la confondre
if (cts is null || worker is null) return;
Exception? cancelFailure = null;
try { cts.Cancel(); } // un callback enregistré sur le jeton peut lever
catch (Exception ex) { cancelFailure = ex; } // le retenir et le rapporter après la jonction
try
{
try { await worker; } // achever la jonction que Cancel ait réussi ou non, et observer tout échec en chemin
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // ne traiter comme « normal » que l'arrêt que nous avons demandé
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // ne perdre aucun des deux échecs
}
}
finally
{
cts.Dispose(); // disposer la source une fois la jonction faite (libère des ressources OS telles que WaitHandle).
if (ReferenceEquals(_cts, cts))
{
_cts = null; // ne pas laisser un StopAsync ultérieur utiliser une source disposée
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
Notez que ce couple Start / StopAsync est un arrangement minimal qui suppose d’être appelé en séquence depuis un seul thread, tel que le thread d’UI. Si plusieurs threads peuvent opérer le cycle de vie en même temps, sérialisez Start / StopAsync eux-mêmes avec quelque chose comme un SemaphoreSlim — laisser les opérations de gestion du travailleur entrer en course entre elles avant même d’avoir protégé le travailleur va à l’encontre du but.
Ce petit exemple contient aussi des touches qui paient en pratique. Pour commencer, Start refuse un appel en double pendant que le travailleur tourne. Écraser _cts et _worker sans condition perd la référence vers le travailleur précédent, et un thread égaré que l’on ne peut ni arrêter ni joindre tourne à côté. La règle de base des API de cycle de vie (Start/Stop) est d’imposer soi-même « un à la fois ». Trois points de plus. Premier, l’API d’arrêt attend l’achèvement. Cancel() ne fait que « demander » l’annulation ; au moment où elle revient, le travailleur peut encore être au milieu de ProcessNextItem. En faire un Stop() qui ne fait que demander et revenir crée une nouvelle course, où le travailleur tourne encore lorsque l’appelant commence le ménage. Deuxième, ne jetez pas le Task — tenez-le. Le jeter avec _ = Task.Run(...) et personne ne remarque lorsque le travailleur meurt d’une exception. Troisième, capturez le jeton dans une variable locale avant de le passer, plutôt que de référencer _cts.Token à l’intérieur de la lambda. Une référence à l’intérieur de la lambda s’évalue à l’exécution, donc si un redémarrage a lieu juste après un arrêt, l’ancien travailleur peut saisir le nouveau jeton par erreur. Passer ce même jeton aussi comme second argument de Task.Run signifie que lorsque l’opération se termine par ThrowIfCancellationRequested ou une OperationCanceledException d’une API consciente de l’annulation, le Task est classé « Canceled » plutôt que « Faulted » (dans cet exemple, où la boucle sort simplement sur sa condition, cela compte comme un achèvement normal). Encore une chose : le catch de StopAsync utilise un filtre when pour ne retenir que les annulations qui proviennent de son propre jeton. Avaler OperationCanceledException sans condition ferait passer un véritable échec levé par un autre jeton à l’intérieur de l’opération, tel qu’un délai par élément, pour « on s’est arrêté, donc c’est normal ». Notez que cette identification par égalité de jeton se brise si WorkLoop utilise un jeton lié en interne (la composition liée du 6.1), parce que l’exception qui arrive porte le jeton lié. Dans cet arrangement, soit appelez ct.ThrowIfCancellationRequested() à la sortie de WorkLoop pour le « traduire » vers le jeton extérieur avant de partir, soit assouplissez le filtre en when (cts.IsCancellationRequested) et acceptez que « une annulation pendant qu’un arrêt est demandé est normale » — choisissez l’un ou l’autre explicitement, comme décision de conception.
flowchart TB
OWNER["Côté qui arrête le travail"] -->|"appelle Cancel() une fois"| CTS["CancellationTokenSource"]
CTS -->|"remet le Token"| W1["Opération travailleur 1"]
CTS -->|"remet le Token"| W2["Opération travailleur 2"]
CTS -->|"remet le Token"| W3["Une API de bibliothèque<br/>consciente de l'annulation"]
W1 -->|"vérifie IsCancellationRequested<br/>fait le ménage et se termine lui-même"| E1["Achèvement normal"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= traité comme annulation achevée"]
W3 -->|"s'interrompt immédiatement même en attendant"| E3["Annulation achevée"]
Figure 6 : La structure de l’annulation coopérative. Le côté qui arrête n’appelle que Cancel(), et chaque opération décide elle-même quand et comment elle se termine. C’est pourquoi elle peut s’arrêter en conservant un état cohérent
Il y a aussi des conventions établies du côté bibliothèque. Une opération annulable doit offrir des méthodes publiques qui prennent un CancellationToken, et une boucle de calcul doit vérifier IsCancellationRequested périodiquement ou appeler ThrowIfCancellationRequested(). Ce dernier lève une OperationCanceledException, que Task traite comme « annulation achevée » plutôt que « échec ». Lorsque vous voulez arrêter à la fois sur un jeton fourni de l’extérieur et une condition interne telle qu’un délai, composez-les avec un jeton lié.7
6.2. Traiter Thread.Abort comme quelque chose qui n’existe pas
Thread.Abort, la façon de « tuer un thread de l’extérieur quand il n’écoute pas », ne fait plus que lever une PlatformNotSupportedException sur .NET Core / .NET 5 et suivants, et ne peut plus s’utiliser. Jeter une exception dans un thread sans savoir où il s’exécute invite à une libération de ressources interrompue et à un état corrompu. S’il faut forcer la terminaison de code tiers qui ne répond pas à l’annulation coopérative (et ne peut pas être écrit pour y répondre), la consigne officielle est de l’exécuter dans un processus séparé et de l’arrêter avec Process.Kill.8
6.3. Quand on attend, utiliser un handle d’attente plutôt que du polling
Écrire une « boucle sur Sleep(100) jusqu’à ce que le drapeau se lève » gaspille à la fois le CPU et la réactivité. Pour signaler entre threads, il existe des primitives de synchronisation telles que ManualResetEventSlim et SemaphoreSlim, qui mettent un thread correctement en sommeil jusqu’à ce qu’il soit signalé.13 La précision des timers sous Windows et quand utiliser une attente d’événement à la place sont détaillées dans « Pourquoi préférer les attentes d’événement à Sleep(1) sous Windows ».
7. Les circonstances particulières du thread d’UI — les règles des applications de bureau Windows
En plus des principes généraux, les applications de bureau Windows ont une contrainte forte de plus : la règle selon laquelle l’UI ne peut être touchée que par le thread qui l’a créée, le thread d’UI.
Les contrôles WinForms ne sont pas thread-safe, et les opérer depuis plusieurs threads pousse un contrôle dans un état incohérent et devient une cause de courses, d’interblocages et de gels. Windows exige qu’une application fournisse un thread dédié qui reçoit les messages système, et la création et l’opération de l’UI doivent être concentrées sur ce thread.9 WPF a exactement la même structure : seul le thread d’UI peut modifier les éléments d’UI.10
Lorsque vous voulez mettre à jour l’UI depuis un autre thread, convertissez-le en « une demande au thread d’UI » plutôt que de toucher l’UI directement.
flowchart LR
OS["Windows<br/>souris, clavier, redessin"] --> Q["La file de messages<br/>du thread d'UI"]
BG["Thread d'arrière-plan<br/>(traitement lourd, communication)"] -->|"le demander avec Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["Thread d'UI<br/>le seul thread qui peut toucher les contrôles"]
BG -.->|"toucher un contrôle directement"| NG["Interdit<br/>cause de courses, d'interblocages, de gels"]
Figure 7 : Les mises à jour d’UI se convertissent en « demandes ». Le travail du thread d’arrière-plan s’arrête à faire mettre le travail dans la file de messages, et celui qui touche les contrôles est toujours le thread d’UI lui-même
| Framework | Comment 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 celles-ci, les formes synchrones (Control.Invoke / Dispatcher.Invoke) demandent de l’attention. Si le travailleur appelle Invoke pendant que le thread d’UI attend de façon synchrone que ce travailleur se termine, on obtient un interblocage où chacun attend l’autre (exactement l’attente circulaire du chapitre 2). Faites des formes asynchrones (BeginInvoke / InvokeAsync) le défaut pour les notifications et les rapports de progression depuis l’arrière-plan, et limitez les formes synchrones aux situations où vous pouvez affirmer avec certitude que le thread d’UI n’attend pas après vous.
En pratique, il y a une meilleure réponse un cran plus loin. Si vous écrivez du travail démarré sur le thread d’UI avec async/await, await capture le SynchronizationContext du thread d’UI et reprend la continuation sur le thread d’UI automatiquement, ce qui réduit fortement les occasions d’écrire Invoke à la main. Ce n’est toutefois pas une propriété inconditionnelle. Le code entré depuis un callback d’arrière-plan, et les continuations après un ConfigureAwait(false), ne reviennent pas au thread d’UI, donc une répartition explicite reste nécessaire si vous touchez l’UI sur ces chemins. S’arrêter sur la forme « le travail lourd va à Task.Run ou à l’E/S asynchrone, et refléter le résultat à l’écran se fait dans la continuation après await » est la forme de base d’une application Windows moderne. La relation entre le thread d’UI et async/await est exposée sur une seule feuille dans « async WPF/WinForms et le thread d’UI en une feuille ».
De plus, lorsque COM est impliqué — intégration Office, composants héritage — une autre couche s’ajoute sous la forme du modèle de threads propre à COM (STA/MTA). La casse de « nous avons créé un objet COM sur le thread d’UI mais l’avons appelé depuis un autre thread et ça a figé » appartient à cette couche, et s’explique dans « Fondamentaux COM STA/MTA ».
8. Si vous écrivez du code natif (C++/C)
Les principes jusqu’ici — ne pas créer de threads directement, réduire l’état mutable partagé, tenir les verrous avec discipline, concevoir comment ça s’arrête — se reportent inchangés au code natif. Ce qui change, ce sont les outils. En C++, les pendants sont le RAII avec std::jthread / std::mutex / std::atomic ; en C, ce sont les API Win32 _beginthreadex, les verrous SRW, les variables de condition et le schéma d’événement d’arrêt. Chacun est traité dans « l’édition C++ » et « l’édition langage C » de cette série, jusqu’aux pièges propres au langage tels que le destructeur de std::thread, le danger de TerminateThread, et DllMain et le verrou du chargeur.
9. Vérification et débogage — se préparer en supposant que ça ne se reproduira pas
On ne peut pas attendre des tests qu’ils trouvent les bugs de multithreading, parce qu’un test unitaire ordinaire compte comme succès une exécution qui « s’est trouvée à ne pas avoir de course ». Pensez votre préparation en trois couches.
La première ligne de défense, ce sont les principes de conception eux-mêmes. Une application avec 5 morceaux d’état mutable partagé et une avec 50 diffèrent d’un facteur dix dans le nombre d’endroits à soupçonner. En revue, utilisez un tableau pour confirmer « quelles données mutables sont partagées », « quel verrou protège chacune », « l’ordre d’acquisition des verrous est-il unique », et « où est le chemin d’arrêt ». Une conception pour laquelle ce tableau ne peut pas s’écrire n’est pas encore achevée, même si elle tourne.
Deuxième, rendre les anomalies observables plutôt que de les cacher. Détectez les attentes de verrou anormales avec un délai Monitor.TryEnter et journalisez-les,4 enregistrez plutôt qu’étouffer les exceptions non observées du travail mis en file dans le pool de threads, et assurez-vous de pouvoir prendre un dump complet sur un hang et inspecter les piles de chaque thread — la lutte contre un bug qui « n’arrive que de temps en temps » se décide par la quantité d’information que l’on peut tirer de la fois où il arrive. Mettre en place dumps et journaux est traité dans « Concevoir les applications Windows pour laisser des journaux et des dumps en cas de plantage ».
Troisième, secouer sous charge. Tourner longtemps à un degré de parallélisme supérieur au nombre de cœurs, randomiser l’ordre de traitement et injecter des délais artificiels sont des moyens réalistes de rendre un entrelacement malchanceux plus probable sur la machine de développement et de faire sortir les courses avant la livraison. Un bug qui disparaît sous une exécution de débogage se reproduit souvent en compilation release sous forte charge.
10. Synthèse — une liste de contrôle avant d’ajouter des threads
Réduites à l’essentiel, les bonnes pratiques de la programmation multithread ne sont pas « l’art d’écrire correctement la synchronisation » mais « une conception qui évite d’avoir à écrire de la synchronisation ». Si vous pouvez répondre aux huit questions suivantes avant de commencer, vous pouvez empêcher presque toutes les casses graves.
- Ce travail est-il lié au CPU ou aux E/S (si c’est le second, la réponse est async/await plutôt que des threads)
- Êtes-vous sur le point d’écrire
new Thread(ne peut-il pas s’exprimer avec Task,Parallelou le pool de threads) - Quelles données mutables sont partagées entre threads, et pouvez-vous les énumérer
- Ce partage peut-il être éliminé par « découpage », « immuabilité » ou « remise via une file »
- Chaque morceau restant de données partagées a-t-il exactement un verrou qui lui est assigné
- L’ordre d’acquisition des verrous est-il le même sur chaque thread, et êtes-vous libre d’appels extérieurs pendant que vous tenez un verrou
- Un
CancellationTokenest-il passé à chaque opération de longue durée, et pouvez-vous expliquer le chemin d’arrêt - Le code qui touche l’UI est-il concentré sur le thread d’UI
Les bugs de multithreading n’apparaissent pas le jour où on les écrit ; ils montrent les dents chez le client une fois qu’on les a oubliés. Dit autrement, faire passer cette liste de contrôle au stade de la conception signifie que l’on peut extraire la classe d’échec la plus coûteuse — « ça plante de temps en temps », « ça fige une fois par mois » — avant d’écrire le code.
Articles associés
- Bonnes pratiques du multithreading : édition C++
- Bonnes pratiques du multithreading : édition langage C
- Bonnes pratiques du multithreading : édition Java
- Tableau de décision pratique pour C# async/await - Task.Run et ConfigureAwait
- async WPF/WinForms et le thread d’UI en une feuille
- Les profondeurs de l’E/S Windows (partie 3) — ports de completion d’E/S (IOCP) et pool de threads .NET
- Fondamentaux COM STA/MTA - modèle de threads et façon d’éviter les hangs
- Pourquoi préférer les attentes d’événement à Sleep(1) sous Windows
- Pièges de la mémoire partagée et bonnes pratiques
Domaines de conseil associés
KomuraSoft LLC assure des revues de conception d’applications métier qui incluent leur passage au multithread, l’investigation de cause de défaillances difficiles à reproduire telles que « ça plante ou ça fige de temps en temps » (analyse de dumps, localisation du code en course), et le conseil technique sur la parallélisation et le passage à l’asynchrone d’applications existantes. Il est tout à fait possible de venir au stade de « j’aimerais que quelqu’un regarde si cette conception peut entrer en course ».
- Conseil technique et revue de conception
- Investigation de bugs et analyse de cause
- Développement d’applications Windows
- Nous contacter
Références
-
Microsoft Learn, Task Parallel Library (TPL). Sur le fait que la TPL est le moyen recommandé d’écrire du code multithread et parallèle à partir de .NET Framework 4 ; qu’elle ajuste le degré de parallélisme dynamiquement aux processeurs disponibles ; qu’elle prend en charge le découpage du travail, l’ordonnancement sur le pool de threads, la prise en charge de l’annulation et la gestion d’état ; qu’une boucle dont le travail par itération est petit peut devenir plus lente à cause du surcoût de parallélisation ; et qu’une compréhension de base des verrous, des interblocages et des conditions de concurrence est 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 travailleurs géré par le système pour que les développeurs se concentrent sur les tâches de l’application plutôt que sur la gestion des threads, et que .NET utilise le pool de threads largement, pour les opérations TPL, les achèvements d’E/S asynchrones, les callbacks de timer, les attentes enregistrées et les connexions de socket. ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. Sur le fait qu’une boucle parallèle est parfois plus lente qu’une boucle séquentielle, si bien que la mesure est toujours requise ; sur le fait d’éviter les écritures vers la mémoire partagée à l’intérieur d’une boucle parallèle, les surcharges qui prennent un état local au thread étant recommandées à la place ; et sur le fait qu’il n’y a aucune garantie que chaque itération de For/ForEach s’exécute en parallèle, si bien que du code qui attend entre itérations peut s’interbloquer. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices. Sur les définitions d’une condition de concurrence (avec l’exemple d’un incrément de compteur qui se décompose en lecture, addition et réécriture, si bien que l’une est perdue par écrasement) et d’un interblocage ; sur l’usage de l’annulation coopérative plutôt que Thread.Abort ; sur le fait de ne jamais verrouiller un type ni this, et d’utiliser une instance du System.Threading.Lock dédié à partir de .NET 9 / C# 13 ; sur le fait que l’instruction lock de C# garantit Monitor.Exit dans un finally ; sur la détection d’interblocage par un délai Monitor.TryEnter ; sur le fait que la classe Interlocked est plus rapide pour les changements d’état simples ; et sur la consigne de conception de rendre les données statiques thread-safe par défaut et les données d’instance non thread-safe 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 un FIFO pour le modèle producteur/consommateur qui gère la synchronisation en interne ; que CreateBounded crée un canal avec une limite de capacité ; que le comportement par défaut lorsque la limite est atteinte est que l’écrivain attende (Wait), d’autres valeurs FullMode telles que DropOldest étant aussi disponibles ; et que de la backpressure s’applique lorsque l’écriture dépasse la lecture. ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. Sur le fait que les collections sous System.Collections.Concurrent atteignent la sûreté de thread par un verrouillage à grain fin ou des mécanismes lock-free, et que ConcurrentQueue et ConcurrentStack sont implémentés avec des opérations Interlocked plutôt que des verrous, si bien qu’ils supportent des ajouts et suppressions fréquents depuis plusieurs threads. ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. Sur les étapes du modèle d’annulation coopérative bâti sur CancellationTokenSource et CancellationToken ; sur le fait que l’annulation est coopérative plutôt que forcée, l’écouteur décidant comment il s’arrête ; sur les trois façons de la surveiller, polling, enregistrement d’un callback, et handle d’attente ; sur le fait que Task traite l’OperationCanceledException levée par ThrowIfCancellationRequested comme annulation achevée ; sur la composition de plusieurs jetons avec un jeton lié ; et sur le fait que les bibliothèques sont censées offrir des méthodes publiques qui prennent un CancellationToken. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. Sur le fait que CancellationToken est ce qu’il faut utiliser pour arrêter un thread ; que Thread.Abort lève PlatformNotSupportedException sur .NET Core et .NET 5 et suivants, et produit aussi un avertissement d’obsolescence à la compilation (SYSLIB0006) à partir de .NET 5 ; et que le code tiers qui ne répond pas à l’annulation coopérative doit s’exécuter dans un processus séparé et s’arrêter avec 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, si bien que les opérer depuis plusieurs threads mène à un état incohérent, des courses, des interblocages et des gels ; que chaque contrôle doit être créé et accédé sur le même thread, et que Windows exige un thread d’UI dédié auquel il distribue les messages système ; et sur le fait de faire des appels sûrs depuis un autre thread avec Control.Invoke, avec Control.InvokeAsync à partir de .NET 9, ou avec BackgroundWorker. ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). Sur le fait que WPF limite la modification de l’UI à un seul thread ; qu’un thread d’arrière-plan fait sa demande en enregistrant des work items auprès du Dispatcher du thread d’UI ; que Dispatcher.Invoke est synchrone tandis que InvokeAsync et BeginInvoke sont asynchrones ; et que le Dispatcher traite le travail via une file à priorités. ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). Sur le fait que Parallel.For / Parallel.ForEach fournissent un parallélisme de données avec à peu près le même ressenti qu’écrire une boucle for ; qu’il n’est pas besoin de créer des threads ni de mettre des work items en file, ni de verrous dans une boucle de base ; et que la TPL partitionne la source de données, la traite sur plusieurs threads, et redistribue la charge si elle devient inégale. ↩
-
Microsoft Learn, BlockingCollection<T> Class. Sur le fait que BlockingCollection est une implémentation producteur/consommateur avec blocage et une limite de capacité ; que la limite de capacité empêche le producteur de trop distancer le consommateur ; et qu’elle n’est pas conçue pour un accès asynchrone, Channel<T> étant recommandé à considérer pour un producteur/consommateur asynchrone. ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives. Sur le fait que Monitor fournit l’exclusion mutuelle via l’objet verrouillé et a une affinité de thread ; que le code C# utilise l’instruction lock plutôt que Monitor directement ; que ReaderWriterLockSlim rend les écritures exclusives tout en permettant des lectures concurrentes ; et que SemaphoreSlim est un sémaphore léger pour un usage intra-processus, tandis que Semaphore peut être nommé et servir à la synchronisation inter-processus. ↩ ↩2 ↩3
-
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. Sur le fait que l’instruction lock de C# et le type Lock ont une affinité de thread et ne peuvent donc pas s’utiliser de part et d’autre d’un await (parce que le thread qui exécute la continuation peut changer de part et d’autre de l’await) ; sur l’usage d’un SemaphoreSlim de compte 1 via WaitAsync et un Release dans un try/finally pour l’exclusion mutuelle en code asynchrone ; et sur le fait qu’un Channel borné est une alternative pour le throttling. ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Bonnes pratiques du multithreading : édition C++ — éliminer les accidents avec RAII et jthread
En C++, une course de données est un comportement indéfini. Piège du destructeur de std::thread, arrêt avec jthread et stop_token, scoped...
Réveils parasites — pourquoi une variable de condition se réveille « sans notification » et comment attendre correctement sous Windows
Le wait d'une variable de condition peut revenir sans notification (réveil parasite). L'article explique, d'après l'implémentation Window...
Bonnes pratiques du multithreading : édition Java — les usages à l'ère des threads virtuels
En Java, la pratique établie consiste à ne pas créer de threads directement, mais à s'appuyer sur ExecutorService et les threads virtuels...
Bonnes pratiques du multithreading : édition C — écrire en sécurité à la manière Win32
En C sur Win32, la pratique établie est _beginthreadex, les verrous SRW et les variables de condition, Interlocked, et un événement d'arr...
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.