mardi 1 avril 2008

Singleton générique

Tout d'abord, c'est quoi un Singleton ? Pour résumer très vite (Google est ton ami si tu veux plus de détails), un singleton est une instance unique d'une classe qui sera utilisée tout au long d'un processus. Le but de cette démarche est surtout pour moi de gagner en rapidité lors de l'écriture et aussi lors de l'exécution.

Imaginons que j'ai une classe MyClass et à l'intérieur une méthode DoThings. Si je veux appeler cette méthode je suis obligé de faire :

   1: MyClass myClass = new MyClass(); 
   2: myClass.DoThings();

et cela chaque fois que je voudrai appeler la méthode. C'est une perte de temps dans l'écriture du code lui-même mais aussi une perte de temps dans l'exécution puisqu'à chaque fois il devra instancier une nouvelle fois la classe (et tout ce que cela implique niveau mémoire etc). Dans ce cas-là, on pourrait se dire qu'il suffirait de mettre DoThings en méthode statique. De manière générale je suis un peu contre mettre les choses en static sauf si l'utilité est réellement prouvée. Dans notre cas, c'est seulement parce que l'on est feignant, donc je m'y refuse.

La méthode la plus simple, et la plus répandue aussi je crois, pour obtenir le singleton serait de définir des méthodes statiques dans la classe pour effectuer cela.

   1: public class MyClass
   2: {
   3:     private static MyClass _singleton;
   4:  
   5:     static MyClass()
   6:     {
   7:         _singleton = new MyClass();
   8:     }
   9:  
  10:     public static MyClass GetInstance()
  11:     {
  12:         return _singleton;
  13:     }
  14:  
  15:     public void DoThings()
  16:     {
  17:     }
  18: }
Le constructeur statique instancie une fois la classe et la stocke dans une variable statique elle aussi. A chaque appel de la méthode statique GetInstance, on retourne toujours la même instance. Ainsi là où l'on avait les deux lignes définies plus haut, on a plus que ceci :
   1: MyClass.GetInstance().DoThings();

Il me semble que c'est déjà pas mal non ? Et en plus ca marche très bien. Le seul petit problème, c'est que l'on doit se taper la variable, le constructeur et la méthode dans chaque classe où l'on souhaite avoir le singleton. La solution ? Les génériques !

Grâce aux génériques on va pouvoir se créer des singletons une bonne fois pour toutes, et à un seul endroit en plus. Sans plus attendre, le code :

   1: public static class Singleton<T> where T : class, new()
   2: {
   3:     private static T _singleton;
   4:  
   5:     static Singleton()
   6:     {
   7:         _singleton = new T();
   8:     }
   9:  
  10:     public static T GetInstance()
  11:     {
  12:         return _singleton;
  13:     }
  14: }

Le code indique que la classe prendra un argument en générique qui, grâce à la clause where, devra être une classe, et avoir un constructeur public par défaut. Le reste du code est identique au code présenté précédemment. L'appel quant à lui change un peu, il faut désormais appeler la méthode DoThings de cette manière :

   1: Singleton<MyClass>.GetInstance().DoThings();

Je le concède, cela prends plus de caractères pour faire l'appel, cependant cela devient beaucoup plus simple lors de l'écriture des classes. En plus on peut le customiser pour posséder plusieurs singleton d'une même classe. Par exemple, imaginons un système qui impose d'aller taper une base de données dans un cas, et une autre base de données dans un autre ; il nous faudrait deux singletons, un pour chaque base de données.

Pour cela on peut définir une seconde classe Singleton qui permettrait d'obtenir un singleton en fonction d'un paramètre fourni lors de l'appel à GetInstance. Sans plus attendre, le code qui permet ça :

   1: public static class Singleton<T, P> where T : class, new()
   2: {
   3:     private static Dictionary<P, T> _singleton;
   4:     private static ReaderWriterLockSlim _lock;
   5:  
   6:     static Singleton()
   7:     {
   8:         _singleton = new Dictionary<P, T>();
   9:         _lock = new ReaderWriterLockSlim();
  10:     }
  11:  
  12:     public static T GetInstance(P param)
  13:     {
  14:         T t;
  15:         _lock.EnterUpgradeableReadLock();
  16:         try
  17:         {
  18:             if (!_singleton.TryGetValue(param, out t))
  19:             {
  20:                 _lock.EnterWriteLock();
  21:                 try
  22:                 {
  23:                     if (!_singleton.ContainsKey(param))
  24:                     {
  25:                         t = new T();
  26:                         _singleton.Add(param, t);
  27:                     }
  28:                 }
  29:                 finally
  30:                 {
  31:                     _lock.ExitWriteLock();
  32:                 }
  33:             }
  34:         }
  35:         finally
  36:         {
  37:             _lock.ExitUpgradeableReadLock();
  38:         }
  39:         return t;
  40:     }
  41: }

Le code ce coup-ci a pas mal changé. Tout d'abord on voit apparaître un dictionnaire qui va contenir tous nos singletons en fonction d'une clé qui sera le paramètre fourni lors de l'appel à GetInstance. On reconnait aussi le ReaderWriterLockSlim qui est là pour éviter les collisions de threads lorsque deux threads concurrents essaieront d'obtenir un même singleton qui n'existe pas encore.

Normalement le code devrait être Thread-Safe, mais j'avoue que je n'ai fait aucune charge pour m'en assurer.

Pour obtenir un singleton paramétré, on fait désormais cet appel :

   1: Singleton<MyClass, int>.GetInstance(1).DoThings(); 
   2: Singleton<MyClass, int>.GetInstance(2).DoThings();

Ces deux lignes utiliseront deux instances distinctes de la classe MyClass.

Une partie de Cache - Cache / Partie 3

Voilà la dernière partie de cette aventure au combien trépidante. Il ne reste plus qu'à vider régulièrement le contenu du cache pour que ce dernier soit complet.

Pour faire cela, nous allons utiliser un Timer (namespace System.Timers). Pour ceux qui ne connaîtraient pas cette classe, peu nombreux j'imagine, elle permet de déclencher un événement à intervalle régulier.

Voici très brièvement sa définition au sein de notre classe :

   1: private Timer _timer;
   2:  
   3: public Cache()
   4: {
   5:     _timer = new Timer();
   6:     _timer.Elapsed += timer_Elapsed;
   7:     _timer.Interval = 5000;
   8:     _timer.Start();
   9:  
  10: ...
  11: }

Le principe est super simple : lorsque l'événement se déclenche, on regarde tous les éléments présents et on efface ceux qui sont trop vieux. Cependant, avant de rentrer plus en détails dans le vif du sujet, je me dois d'exposer une dernière petite chose.

Dans ce cache, nous allons offrir la possibilité d'être notifié lorsqu'un élément est supprimé du cache et parce qu'on est sympa, on va aussi offrir la possibilité d'empêcher que l'élément soit supprimé. Là d'un coup tu te dis : c'est con ce que tu dis, le cache n'a plus aucun intérêt. Hé bien tu n'as qu'à moitié tord ! Je dis beaucoup de conneries, mais je dis aussi parfois des trucs qui peuvent servir.

Par exemple, tu es un site marchand et tu souhaites mettre en cache les paniers de tes utilisateurs. Ces paniers ne doivent subsister que 20 min (histoire de leur mettre la pression pour qu'ils achètent vite :p) mais attention tu es tombé sur un mec un peu lent qui a un panier qui vient d'atteindre les 20 min et il est en train de payer, donc ce serait dommage de lui effacer son panier. Il faut donc pouvoir offrir la possibilité de garder le panier au-delà de 20 min qui est pourtant sa durée de vie... Ok, c'est pas terrible comme exemple, mais ça illustre un peu le concept.

Pour faire tout ça nous allons créer nos propres EventArgs, delegate et event. En voici les définitions :

   1: public class ItemRemovedEventArgs<K, V> : EventArgs
   2: {
   3:     #region Private Members
   4:  
   5:     private V _value;
   6:     private K _key;
   7:     private bool _cancel;
   8:  
   9:     #endregion
  10:  
  11:     #region Constructors
  12:  
  13:     public ItemRemovedEventArgs(K key, V value)
  14:     {
  15:         _key = key;
  16:         _value = value;
  17:         _cancel = false;
  18:     }
  19:  
  20:     #endregion
  21:  
  22:     #region Properties
  23:  
  24:     public K Key
  25:     {
  26:         get { return _key; }
  27:     }
  28:  
  29:     public V Value
  30:     {
  31:         get { return _value; }
  32:     }
  33:  
  34:     public bool Cancel
  35:     {
  36:         get { return _cancel; }
  37:         set { _cancel = value; }
  38:     }
  39:  
  40:     #endregion
  41: }

et

   1: public delegate void ItemRemovedHandler(object sender, ItemRemovedEventArgs<K, V> e); 
   2:  
   3: public event ItemRemovedHandler ItemRemoved;


Bon, fini les plaisanteries, maintenant on veut voir du code ! Voici le code qui s'occupe de la suppression tel qu'il est présent dans mon projet :

   1: private void timer_Elapsed(object sender, ElapsedEventArgs e)
   2: {
   3:     // If a scavenging is currently being processed,
   4:     // don't try to do one too to avoid a longer lock on the dictionnary
   5:     if (!_isScavenging)
   6:     {
   7:         _isScavenging = true;
   8:  
   9:         // Check for old entries
  10:         _lock.EnterUpgradeableReadLock();
  11:         try
  12:         {
  13:             List<K> keysToDelete = new List<K>();
  14:             DateTime currentDatetime = DateTime.Now;
  15:  
  16:             // TODO: Change the foreach to a while for better performance
  17:             foreach (KeyValuePair<K, CacheItem> pair in _items)
  18:             {
  19:                 if ((pair.Value.LastRefresh + pair.Value.Duration) < currentDatetime)
  20:                 {
  21:                     keysToDelete.Add(pair.Key);
  22:                 }
  23:             }
  24:             if (keysToDelete.Count > 0)
  25:             {
  26:                 _lock.EnterWriteLock();
  27:                 try
  28:                 {
  29:                     for (int j = 0; j < keysToDelete.Count; j++)
  30:                     {
  31:                         V value = _items[keysToDelete[j]].Item;
  32:                         if (ItemRemoved != null)
  33:                         {
  34:                             ItemRemovedEventArgs<K, V> ea = new ItemRemovedEventArgs<K, V>(keysToDelete[j], value);
  35:                             ItemRemoved(this, ea);
  36:                             if (!ea.Cancel)
  37:                             {
  38:                                 _items.Remove(keysToDelete[j]);
  39:                             }
  40:                         }
  41:                         else
  42:                         {
  43:                             _items.Remove(keysToDelete[j]);
  44:                         }
  45:                     }
  46:                 }
  47:                 finally
  48:                 {
  49:                     _lock.ExitWriteLock();
  50:                 }
  51:             }
  52:         }
  53:         finally
  54:         {
  55:             _lock.ExitUpgradeableReadLock();
  56:         }
  57:  
  58:         _isScavenging = false;
  59:     }
  60: }

Petite remarque qui a je trouve son importance, on fait toute cette manip en deux temps :

  1. Je recherche tous les éléments que je dois supprimer
  2. Je supprime les éléments

Pourquoi faire la chose en deux fois ? Tout d'abord parce qu'un Dictionary ne permet pas de supprimer un élément lorsque l'on est en train de parcourir sa collection (avec un foreach), mais surtout aussi, afin de ne locker la collection qu'en lecture au début, et n'acquérir le lock en écriture que si il y a besoin de faire des modifications. Grâce à cela on permet des locks plus brefs.


Et voilà, si je ne me suis pas vautré dans mes explications, tout cela devrait permettre d'avoir un cache qui fonctionne plutôt pas mal. Personnellement j'ai effectué quelques tests afin de vérifier le bon fonctionnement de ce code (oui je vais éviter de raconter des conneries si possible) et mon code a mis sur mon pc environ 13 secondes pour faire 12 500 000 accès (moitié en lecture, moitié en écriture).

Le protocole étant grossièrement 500 threads qui en parallèle font 25 000 tentatives chacun sur le cache (les lectures et écritures étant réparties aléatoirement sur l'ensemble du test). Si on considère qu'une lecture prends deux fois moins de temps qu'un ajout, et que mes process sont répartis également sur mes dual core, on obtient qu'une lecture prends 0,025 ms et une écriture 0,050 ms. Et tout ça sans aucune erreur ni inter-blocage :-)

Une partie de Cache - Cache / Partie 2

Et nous revoilà pour la seconde partie de ce post consacré au Cache tant décrié ;-) .

Afin de pouvoir stocker des éléments dans notre cache, nous allons devoir encapsuler notre objet Obj dans une classe générique (private au sein de la classe de Cache) qui contiendra la dernière date d'accès à Obj ainsi que sa durée de vie dans le cache. En voici le descriptif :

   1: private class CacheItem


   2: {


   3:     public V Item;


   4:     public DateTime LastRefresh;


   5:     public readonly bool SlidingExpiration;


   6:     public readonly TimeSpan Duration;


   7:  


   8:     public CacheItem(V value, bool slidingExpiration, TimeSpan duration)


   9:     {


  10:         Item = value;


  11:         LastRefresh = DateTime.Now;


  12:         SlidingExpiration = slidingExpiration;


  13:         Duration = duration;


  14:     }


  15: }




Si on se souvient du post précédent on peut voir rapidement que l'on reprend le type générique fourni dans la définition de notre cache (ie. Cache<K, V>).



On peut se demander pourquoi ne pas stocker directement la date d'expiration de l'objet plutôt que la date du dernier accès. Tout simplement parce que notre cache permet une SlidingExpiration (ie. prolonger la durée de vie de l'objet à chaque accès chaque fois que ce dernier est requêté). Cette SlidingExpiration imposerait de recalculer continuellement une nouvelle date d'expiration ce qui coûterait cher en terme de temps processeur.



Dans le post précédent, j'avais indiqué que notre cache allait implémenter l'interface IDictionnary. Or cette interface ne prévoit pas dans ses méthodes un argument définissant une durée de vie, ni la SlidingExpiration. Il faut donc pouvoir définir des valeurs par défaut lorsque ces méthodes seront appelées. Cela sera possible grâce aux propriétés suivante dans notre cache.





   1: public TimeSpan DefaultDuration


   2: {


   3:     get { return _defaultDuration; }


   4:     set { _defaultDuration = value; }


   5: }


   6:  


   7: public bool DefaultSlidingExpiration


   8: {


   9:     get { return _defaultSlidingExpiration; }


  10:     set { _defaultSlidingExpiration = value; }


  11: }




 



Mettre ici l'ensemble des méthodes que les différentes interfaces nous imposent d'implémenter nous prendrait un peu trop de place, et surtout je vais pas faire tout le boulot... :-) Je me contenterai seulement de mettre deux exemples (l'ajout et la récupération de valeur) qui illustre tout ce dont j'ai déjà parlé (le ReaderWriterLockSlim ainsi que notre classe générique) :





   1: public void Add(K key, V value)


   2: {


   3:     _lock.EnterWriteLock();


   4:     try


   5:     {


   6:         _items.Add(key, new CacheItem(value, _defaultSlidingExpiration, _defaultDuration));


   7:     }


   8:     finally


   9:     {


  10:         _lock.ExitWriteLock();


  11:     }


  12: }


  13:  


  14: ...


  15:  


  16: public bool TryGetValue(K key, out V value)


  17: {


  18:     _lock.EnterReadLock();


  19:     try


  20:     {


  21:         CacheItem ci;


  22:         if (_items.TryGetValue(key, out ci))


  23:         {


  24:             if (ci.SlidingExpiration)


  25:             {


  26:                 ci.LastRefresh = DateTime.Now;


  27:             }


  28:             value = ci.Item;


  29:             return true;


  30:         }


  31:     }


  32:     finally


  33:     {


  34:         _lock.ExitReadLock();


  35:     }


  36:     value = default(V);


  37:     return false;


  38: }




Et voilà, grâce à tout ça, et surtout si tu es motivé, tu devrais être capable d'avoir un cache qui se remplit sans problème mais qui est aussi et surtout entièrement Thread-Safe.



 



Dans un dernier post, nous verrons ensemble la touche finale de notre cache : comment faire expirer les éléments de notre cache.