[C#] Une implémentation du pattern WeakEvent

Très mauvaisMauvaisMoyenBonExcellent (3 votes) 
Loading...

Comme vous le savez peut-être, la mauvaise utilisation des évènements est l’une des principales causes de fuites mémoires dans une application .NET : en effet, un évènement garde des références aux objets qui y sont abonnés (via le delegate), ce qui empêche le garbage collector de collecter ces objets quand ils ne sont plus utilisés. Le problème est particulièrement vrai pour un évènement statique, puisque les références sont conservées pendant toute l’exécution de l’application. Si on crée de nombreux objets qui s’abonnent à un évènement statique et qu’on ne les désabonne pas, ils restent indéfiniment en mémoire, même si on n’en a plus besoin depuis longtemps, ce qui peut finir par saturer la mémoire.

La solution “évidente” au problème est bien sûr de désabonner les objets qui ne sont plus utilisés. Malheureusement, il n’y a pas toujours de moyen simple de savoir à quel moment on peut désabonner un objet. Une autre approche est d’implémenter le pattern WeakEvent, qui permet de ne garder qu’une référence faible vers les objets abonnés à l’évènement, de façon à ne pas empêcher le garbage collector de les collecter. Microsoft inclut dans WPF des éléments pour implémenter le pattern WeakEvent, et explique comment créer ses propres évènements selon ce pattern, à l’aide de la classe WeakEventManager et de l’interface IWeakEventListener. Cependant, cette technique est assez lourde à mettre en œuvre, aussi bien pour exposer un tel évènement (il faut créer une nouvelle classe dédiée) que pour s’abonner à l’évènement (implémentation de IWeakEventListener).

J’ai donc réfléchi à une autre solution, permettant d’implémenter plus facilement le pattern WeakEvent. Ma première idée était d’utiliser une liste de WeakReference pour stocker la liste des delegates abonnés à l’évènement. Malheureusement, lorsqu’on s’abonne à un évènement, on écrit généralement quelque chose comme ça :

myObject.MyEvent += new EventHandler(myObject_MyEvent);

On crée donc un delegate, mais on ne garde aucune référence dessus. Puisque l’évènement ne référence ce delegate que via une WeakReference, rien n’empêche le garbage collector de le collecter… et c’est effectivement ce qui arrive. Au bout d’un temps variable (pas plus de quelques secondes d’après mes observations), le delegate est collecté et n’est donc plus appelé quand l’évènement est déclenché.

Plutôt que de conserver une référence faible vers le delegate lui même, une meilleure solution serait de faire une référence faible sur l’objet qui implémente la méthode (Delegate.Target). J’ai donc créé une classe WeakDelegate<TDelegate> pour gérer cela :

    public class WeakDelegate<TDelegate> : IEquatable<TDelegate>
    {
        private WeakReference _targetReference;
        private MethodInfo _method;

        public WeakDelegate(Delegate realDelegate)
        {
            if (realDelegate.Target != null)
                _targetReference = new WeakReference(realDelegate.Target);
            else
                _targetReference = null;
            _method = realDelegate.Method;
        }

        public TDelegate GetDelegate()
        {
            return (TDelegate)(object)GetDelegateInternal();
        }

        private Delegate GetDelegateInternal()
        {
            if (_targetReference != null)
            {
                return Delegate.CreateDelegate(typeof(TDelegate), _targetReference.Target, _method);
            }
            else
            {
                return Delegate.CreateDelegate(typeof(TDelegate), _method);
            }
        }

        public bool IsAlive
        {
            get { return _targetReference == null || _targetReference.IsAlive; }
        }


        #region IEquatable<TDelegate> Members

        public bool Equals(TDelegate other)
        {
            Delegate d = (Delegate)(object)other;
            return d != null
                && d.Target == _targetReference.Target
                && d.Method.Equals(_method);
        }

        #endregion

        internal void Invoke(params object[] args)
        {
            Delegate handler = (Delegate)(object)GetDelegateInternal();
            handler.DynamicInvoke(args);
        }
    }

Il ne reste plus qu’à gérer une liste de WeakDelegate<TDelegate>, ce que fait la classe WeakEvent<TDelegate> :

    public class WeakEvent<TEventHandler>
    {
        private List<WeakDelegate<TEventHandler>> _handlers;

        public WeakEvent()
        {
            _handlers = new List<WeakDelegate<TEventHandler>>();
        }

        public virtual void AddHandler(TEventHandler handler)
        {
            Delegate d = (Delegate)(object)handler;
            _handlers.Add(new WeakDelegate<TEventHandler>(d));
        }

        public virtual void RemoveHandler(TEventHandler handler)
        {
            // also remove "dead" (garbage collected) handlers
            _handlers.RemoveAll(wd => !wd.IsAlive || wd.Equals(handler));
        }

        public virtual void Raise(object sender, EventArgs e)
        {
            var handlers = _handlers.ToArray();
            foreach (var weakDelegate in handlers)
            {
                if (weakDelegate.IsAlive)
                {
                    weakDelegate.Invoke(sender, e);
                }
                else
                {
                    _handlers.Remove(weakDelegate);
                }
            }
        }

        protected List<WeakDelegate<TEventHandler>> Handlers
        {
            get { return _handlers; }
        }
    }

Cette classe gère automatiquement la suppression des handlers “morts” (collectés), et fournit une méthode Raise pour faciliter le déclenchement de l’évènement. Elle peut s’utiliser de la façon suivante :

        private WeakEvent<EventHandler> _myEvent = new WeakEvent<EventHandler>();
        public event EventHandler MyEvent
        {
            add { _myEvent.AddHandler(value); }
            remove { _myEvent.RemoveHandler(value); }
        }

        protected virtual void OnMyEvent()
        {
            _myEvent.Raise(this, EventArgs.Empty);
        }

C’est un peu plus long à écrire qu’un évènement “classique”, mais ce n’est finalement pas grand chose par rapport aux avantages que ça apporte… D’ailleurs, on peut facilement créer un “code snippet” pour Visual Studio, qui permet de créer un “évènement faible” en un rien de temps, avec seulement 3 informations à renseigner :

<?xml version="1.0" encoding="utf-8" ?>
<CodeSnippets  xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
  <CodeSnippet Format="1.0.0">
    <Header>
      <Title>wevt</Title>
      <Shortcut>wevt</Shortcut>
      <Description>Code snippet for a weak event</Description>
      <Author>Thomas Levesque</Author>
      <SnippetTypes>
        <SnippetType>Expansion</SnippetType>
      </SnippetTypes>
    </Header>
    <Snippet>
      <Declarations>
        <Literal>
          <ID>type</ID>
          <ToolTip>Event type</ToolTip>
          <Default>EventHandler</Default>
        </Literal>
        <Literal>
          <ID>event</ID>
          <ToolTip>Event name</ToolTip>
          <Default>MyEvent</Default>
        </Literal>
        <Literal>
          <ID>field</ID>
          <ToolTip>Name of the field holding the registered handlers</ToolTip>
          <Default>_myEvent</Default>
        </Literal>
      </Declarations>
      <Code Language="csharp">
        <![CDATA[private WeakEvent<$type$> $field$ = new WeakEvent<EventHandler>();
        public event $type$ $event$
        {
            add { $field$.AddHandler(value); }
            remove { $field$.RemoveHandler(value); }
        }

        protected virtual void On$event$()
        {
            $field$.Raise(this, EventArgs.Empty);
        }
	$end$]]>
      </Code>
    </Snippet>
  </CodeSnippet>
</CodeSnippets>

Ce qui donne dans Visual Studio le résultat suivant :

Code snippet pour implémenter un WeakEvent

6 Comments

  1. Jérémy says:

    Salut Thomas,

    Merci pour cet article sympathique. Tu expliques bien la problématique et ta solution pour ce problème qui à mon avis est trop souvent “omis” (peut être volontairement) par les développeurs.

    Ta solution ressemble à celle proposée sur CodeProject (http://www.codeproject.com/KB/cs/WeakEvents.aspx) que j”avais vue il y a quelques mois. L”auteur complète ta proposition avec un mécanisme pour ne pas trop perdre en performance (à cause de la réflection pour l”appel du delegate).

    Encore merci pour ce chouette article !
    A+

    Jérémy

    • Salut Jérémy,

      Merci pour ton retour, et pour le lien ! C”est vrai que ça ressemble en partie à ce que j”ai fait, mais c”est plus approfondi…

      Je n”ai pas encore fait de tests de perfs, et je crains effectivement que le Delegate.CreateDelegate soit un peu lourd… Je vais me pencher sur l”implémentation proposée sur CodeProject, je pourrai peut-être en tirer quelque chose d”utile !

    • J”ai regardé l”article CodeProject ; j”y connais pas grand chose en MSIL, mais d”après ce que je comprends, il teste si la référence est valide, et il appelle la méthode du delegate en lui passant les paramètres. Rien de très méchant…

      J”ai essayé de suivre le même principe, mais sans génération d”IL. En gros, si j”appelle MethodInfo.Invoke au lieu de créer un delegate et de l”appeler avec DynamicInvoke, je multiplie les perfs par 7.5… Donc c”est pas aussi efficace que son système à base de Reflection.Emit, mais ça fait déjà un gain non négligeable.

  2. cinemania says:

    En réalité tu ne pourra jamais obtenir les mêmes temps que les sources de CodeProject sans utiliser toi même la LCG.
    En effet, dans ton processus ce qui coute du temps ce n”est pas le “Delegate.CreateDelegate” mais le “handler.DynamicInvoke(args)” dans ton WeakDelegate.
    En utilisant MethodInfo.Invoke tu as légèrement amélioré le temps mais tu es encore très loin des temps d”un appel de délégué ou d”un appel direct, car tu utilise l”invocation dynamique de la reflection, qui est un must en terme de performances désastreuses.
    En réalité, même la méthode utilisant la LCG ne peut pas garantir les mêmes performances qu”avec les vrais Events car tu cré un délégué pour en appeler un autre… ce délégué insère un léger temps d”exécution et de latence, même s”il coute peu… ce n”est pas totalement négligeable.

  3. JYL says:

    Ouille ! Attention aux races conditions :

    if (weakDelegate.IsAlive)
    weakDelegate.Invoke(sender, e);

    ==> si le GC libère l”objet entre l”instruction IsAlive et l”instruction Invoke, il y aura une belle exception…

1 Trackbacks

Leave a comment

css.php