Multithreading et concurrence
Thread, lock, Interlocked, collections concurrentes.
Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 16 min
Dès que deux threads touchent la même donnée et que l'un d'eux écrit, le résultat dépend d'un entrelacement que personne ne choisit. Le multithreading sert à occuper plusieurs cœurs, ou à laisser un thread libre pendant qu'un autre calcule ; la concurrence est ce qu'on paie en échange : chaque donnée partagée doit être protégée, et chaque outil de protection garantit moins qu'on ne le croit. Ce cours part d'un compteur faux, le corrige, puis passe aux pièges qui survivent à la correction. L'asynchronisme, qui répond à une autre question — ne pas immobiliser de thread pendant une attente —, a son propre cours,
Thread et pool de threads
Un Thread est un thread du système d'exploitation : une pile à lui, un ordonnancement décidé par le noyau. En créer un coûte une réservation de pile et un appel système, et c'est pourquoi .NET tient un pool de threads déjà créés, que Task.Run, les minuteurs et les continuations d'await empruntent puis rendent.
La différence la plus visible est le premier plan. Un thread créé par new Thread est de premier plan : le processus attend sa fin avant de s'arrêter. Ceux du pool sont d'arrière-plan : le processus s'arrête sans eux, en les coupant où ils en sont. Join bloque l'appelant jusqu'à la fin d'un thread.
using System;
using System.Threading;
using System.Threading.Tasks;
static class Fils
{
static void Decrire(string origine)
{
var courant = Thread.CurrentThread;
Console.WriteLine(
$"{origine,-12} pool={courant.IsThreadPoolThread,-5} arriere-plan={courant.IsBackground}");
}
public static void Executer()
{
Decrire("principal");
// Un Thread cree a la main : une pile a lui, et le premier plan par
// defaut. Le processus ne s'arrete pas tant qu'il en reste un vivant.
var dedie = new Thread(() => Decrire("new Thread")) { Name = "export-nocturne" };
dedie.Start();
dedie.Join(); // bloque l'appelant jusqu'a la fin du thread
// Task.Run emprunte un thread du pool : deja cree, rendu apres usage,
// toujours en arriere-plan. C'est le cas courant.
Task.Run(() => Decrire("Task.Run")).Wait();
// LongRunning demande un thread a part, hors du pool : une boucle de
// plusieurs minutes n'y immobilise aucun thread partage.
Task.Factory.StartNew(() => Decrire("LongRunning"), TaskCreationOptions.LongRunning).Wait();
// principal pool=False arriere-plan=False
// new Thread pool=False arriere-plan=False
// Task.Run pool=True arriere-plan=True
// LongRunning pool=False arriere-plan=True
}
} Créer un thread à la main reste rare : une boucle qui vit autant que le processus, un thread qui exige un appartement STA pour COM, une priorité propre. Pour un travail long lancé depuis une tâche, TaskCreationOptions.LongRunning suffit — le planificateur par défaut y répond par un thread hors du pool, comme le montre la sortie, et ne prive donc le pool d'aucun de ses threads. Tout le reste passe par le pool, dont le cours Programmation asynchrone décrit la famine.
La course de données
compteur++ ressemble à une opération ; c'en sont trois. Le processeur charge la valeur dans un registre, lui ajoute un, puis la réécrit en mémoire. Si deux threads chargent la même valeur avant que l'un ait réécrit, ils écrivent le même résultat, et un incrément disparaît. C'est une course de données : deux accès non synchronisés au même emplacement, dont au moins une écriture.
using System;
using System.Threading;
static class Course
{
private static int compteur;
public static void Executer()
{
// Un million d'increments ne dure que quelques millisecondes : sans
// depart commun, un thread peut avoir fini avant que le suivant ait
// demarre, et le defaut se cache. Le signal les lache ensemble.
using var depart = new ManualResetEventSlim();
var threads = new Thread[4];
for (var i = 0; i < threads.Length; i++)
{
threads[i] = new Thread(() =>
{
depart.Wait();
for (var n = 0; n < 1_000_000; n++)
{
// Lire compteur, ajouter un, ecrire le resultat : trois
// operations. Deux threads qui lisent la meme valeur
// ecrivent le meme resultat, et un increment disparait.
compteur++;
}
});
threads[i].Start();
}
depart.Set();
foreach (var thread in threads)
{
thread.Join();
}
Console.WriteLine($"attendu 4000000, obtenu {compteur}");
// attendu 4000000, obtenu 2702168
// Une execution reelle. La suivante a donne 2083074 : le nombre change
// a chaque fois.
}
} Cinq exécutions successives par dotnet run, sur une machine à 12 cœurs logiques, ont affiché 2 702 168, 2 083 074, 3 156 939, 1 887 097 et 2 281 841 ; cinq autres en Release, entre 1 260 956 et 2 866 776. Le chiffre change à chaque fois. Ce qui rend le défaut redoutable n'est pas son ampleur, mais sa discrétion : la même boucle sans départ commun a affiché le compte exact six fois sur huit. Quand les threads se chevauchent peu, les pertes deviennent rares. Un test qui passe ne prouve rien.
Corriger, c'est rendre la séquence lire-modifier-écrire indivisible. Deux moyens existent : exclure les autres threads pendant qu'elle se déroule, c'est le verrou ; ou la confier à une seule instruction du processeur, c'est Interlocked.
lock, et le type Lock de .NET 9
lock (x) garantit qu'un seul thread à la fois exécute un bloc protégé par le même objet. Sur un objet ordinaire, le compilateur écrit Monitor.Enter et Monitor.Exit dans un try/finally, si bien que le verrou est rendu même quand le bloc lève une exception. Le verrou est réentrant : le thread qui le tient peut le reprendre sans s'attendre lui-même. Il ordonne aussi la mémoire — ce qu'un thread a écrit avant de sortir est lu à jour par le suivant qui entre, sans volatile nulle part.
Depuis C# 13 et .NET 9, l'objet à verrouiller a son type : System.Threading.Lock. Quand le type statique de l'expression est Lock, lock produit using (x.EnterScope()) au lieu de Monitor, et la documentation le recommande pour les performances. Dans les deux cas, l'objet reste privé et dédié : jamais this, un Type ou une chaîne, que n'importe quel autre code peut verrouiller aussi — les littéraux de chaîne sont internés, donc partagés.
using System;
using System.Threading;
sealed class Compteur
{
// Un objet prive, qui ne sert qu'a cela : aucun code exterieur ne peut le
// verrouiller a contretemps. Depuis .NET 9, c'est un Lock plutot qu'un
// object, et lock appelle alors EnterScope au lieu de Monitor.Enter.
private readonly Lock verrou = new();
private int valeur;
public void Incrementer()
{
// Un seul thread a la fois entre ici. La sortie du bloc publie aussi
// l'ecriture : le suivant qui prend le verrou lit la valeur a jour.
lock (verrou)
{
valeur++;
}
}
// La lecture passe par le meme verrou que l'ecriture, sinon rien ne
// garantit qu'elle voie la derniere valeur.
public int Valeur
{
get
{
lock (verrou)
{
return valeur;
}
}
}
}
static class AvecVerrou
{
public static void Executer()
{
var compteur = new Compteur();
using var depart = new ManualResetEventSlim(); // le meme depart commun
var threads = new Thread[4];
for (var i = 0; i < threads.Length; i++)
{
threads[i] = new Thread(() =>
{
depart.Wait();
for (var n = 0; n < 1_000_000; n++)
{
compteur.Incrementer();
}
});
threads[i].Start();
}
depart.Set();
foreach (var thread in threads)
{
thread.Join();
}
Console.WriteLine($"attendu 4000000, obtenu {compteur.Valeur}"); // 4000000, a chaque fois
}
} Un verrou ne protège une donnée que si tous ses accès passent par lui, les lectures comprises. Et le choix entre EnterScope et Monitor se fait sur le type statique, pas sur l'objet. Vu comme object — un paramètre, un champ mal typé —, le Lock est verrouillé par Monitor, un autre mécanisme posé sur le même objet, et les deux ne s'excluent pas. Le compilateur signale la conversion vers object par l'avertissement CS9216. Il ne signale rien, en revanche, quand le Lock passe par un paramètre générique T : le type est inféré, aucune conversion n'a lieu, et lock sur un T retombe sur Monitor en silence.
using System;
using System.Threading;
static class DeuxVerrous
{
private static readonly Lock verrou = new();
// Une aide qui prend object : le Lock y arrive converti.
static bool EssayerSans(object cible)
{
var entre = false;
var autre = new Thread(() =>
{
// Le type statique est object : lock produit Monitor.Enter, qui
// travaille sur l'en-tete de l'objet et ignore tout de l'etat
// interne du Lock. Ce n'est plus le meme verrou.
lock (cible)
{
entre = true;
}
});
autre.Start();
autre.Join();
return entre;
}
public static void Executer()
{
lock (verrou) // EnterScope : le Lock est pris
{
// warning CS9216 sur cet argument, a la conversion vers object.
Console.WriteLine(EssayerSans(verrou)); // True : l'autre thread est entre quand meme
}
}
}Il suffit que le type Lock survive jusqu'au lock :
using System;
using System.Threading;
static class UnVerrou
{
private static readonly Lock verrou = new();
// Le parametre garde le type Lock : lock y produit EnterScope, le meme
// chemin que chez l'appelant, et les deux s'excluent.
static bool EssayerSans(Lock cible)
{
var autre = new Thread(() =>
{
lock (cible)
{
}
});
autre.Start();
// L'autre thread attend son tour : Join rend False au bout du delai.
return autre.Join(TimeSpan.FromMilliseconds(200));
}
public static void Executer()
{
lock (verrou)
{
Console.WriteLine(EssayerSans(verrou)); // False : l'autre attend, comme il se doit
}
// A la sortie du bloc, le verrou est rendu et l'autre thread passe.
}
}Interlocked
Pour une seule variable, un verrou est lourd. Interlocked expose des opérations que le processeur exécute d'un bloc : sur x64, en code optimisé, le JIT traduit Interlocked.Increment en une instruction préfixée par lock — inc si le résultat est ignoré, xadd s'il sert —, dont la lecture et l'écriture sont indivisibles pour tous les cœurs. Increment, Decrement et Add rendent la nouvelle valeur, Exchange l'ancienne.
CompareExchange est la brique de tout le reste : il écrit une valeur seulement si la variable contient encore celle qu'on attend, et rend dans tous les cas ce qu'il a trouvé. Toute opération absente de la classe — un maximum, un plafond, une multiplication — s'écrit comme une boucle qui lit, calcule, tente l'échange et recommence si un autre thread est passé entre-temps.
using System;
using System.Threading;
static class Atomique
{
private static int compteur;
private static int maximum;
// Il n'existe pas d'Interlocked.Max : la boucle compare-et-echange en tient
// lieu. On lit, on calcule, et on n'ecrit que si personne n'a ecrit
// entre-temps. Sinon, on recommence avec la valeur que l'autre a laissee.
static void RetenirMaximum(int candidat)
{
var courant = Volatile.Read(ref maximum);
while (candidat > courant)
{
// Ecrit candidat si maximum vaut encore courant, et rend dans tous
// les cas la valeur trouvee avant l'operation.
var trouve = Interlocked.CompareExchange(ref maximum, candidat, courant);
if (trouve == courant)
{
return; // l'ecriture est passee
}
courant = trouve; // un autre thread est passe avant : on repart de sa valeur
}
}
public static void Executer()
{
using var depart = new ManualResetEventSlim(); // le meme depart commun
var threads = new Thread[4];
for (var i = 0; i < threads.Length; i++)
{
var premier = i * 1_000_000;
threads[i] = new Thread(() =>
{
depart.Wait();
for (var n = 0; n < 1_000_000; n++)
{
// Une seule instruction du processeur (lock inc sur x64 en
// Release, d'apres le listage du JIT) : lire, ajouter et
// ecrire ne peuvent plus etre separes.
Interlocked.Increment(ref compteur);
RetenirMaximum(premier + n);
}
});
threads[i].Start();
}
depart.Set();
foreach (var thread in threads)
{
thread.Join();
}
Console.WriteLine(compteur); // 4000000
Console.WriteLine(maximum); // 3999999
}
} La limite est dans le nom : chaque opération est atomique sur une variable. Deux compteurs mis à jour par Interlocked sont justes chacun, mais un thread peut lire l'un après l'incrément et l'autre avant. Dès qu'un invariant lie deux champs — un solde et son historique, un total et un nombre d'éléments —, il faut un verrou.
volatile, et ce qu'il ne garantit pas
Le JIT et le processeur réordonnent et gardent en registre les accès mémoire tant que le thread qui les fait ne voit pas la différence. Un autre thread, lui, la voit. Le cas d'école est un drapeau d'arrêt lu dans une boucle.
using System;
using System.Threading;
static class Arret
{
private static bool arreter;
public static void Executer()
{
var travailleur = new Thread(() =>
{
var tours = 0L;
// Rien dans la boucle n'ecrit arreter : le JIT optimise a le droit
// de ne le lire qu'une fois, avant d'entrer. La boucle tourne alors
// sur une copie en registre qui ne changera jamais.
while (!arreter)
{
tours++;
}
Console.WriteLine($"arrete apres {tours} tours");
});
travailleur.IsBackground = true; // pour que le processus puisse finir quand meme
travailleur.Start();
Thread.Sleep(500);
arreter = true;
Console.WriteLine(travailleur.Join(TimeSpan.FromSeconds(2)) ? "arrete" : "ne s'arrete pas");
// dotnet run -c Release : ne s'arrete pas
// dotnet run (Debug) : arrete apres 174889308 tours, puis arrete
// (le nombre de tours change a chaque execution)
// Le code n'est pas plus juste en Debug : le JIT y optimise moins.
}
} En Release, trois exécutions sur trois ne s'arrêtent pas. La boucle démarre en code non optimisé, qui relit le champ à chaque tour ; dès qu'elle a assez tourné, le remplacement sur pile (OSR) lui substitue à chaud une version optimisée, dont le listage du JIT montre une seule lecture du champ, avant la boucle, puis un saut sur place. En Debug, rien n'est optimisé et le thread s'arrête. volatile interdit cette optimisation : chaque lecture du champ est une vraie lecture, avec une sémantique d'acquisition — rien de ce qui la suit ne remonte avant elle —, et chaque écriture une sémantique de libération.
using System;
using System.Threading;
static class Arret
{
// Chaque lecture d'un champ volatile est une vraie lecture en memoire : le
// JIT ne peut plus la sortir de la boucle.
private static volatile bool arreter;
public static void Executer()
{
var travailleur = new Thread(() =>
{
var tours = 0L;
while (!arreter)
{
tours++;
}
Console.WriteLine($"arrete apres {tours} tours");
});
travailleur.IsBackground = true;
travailleur.Start();
Thread.Sleep(500);
arreter = true;
Console.WriteLine(travailleur.Join(TimeSpan.FromSeconds(2)) ? "arrete" : "ne s'arrete pas");
// dotnet run -c Release : arrete apres 835103936 tours, puis arrete
// (le nombre de tours change a chaque execution)
}
} Ce qu'il ne garantit pas tient en deux points. L'atomicité d'abord : ++ sur un champ volatile reste une lecture volatile suivie d'une écriture volatile, avec un trou entre les deux.
using System;
using System.Threading;
static class Volatil
{
// Avec volatile, chaque lecture et chaque ecriture ont reellement lieu, et
// dans l'ordre, une par une.
// Il ne soude pas la lecture et l'ecriture de ++ en une seule operation.
private static volatile int compteur;
public static void Executer()
{
using var depart = new ManualResetEventSlim(); // le meme depart commun
var threads = new Thread[4];
for (var i = 0; i < threads.Length; i++)
{
threads[i] = new Thread(() =>
{
depart.Wait();
for (var n = 0; n < 1_000_000; n++)
{
compteur++; // une lecture volatile, puis une ecriture volatile
}
});
threads[i].Start();
}
depart.Set();
foreach (var thread in threads)
{
thread.Join();
}
Console.WriteLine($"attendu 4000000, obtenu {compteur}");
// attendu 4000000, obtenu 3653268 : une execution reelle, variable
// comme sans volatile
}
} L'ordre complet ensuite : acquisition et libération n'empêchent pas une écriture d'être doublée par une lecture qui la suit, sur une autre variable, et la spécification ne promet pas que tous les threads voient les écritures volatiles dans le même ordre. D'où un seul usage sûr : un drapeau ou une référence publiés par un thread et lus par d'autres. Pour un élément de tableau, que volatile ne peut pas marquer, Volatile.Read et Volatile.Write font la même chose accès par accès. Au-delà, lock ou Interlocked.
Collections concurrentes
List<T> et Dictionary<TKey, TValue> n'assurent aucune synchronisation : dès que plusieurs threads y ajoutent ou retirent en même temps, c'est au code appelant de verrouiller. System.Collections.Concurrent fournit des collections dont chaque opération est sûre sous concurrence, avec des verrous fins ou sans verrou du tout.
| Collection | Forme | Emploi |
|---|---|---|
ConcurrentDictionary | clé et valeur | cache, index partagé ; lectures sans verrou |
ConcurrentQueue | premier entré, premier sorti | file de travail sans verrou |
ConcurrentStack | dernier entré, premier sorti | pile partagée |
ConcurrentBag | sac, sans ordre | le même thread ajoute et retire : réserve d'objets |
BlockingCollection | enveloppe bloquante, bornable | producteur-consommateur synchrone ; Channel<T> en asynchrone |
Chaque opération est sûre ; une suite d'opérations ne l'est pas. Un TryGetValue suivi d'un TryAdd laisse un trou où un autre thread s'insère, d'où les méthodes composées GetOrAdd, AddOrUpdate et TryUpdate. Mais GetOrAdd n'est pas atomique vis-à-vis de sa fabrique : la documentation précise qu'elle est appelée hors des verrous internes, pour ne jamais exécuter de code inconnu sous verrou. Tous les threads qui trouvent la clé absente l'appellent, chacun de son côté ; un seul résultat est rangé, et tous reçoivent celui-là.
using System;
using System.Collections.Concurrent;
using System.Threading;
sealed class Rapport
{
public Rapport(string periode)
{
Periode = periode;
Thread.Sleep(100); // un calcul couteux : requetes, agregats
}
public string Periode { get; }
}
static class CacheRapports
{
private static readonly ConcurrentDictionary<string, Rapport> cache = new();
private static int calculs;
public static void Executer()
{
var obtenus = new Rapport[8];
var threads = new Thread[obtenus.Length];
for (var i = 0; i < threads.Length; i++)
{
var indice = i;
threads[i] = new Thread(() =>
{
// La fabrique s'execute hors des verrous internes du
// dictionnaire : tous les threads qui trouvent la cle absente
// la lancent, chacun de son cote. Un seul resultat est range.
obtenus[indice] = cache.GetOrAdd("2026-T3", periode =>
{
Interlocked.Increment(ref calculs);
return new Rapport(periode);
});
});
threads[i].Start();
}
foreach (var thread in threads)
{
thread.Join();
}
Console.WriteLine($"rapport calcule {calculs} fois");
// rapport calcule 8 fois : le plus souvent, les huit threads arrivent
// pendant les 100 ms du premier calcul. Une autre execution a affiche
// 3 : le nombre varie, entre 1 et 8.
// Les perdants recoivent pourtant la valeur rangee, pas la leur.
Console.WriteLine(Array.TrueForAll(obtenus, r => ReferenceEquals(r, obtenus[0]))); // True
}
} C'est sans gravité pour une fabrique rapide et sans effet. C'en est une quand elle coûte cher, ouvre une connexion ou envoie un message : les résultats perdants sont jetés sans être libérés. Ranger un Lazy<T> plutôt que la valeur ramène le calcul à un seul, parce que Lazy garantit par défaut une seule exécution de sa fabrique.
using System;
using System.Collections.Concurrent;
using System.Threading;
sealed class Rapport
{
public Rapport(string periode)
{
Periode = periode;
Thread.Sleep(100);
}
public string Periode { get; }
}
static class CacheRapports
{
// Le dictionnaire range des Lazy, pas des rapports. Plusieurs Lazy peuvent
// encore etre construits en course, mais ils ne coutent rien : un seul est
// range, et tous les threads lisent Value sur celui-la.
private static readonly ConcurrentDictionary<string, Lazy<Rapport>> cache = new();
private static int calculs;
static Rapport Obtenir(string periode) =>
cache.GetOrAdd(periode, p => new Lazy<Rapport>(() =>
{
Interlocked.Increment(ref calculs);
return new Rapport(p);
})).Value; // ExecutionAndPublication, le mode par defaut : un seul calcul
public static void Executer()
{
var obtenus = new Rapport[8];
var threads = new Thread[obtenus.Length];
for (var i = 0; i < threads.Length; i++)
{
var indice = i;
threads[i] = new Thread(() => obtenus[indice] = Obtenir("2026-T3"));
threads[i].Start();
}
foreach (var thread in threads)
{
thread.Join();
}
Console.WriteLine($"rapport calcule {calculs} fois"); // rapport calcule 1 fois
Console.WriteLine(Array.TrueForAll(obtenus, r => ReferenceEquals(r, obtenus[0]))); // True
}
} Une contrepartie : un Lazy construit avec une fabrique mémorise l'exception si elle échoue, et le dictionnaire la resservira à chaque lecture. Il faut alors retirer l'entrée pour qu'un appel suivant retente, et la retirer sous condition : TryRemove(new KeyValuePair<string, Lazy<Rapport>>(periode, fautif)) ne supprime que si la valeur rangée est encore le Lazy fautif, sans effacer celui qu'un autre thread aurait déjà remis à sa place. Les deux fabriques d'AddOrUpdate ont la même propriété que celle de GetOrAdd : elles peuvent s'exécuter plusieurs fois.
Interblocage par ordre d'acquisition
Un interblocage demande un cycle : chaque thread tient un verrou et attend celui qu'un autre tient. Le cas le plus fréquent se forme à deux verrous pris dans l'ordre inverse par deux chemins de code. Le virement entre comptes en est l'exemple type : verrouiller la source, puis la destination, suffit à le produire dès que deux virements se croisent.
using System;
using System.Threading;
sealed class Compte(int numero, decimal solde)
{
public int Numero { get; } = numero;
public decimal Solde { get; set; } = solde;
public Lock Verrou { get; } = new();
}
static class Virements
{
// Le rendez-vous force le pire entrelacement : chacun tient son premier
// verrou quand il demande le second. Sans lui, l'interblocage reste
// possible mais rare : il attend la charge de la production pour se montrer.
private static readonly Barrier rendezVous = new(2);
// Verrouiller la source, puis la destination : deux virements croises
// prennent les memes verrous dans l'ordre inverse.
static void Virer(Compte source, Compte destination, decimal montant)
{
lock (source.Verrou)
{
rendezVous.SignalAndWait();
lock (destination.Verrou) // chacun attend le verrou que tient l'autre
{
source.Solde -= montant;
destination.Solde += montant;
}
}
}
public static void Executer()
{
var courant = new Compte(1, 100m);
var epargne = new Compte(2, 100m);
var aller = new Thread(() => Virer(courant, epargne, 10m)) { IsBackground = true };
var retour = new Thread(() => Virer(epargne, courant, 20m)) { IsBackground = true };
aller.Start();
retour.Start();
// Rien ne detecte un interblocage : les deux threads attendent, sans
// erreur ni exception. Seul le delai de Join le rend visible ici.
var finis = aller.Join(TimeSpan.FromSeconds(2)) & retour.Join(TimeSpan.FromSeconds(2));
Console.WriteLine(finis ? "termine" : "interblocage"); // interblocage
}
}Rien ne signale l'interblocage : ni exception, ni processeur occupé, seulement deux threads qui attendent pour toujours. Il se lit après coup, dans la fenêtre des piles parallèles du débogueur ou dans un vidage mémoire. La correction consiste à fixer un ordre global d'acquisition, indépendant du sens de l'opération — ici le numéro de compte.
using System;
using System.Threading;
sealed class Compte(int numero, decimal solde)
{
public int Numero { get; } = numero;
public decimal Solde { get; set; } = solde;
public Lock Verrou { get; } = new();
}
static class Virements
{
// Un ordre unique pour tout le programme : le plus petit numero d'abord,
// quel que soit le sens du virement. Deux threads ne peuvent plus tenir
// chacun un verrou que l'autre attend.
static void Virer(Compte source, Compte destination, decimal montant)
{
var (premier, second) = source.Numero < destination.Numero
? (source, destination)
: (destination, source);
lock (premier.Verrou)
{
lock (second.Verrou)
{
source.Solde -= montant;
destination.Solde += montant;
}
}
}
public static void Executer()
{
var courant = new Compte(1, 100m);
var epargne = new Compte(2, 100m);
// Cent mille virements dans chaque sens, en meme temps.
var aller = new Thread(() =>
{
for (var n = 0; n < 100_000; n++) Virer(courant, epargne, 10m);
});
var retour = new Thread(() =>
{
for (var n = 0; n < 100_000; n++) Virer(epargne, courant, 10m);
});
aller.Start();
retour.Start();
aller.Join();
retour.Join();
Console.WriteLine($"{courant.Solde} + {epargne.Solde} = {courant.Solde + epargne.Solde}");
// 100 + 100 = 200 : les virements se compensent, et rien ne s'est perdu
}
} Deux règles complètent celle de l'ordre. Ne jamais appeler sous verrou du code qu'on ne maîtrise pas — un rappel, un événement, une méthode virtuelle —, qui peut prendre d'autres verrous dans un ordre inconnu. Et quand l'ordre ne peut pas être garanti, Monitor.TryEnter ou Lock.TryEnter avec un délai transforment l'attente éternelle en échec visible, qu'on peut journaliser et réessayer.
Verrou et await
lock et await ne se mélangent pas, et le compilateur le refuse : un await dans le corps d'un lock produit CS1996, « Impossible d'attendre dans le corps d'une instruction lock » avec un SDK en français, et un using (verrou.EnterScope()) qui enjambe un await produit CS4007, Lock.Scope étant une ref struct qui ne survit pas à une suspension. La raison n'est pas de syntaxe. Un Monitor comme un Lock appartient au thread qui l'a pris, et après un await la méthode reprend sur un thread quelconque du pool, faute de contexte de synchronisation dans une console ou dans ASP.NET Core, comme l'explique le cours Programmation asynchrone. Contourner l'interdit à la main compile, et échoue à l'exécution :
using System;
using System.Threading;
using System.Threading.Tasks;
static class Jeton
{
private static readonly object verrou = new();
private static string? valeur;
static async Task<string> DemanderAsync()
{
await Task.Delay(50); // l'appel au fournisseur d'identite
return Guid.NewGuid().ToString("N");
}
// lock refuse l'await (CS1996). Le contourner a la main compile...
static async Task<string> ObtenirAsync()
{
Monitor.Enter(verrou);
try
{
valeur ??= await DemanderAsync();
return valeur;
}
finally
{
// ... mais un Monitor appartient au thread qui l'a pris, et apres
// l'await la methode reprend sur un thread du pool quelconque.
Monitor.Exit(verrou);
}
}
public static async Task ExecuterAsync()
{
try
{
await ObtenirAsync();
}
catch (SynchronizationLockException erreur)
{
Console.WriteLine(erreur.GetType().Name); // SynchronizationLockException
}
// Et le thread de depart, ici le thread principal, possede toujours le
// verrou : aucun autre thread ne pourra plus le prendre.
var pris = await Task.Run(() => Monitor.TryEnter(verrou, TimeSpan.FromMilliseconds(200)));
Console.WriteLine(pris); // False
}
} Même si le thread était le bon, tenir un verrou pendant une attente réseau bloquerait tous les threads qui le demandent pour toute sa durée, ce qui contredit l'objet même d'await. L'outil est SemaphoreSlim à une place : il n'a pas de propriétaire, n'importe quel thread peut le rendre, et WaitAsync attend sans bloquer de thread.
using System;
using System.Threading;
using System.Threading.Tasks;
static class Jeton
{
// Un semaphore a une place : l'exclusion mutuelle d'un verrou, sans
// proprietaire. N'importe quel thread peut rendre la place, et celui qui
// attend avec WaitAsync ne bloque aucun thread pendant l'attente.
private static readonly SemaphoreSlim acces = new(1, 1);
private static string? valeur;
private static int demandes;
static async Task<string> DemanderAsync()
{
Interlocked.Increment(ref demandes);
await Task.Delay(50);
return Guid.NewGuid().ToString("N");
}
static async Task<string> ObtenirAsync()
{
await acces.WaitAsync();
try
{
valeur ??= await DemanderAsync();
return valeur;
}
finally
{
acces.Release(); // quel que soit le thread de reprise
}
}
public static async Task ExecuterAsync()
{
var appels = new Task<string>[5];
for (var i = 0; i < appels.Length; i++)
{
appels[i] = ObtenirAsync();
}
var jetons = await Task.WhenAll(appels);
Console.WriteLine(demandes); // 1 : les quatre autres ont attendu, puis trouve le jeton
Console.WriteLine(Array.TrueForAll(jetons, j => j == jetons[0])); // True
}
} Faute de propriétaire, il n'est pas réentrant : une méthode qui le redemande alors qu'elle le tient attend une place qu'elle seule peut rendre, et s'interbloque avec elle-même. Le Release se place dans un finally, pour la même raison que le Monitor.Exit que lock écrit à notre place.