CodeGym /Cours /C# SELF /Optimisation de la programmation événementielle

Optimisation de la programmation événementielle

C# SELF
Niveau 54 , Leçon 2
Disponible

1. Introduction

Dans la plupart des applications typiques, les événements fonctionnent rapidement et sont presque "gratuits" — le CLR (Common Language Runtime) est très bien optimisé pour les gérer. Cependant, quand l'application devient grosse, qu'il y a beaucoup d'événements, des chaînes d'abonnés longues et des exigences de performance qui augmentent, on découvre soudain : même une construction "simple" comme les événements peut créer un goulot d'étranglement. C'est particulièrement visible dans les systèmes avec beaucoup de mises à jour real-time, les interfaces utilisateur (UI), ou lors du traitement de centaines de milliers de notifications de capteurs dans des applications IoT.

Dans cette leçon nous verrons :

  • Comment les événements et les délégués impactent les performances.
  • Quels sont les points sensibles.
  • Comment écrire du code événementiel rapide et éviter les problèmes qui nuisent aux performances.

Implémentation interne des événements dans .NET

Comme déjà mentionné, un événement est un wrapper autour d'un délégué. Un délégué est un objet spécial contenant une liste de méthodes (invocation list) qui sont appelées lors de l'invocation. À chaque appel d'un événement le CLR parcourt cette liste et appelle toutes les méthodes de façon synchrone. (L'asynchronisme n'apparaît que si vous ajoutez manuellement du code asynchrone.)

Schéma illustratif :


[Éditeur] ----- (event) ---> [Delegate (Invocation List)] --> [Gestionnaire 1]
                                                           --> [Gestionnaire 2]
                                                           --> [Gestionnaire N]

2. Coût des délégués et des événements : analyse atomique

Coût de stockage

  • Chaque délégué est un objet à part entière.
  • Chaque gestionnaire (méthode-abonnée) crée un autre délégué.
  • Plus il y a d'abonnés — plus il y a d'objets, plus la mémoire augmente.

Dans les cas simples, il n'y a presque pas de fuites ou d'overhead. Mais si les gestionnaires sont des milliers — il faut s'en préoccuper !

Coût d'appel

  • Appeler un événement = parcourir l'invocation list.
  • Chaque méthode est appelée de façon synchrone (les unes après les autres).
  • Si un gestionnaire fait un travail lourd ou "dort" longtemps, il ralentit tous les autres.

Exemple : implémentation simple


public class Counter
{
    public event EventHandler Counted;

    public void Increment()
    {
        // ... on omet la logique de comptage
        // Les abonnés sont appelés de façon synchrone !
        Counted?.Invoke(this, EventArgs.Empty);
    }
}

Si nous avons 1000 abonnés dont les gestionnaires font Thread.Sleep(10), l'appel de l'événement prendra environ 10 secondes...

3. Les abonnés "lourds" — l'ennemi des performances

Pourquoi les gestionnaires doivent être "légers" ?

  • Les événements sont appelés de façon synchrone, le thread appelant attend la fin de tous les gestionnaires.
  • Un gestionnaire lent ralentit toute la chaîne.
  • Si un gestionnaire peut "tomber" avec une exception — les suivants peuvent ne pas être appelés (si vous ne protégez pas l'appel avec un try/catch).

Démo


class Program
{
    static void Main()
    {
        var publisher = new Counter();
        // Rapide
        publisher.Counted += (s, e) => Console.WriteLine("First");
        // Lent
        publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
        // Encore un
        publisher.Counted += (s, e) => Console.WriteLine("Last");

        // Mesure du temps
        var watch = System.Diagnostics.Stopwatch.StartNew();
        publisher.Increment();
        watch.Stop();
        Console.WriteLine($"Tous les gestionnaires se sont exécutés en {watch.ElapsedMilliseconds} ms.");
    }
}

Essayez d'exécuter — vous verrez une pause notable. Le premier gestionnaire est presque instantané, le deuxième introduit la "latence", et seulement ensuite le troisième.

Conclusion pratique

  • Ne mettez pas de logique métier lourde directement dans les gestionnaires d'événements !
  • Mieux vaut déplacer ce travail dans un thread séparé, une task ou un gestionnaire asynchrone.

4. Exceptions dans les gestionnaires : pièges pour la performance

Si un des abonnés lance une exception, le traitement des événements est interrompu — les gestionnaires suivants peuvent ne pas être appelés !


publisher.Counted += (s, e) => throw new Exception("Erreur !");
publisher.Counted += (s, e) => Console.WriteLine("Vous ne verrez pas cette ligne.");

Pour éviter cela et ne pas ralentir le flux à cause d'une "mauvaise pomme", utilisez un parcours manuel avec protection pour chaque gestionnaire.

Version avancée d'appel d'événements


protected virtual void OnCounted()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            try
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Erreur dans le gestionnaire : {ex.Message}");
                // Logging, ou traitement spécial de l'erreur
            }
        }
    }
}

Cela rend l'événement plus "résilient" : même si un abonné plante — les autres fonctionnent.

5. Événements asynchrones (fire-and-forget)

Si un événement peut être lent — parfois on veut lancer les gestionnaires dans des threads ou tasks séparés, pour ne pas bloquer le thread principal.

Option 1 : lancer chaque gestionnaire dans une task séparée


protected virtual void OnCountedAsync()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            // Fire-and-forget : on n'attend pas la fin !
            System.Threading.Tasks.Task.Run(() =>
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            });
        }
    }
}

Attention au parallélisme

  • Si les abonnés utilisent une ressource partagée — des race conditions sont possibles.
  • Les exceptions dans les gestionnaires fire-and-forget sont difficiles à attraper.
  • Si il est important d'attendre la fin de tous les abonnés — il faut collecter les tasks et utiliser Task.WhenAll.

Pour l'UI (WinForms/WPF) — ne jamais appeler les gestionnaires en dehors du thread UI, sinon on obtiendra une InvalidOperationException.

En général — les événements asynchrones demandent un design soigné et réfléchi !

6. Optimisation du stockage et de l'appel des événements

Événements "vides" : économiser la mémoire

Si votre classe a beaucoup d'événements, la plupart rarement utilisés (par exemple, de nombreux événements dans un composant UI), il y a une astuce : EventHandlerList.

Comment ça marche

Les contrôles .NET (par exemple en WinForms) ne stockent pas un délégué séparé pour chaque événement, mais mettent tous les événements dans une seule structure (EventHandlerList) — seulement si au moins un gestionnaire est abonné.

Exemple de création manuelle d'un EventHandlerList

using System.ComponentModel; // EventHandlerList vit ici !

class MyControl
{
    private readonly EventHandlerList _events = new EventHandlerList();

    private static readonly object EventMyEvent = new object();

    public event EventHandler MyEvent
    {
        add    { _events.AddHandler(EventMyEvent, value); }
        remove { _events.RemoveHandler(EventMyEvent, value); }
    }

    protected virtual void OnMyEvent()
    {
        var handler = (EventHandler)_events[EventMyEvent];
        handler?.Invoke(this, EventArgs.Empty);
    }
}

Pourquoi faire ça : vous économisez de la mémoire en ne créant pas de délégués inutiles pour des centaines d'événements "vides".

7. Thread-safety : éviter les races et les locks

Les événements dans .NET ne sont pas thread-safe par défaut ! Pendant qu'un abonné s'abonne ou se désabonne, un autre thread peut déclencher l'événement. Cela peut conduire à ce que le délégué devienne null juste avant l'appel, ce qui provoquerait une NullReferenceException.

Bonnes pratiques

  • Utilisez l'opérateur ?. (Counted?.Invoke(...)) — protège du null.
  • Pour les cas complexes — verrouillez l'accès à l'événement via lock.

Exemple


private readonly object _lockObj = new object();
private EventHandler _myEvent;

public event EventHandler MyEvent
{
    add { lock (_lockObj) { _myEvent += value; } }
    remove { lock (_lockObj) { _myEvent -= value; } }
}

protected virtual void OnMyEvent()
{
    EventHandler handler;
    lock (_lockObj)
    {
        handler = _myEvent;
    }
    handler?.Invoke(this, EventArgs.Empty);
}

Quand cette complexité est-elle nécessaire ?

  • Dans des applications multithread (par ex. serveurs, parseurs multithread, etc.).
  • Si l'abonnement/désabonnement vient de threads différents et l'appel d'événement d'un autre.

8. Accessors add/remove pour le contrôle et l'optimisation

Dans des cas particuliers (par exemple, si vous devez logger tous les abonnements ou limiter le nombre d'abonnés) vous pouvez implémenter l'événement manuellement via des accessors :


private EventHandler _event;
public event EventHandler MyEvent
{
    add
    {
        if (_event == null || _event.GetInvocationList().Length < 10)
            _event += value;
        else
            Console.WriteLine("Limite : plus de 10 abonnés non autorisé.");
    }
    remove { _event -= value; }
}

Cela permet de :

  • Injecter une logique custom.
  • Rendre les événements thread-safe.
  • Vérifier des limites ou logger les abonnements/désabonnements.

9. Nuances utiles

Expressions lambda, closures et performance

Les expressions lambda sont pratiques pour s'abonner "à la volée" :


var button = new Button();
button.Click += (s, e) => Console.WriteLine("Button clicked");

Mais si la lambda capture des variables — un "closure" est créé, ce qui peut augmenter la consommation mémoire. Dans la plupart des cas UI ce n'est pas catastrophique, mais pour du code bas-niveau il faut surveiller le nombre de closures et la durée de vie des objets capturés.

Fait intéressant :
Si vous ajoutez deux lambdas identiques l'une après l'autre, ce seront deux objets-délégués différents, et la méthode s'exécutera deux fois.

Profilage des événements et des délégués

Quand l'application devient grande et complexe, il faut profiler les événements comme n'importe quel autre code.

Comment mesurer la vitesse d'un événement ?

  • Utilisez Stopwatch pour mesurer le temps entre l'appel de l'événement et la fin du traitement.
  • Utilisez des outils de profilage mémoire (par ex. dotMemory, outils intégrés de Visual Studio) pour trouver des abonnés qui n'ont pas été désabonnés et résident en mémoire.
  • Pour trouver des "abonnés-zombies" cherchez de longues invocation list sur des objets long-lived.

Tableau "Optimisations et pièges"

Problème/Scénario Solution
Beaucoup d'événements long-lived (et inutiles) Utiliser EventHandlerList
Un abonné ralentit tout le monde Déplacer la logique lourde dans une task/un thread séparé
Sécurité des threads Copier le délégué avant l'appel, lock lors de l'ajout/suppression
Exceptions dans les gestionnaires Attraper avec try/catch autour de chaque gestionnaire
Fuites mémoire à cause d'abonnés "zombies" Toujours se désabonner, implémenter IDisposable, profiler

Diagramme : "Cycle de vie d'un événement optimisé"


+----------------+       +------------------+       +---------------------+
| Abonné créé    |  -->  | Abonnement (+=)  |  -->  | Dans l'Invocation   |
+----------------+       +------------------+       +---------------------+
                                |                                ^
                                |                                |
                   Désabonnement (-=) |                     Exception |
                                v                                |
+----------------+       +--------------------+      +----------------------+
| Abonné Dispo   |  -->  | Retiré de l'appel |  --> | Plus de zombie       |
+----------------+       +--------------------+      +----------------------+

10. Comment expliquer la "gestion des événements" en entretien

Si on vous pose la question "En quoi les événements en C# sont inefficaces ?" ou "Quand faut-il optimiser les événements ?", vous devez savoir :

  • Les événements sont bons pour le loose coupling, mais inefficaces avec des abonnements massifs et des gestionnaires lourds.
  • Ils ne sont pas thread-safe par défaut.
  • Ils nécessitent une désinscription manuelle (sinon fuites mémoire).
  • Pour des producteurs/consommateurs massifs — EventHandlerList et accessors personnalisés add/remove.
  • Un contrôle poussé est rarement nécessaire — la plupart des cas sont couverts par le pattern standard.

Dans la prochaine leçon nous passerons à l'analyse de scénarios avancés et d'exemples pratiques de programmation événementielle et déléguée, où vous verrez comment toutes ces optimisations fonctionnent sur des cas réels.

Mythes fréquents et antipatterns

  • Penser que les événements en .NET sont toujours rapides — ils le sont tant qu'il n'y a pas beaucoup d'abonnés ou de gestionnaires lourds.
  • Compter sur le GC pour tout "nettoyer" — non, si vous ne vous désabonnez pas, l'objet restera vivant !
  • Utiliser les événements pour des liaisons "distantes" entre couches de logique métier — mieux vaut utiliser des patterns explicites (par exemple Mediator).
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION