Détecter les deadlocks en C# / .NET grâce au pattern IDisposable
Si vous êtes développeur en environnement multithread, alors vous vous êtes déjà posé cette question : Où est ce deadlock ? !
Bien sûr, ce deadlock est non-reproductible, il survient chez l’utilisateur, et l’on peut passer des heures à le chercher. Sans jamais être sur d’avoir trouvé LE deadlock qui pose problème !
Vous trouverez dans ce post un exemple de lock qui a pour objectif de
- Pouvoir détecter les deadlocks
- Proposer une syntaxe claire et concise
- Ajouter un overhead minimum en terme de performance à la syntaxe lock C# classique
Petit rappel : qu’est-ce qu’un deadlock ?
Un deadlock (interblocage dans sa version française que personne n’utilise) est la situation dans laquelle 2 threads s’attendent mutuellement. Cas concret :
- Un thread T1 acquiert une ressource R1
- Un thread T2 acquiert une ressource R2
- T1 demande la ressource R2
- T2 demande la ressource R1
Avec un schéma :
Exemple de code provoquant un deadlock
void CreateDeadLock() { Object R1 = new object(); Object R2 = new object(); Thread T1 = new Thread(delegate() { Work(R1, R2); }); Thread T2 = new Thread(delegate() { Work(R2, R1); }); T1.Start(); T2.Start(); } void Work(Object acquire, Object demand) { lock (acquire)//T1 take R1 and T2 take R2 { Thread.Sleep(1000);//To ensure that the ressources are taken lock (demand) { } } }
NB : Bien sûr, les situations réelles sont souvent plus compliquées, impliquant par exemple plus de 2 threads ou des locks implicites (écriture dans un fichier…).
Le lock C#
Quand en C# on écrit la syntaxe suivante :
lock (ressource) { }
Il se passe en fait à peu près ça :
Object temp = ressource; Monitor.Enter(temp); try { } finally { Monitor.Exit(temp); }
Monitor.Enter acquière le lock, Monitor.Exit le relâche, Le block try-finally est là pour s’assurer que la ressource sera libérée même en cas d’exception. Quant à la référence “temp”, il garantit que c’est bien l’objet acquis qui sera libéré (l’utilisateur ne peut pas la modifier l’objet vers lequel “temp” pointe).
Prévenir les deadlocks avec un Timeout
Le principe pour prévenir les deadlocks implique l’introduction d’un timeout avec un log ou une levée d’exception dans le cas ou le thread échoue à acquérir la ressource demandée.
Voici un exemple de code résolvant le problème.
if (Monitor.TryEnter(ressource, 10000)) { try { //Work } finally { Monitor.Exit(); } } else { // Log, stack trace display... throw new TimeoutException("Lock acquiring failed"); }
Dans une application avec de nombreux locks, vous conviendrez que cette syntaxe est plus lourde et moins lisible que le lock classique.
Plus grave, elle laisse la possibilité « d’oublier » la libération de la ressource puisque celle-ci incombe au programmeur à chaque utilisation (sisi, ça peut arriver !).
Une solution plus « User-friendly »
L’objectif est de garder le bénéfice du timeout, tout en bénéficiant d’une syntaxe plus concise et claire.
L’idée ici est d’utiliser un Pattern bien connu des programmeurs C++, mais moins des développeurs C# ou JAVA, le principe RAII (Ressource Acquisition Is Initialization).
Le principe RAII
En C++, il y a un moyen simple de s’assurer qu’une ressource sera toujours libérée à la sortie d’une méthode, c’est de l’encapsuler dans un objet créé sur la pile, et de la libérer dans le destructeur de cet objet. En effet, lorsque la méthode se termine (que ce soit par return ou exception), le destructeur de l’objet est appelé et la ressource toujours libérée.
En C# cependant, on ne peut pas appliquer directement cet idiome. En effet :
-L’appel du destructeur d’une classe n’est pas déterministe (il est réalisé par le Garbage Collector)
-Les structures, équivalent C# des objets créé sur la pile en C++, ne peuvent pas avoir de destructeur.
Implémentation du lock par l’utilisation du pattern IDisposable
Pour contourner cette limitation du langage, on peut utiliser le pattern IDisposable.
Voici une première implémentation :
public class LockCookie : IDisposable { private const int TIMEOUT = 10000; private Object _ressource; public LockCookie(Object ressource) { if (Monitor.TryEnter(ressource, TIMEOUT)) { _ressource = ressource; } else { // Log, stack trace display... throw new TimeoutException("Lock acquiring failed"); } } public void Dispose() { if (_ressource != null) { Monitor.Exit(_ressource); } } }
Et son utilisation :
using (new LockCookie(ressource)) { //Work }
Avec ce pattern, une seule ligne permet d’acquérir la ressource, la libération est systématique à l’accolade fermante (même en cas d’exception) et le traitement en cas d’échec n’est pas dupliqué.
CQFD ? Pas tout à fait. Même si l’on peut se satisfera de cette solution dans la plupart des cas, elle créé un overhead non négligeable : il y a création d’objet à chaque acquisition de la ressource (et qui dit création d’objet dit également plus de travail pour le Garbage Collector !).
Optimisation de la solution
Eliminons tout de suite une première piste : transformer notre classe LockCookie en type struct n’optimise pas la solution. Pourquoi ? Parce qu’à l’accolade fermante du using, la méthode Dispose de l’interface IDisposable est appelée, et appeler une méthode d’interface à partir d’un type struct implique un boxing.
Une autre piste consiste à faire de notre classe LockCookie l’objet même de synchronisation. Voici le code qui en résulte.
public class LockCookie : IDisposable { private const int TIMEOUT = 10000; private bool _ressourceAcquired; public LockCookie Get() { _ressourceAcquired = Monitor.TryEnter(this, TIMEOUT); if (!_ressourceAcquired) { throw new TimeoutException("Lock acquiring failed"); } return this; } public void Dispose() { if (_ressourceAcquired) { //NB : the 2 next lines must be in is order to prevent _ressourceAcquired //to be set to true by Get() then reset by this call to dispose _ressourceAcquired = false; Monitor.Exit(this); } } }
Et son utilisation :
//For instance, ressource is a class field : LockCookie _ressource = new LockCookie(); using (_ressource.Get()) { //Work }
L’utilisation est sans doute un peu moins évidente, mais aucun objet n’est créé et la libération de la ressource reste implicite et systématique.
Sur le plan des performances, un lock classique coûte selon mes tests 40 ns (40 milliardièmes de secondes), avec la première solution (avec création d’objet), le coût monte à 50 ns, soit un overhead de 25%, la seconde solution descent à 44 ns, soit un overhead de 10%. Le code des tests de performances sont disponibles en bas de l’article.
Conclusion
Ces 2 solutions apportent une réponse centralisée et efficace aux problèmes liés aux acquisitions de ressources en général et aux deadlocks en particulier. Le coût en terme de performance peut paraître important à certains, mais gardons à l’esprit que le temps d’acquisition d’un lock est souvent négligeable en comparaison du temps ou un thread est effectivement bloqué en l’attente d’une ressource. Je suis par ailleurs curieux de connaître d’éventuelles améliorations ou corrections que vous pourriez apporter.
Code d’analyse de la performance :
void TestPerf()
{
TestPerf(ClassicLock,”ClassicLock”);
TestPerf(LockCookieCreation,”LockCookieCreation”);
TestPerf(LockCookieGet,”LockCookieGet”);
}
public static void ClassicLock(){ lock (_lockObject) { } }
public static void UseLockCookie(){using (new LockCookie(ressource)){ } }
public static void UseLockCookie() { using (ressource.Get()) { } }
public delegate void TestPerfDelegate();
public static void TestPerf(TestPerfDelegate testPerfDelegate, string testName)
{
const long nbIteration = 10000000;//10 millions times
const long nanoSecCoeff = 1000000;//1 ms contains 1 million ns
Stopwatch stopwatch = new Stopwatch();
GC.Collect();//Prevent from garbage collection during loop
stopwatch.Start();
for (int i = 0; i < nbIteration; i++)
{
testPerfDelegate();
}
stopwatch.Stop();
double ellapsedNanoSec = (double)(stopwatch.ElapsedMilliseconds * nanoSecCoeff) / nbIteration;
Console.WriteLine(testName.PadRight(50) + ellapsedNanoSec);
}
}
[/sourcecode]