Détecter les deadlocks en C# / .NET grâce au pattern IDisposable

Par | Classé dans .NET, Avancé, Intermédiaire | Le 09/02/2011

Tags « »

5

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

  1. Pouvoir détecter les deadlocks
  2. Proposer une syntaxe claire et concise
  3. 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 :

  1. Un thread T1 acquiert une ressource R1
  2. Un thread T2 acquiert une ressource R2
  3. T1 demande la ressource R2
  4. 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);
}

}
Partagez !    |
  • Pingback: Tweets that mention Détecter les deadlocks en C# / .NET grâce au pattern IDisposable | In Fine blog -- Topsy.com()

  • http://www.ullink.com Imane EL AZLOUK

    Bonjour,

    Votre blog a attiré toute mon attention. Nous sommes un éditeur de logiciels spécialisé dans la finance de marché. Nos développeurs travaillent avec du matériel de pointe sur des langages Java et C#. Nous utilisons les dernières versions de Framework .NET et évoluons dans des domaines techniques tels que WinForm, DevExpress… Nos équipes produits sont organisées en méthode Scrum et sont réparties dans 3 de nos filiales : Paris, Hong Kong & Cluj. Dans le cadre du développement de notre entreprise, nous sommes à la recherche d’ingénieurs d’étude et développement pour nos équipes à Paris.

    N’hésitez pas à me contacter pour plus de renseignements.

    Bien cordialement,

    Imane El Azlouk | HR Assistant | ULLINK | T: +33 1 49 95 10 25 |Switch: +33 1 49 95 30 00 |
    | 23-25 rue de Provence | 75009 Paris | imane.elazlouk@ullink.com |

  • B____

    Bonjour,

    Si je ne me trompe pas, pour la dernière solution, le verrou est automatiquement relâché via la méthode Dispose ?

    A partir du Fx 4 il est possible d’utiliser SpinLock tel que:

    static void Work(Object acquire, Object demand)

    {

    var sl = new SpinLock();

    var gotLock = false;

    sl.Enter(ref gotLock);

    // manipultation de l’objet acquire

    if (gotLock) sl.Exit();

    gotLock = false;

    sl.Enter(ref gotLock);

    Thread.Sleep(1000);

    // manipulation de l’objet demand

    sl.Exit();

    }

    Que pensez vous de cette alternative ?

    • http://www.linkedin.com/profile?viewProfile=BNrUTaZzcp110142788110550036 Nicolas Lecrique

      Bonjour,

      Merci pour votre commentaire

      Pour votre remarque sur la méthode Dispose. Le verrou sera relâché automatiquement à la parenthèse fermante du using dans les deux exemples (parce qu’on a implémenté la méthode dispose bien sur, rien ne se fait tout seul !)

      Concernant votre alternative, effectivement, on peut utiliser SpinLock, une syntaxe 4.0 et proposer d’autres types de deadlocks…. Mais le but du post est de proposer une alternative syntaxique au Monitor.TryEnter, pas de multiplier les exemples de deadlocks 😉

  • B____

    Je me permets de complété et corriger ma réponse :

    static void Main(string[] args)

    {

    var R1 = new object();

    var R2 = new object();

    var T1 = new Thread(() => Work(R1, R2, “T1″));

    var T2 = new Thread(() => Work(R2, R1, “T2″));

    T1.Start();

    T2.Start();

    T1.Join();

    T2.Join();

    Console.WriteLine(“fin”);

    Console.ReadLine();

    }

    static void Work(Object acquire, Object demand, string ThreadN)

    {

    var sl = new SpinLock();

    var gotLock1 = false;

    sl.Enter(ref gotLock1);

    Thread.Sleep(5000);

    Console.WriteLine(ThreadN);

    // manipultation de l’objet acquire

    if (gotLock1) sl.Exit();

    var gotLock2 = false;

    sl.Enter(ref gotLock2);

    Thread.Sleep(1000);

    Console.WriteLine(ThreadN);

    // manipulation de l’objet demand

    sl.Exit();

    }