CodeGym /Corsi /C# SELF /Ottimizzazione della programmazione basata su eventi

Ottimizzazione della programmazione basata su eventi

C# SELF
Livello 54 , Lezione 2
Disponibile

1. Introduzione

Nella maggior parte delle applicazioni tipiche gli eventi funzionano velocemente e quasi "gratuitamente" — CLR (Common Language Runtime) è molto ottimizzata per gestirli. Tuttavia, quando un'applicazione cresce, gli eventi diventano numerosi, le catene di subscriber si allungano e i requisiti di performance aumentano, può emergere che anche una costruzione "semplice" come gli eventi può diventare un collo di bottiglia. Questo è particolarmente evidente in sistemi con molti aggiornamenti real-time, interfacce utente (UI) o quando si elaborano centinaia di migliaia di notifiche da sensori in applicazioni IoT.

In questa lezione vedremo:

  • Come eventi e delegati influenzano le prestazioni.
  • Quali sono i punti critici.
  • Come scrivere codice ad eventi veloce ed evitare problemi che impattano le performance.

Internals degli eventi in .NET

Come già accennato, un evento è un wrapper attorno a un delegato. Un delegato è un oggetto speciale che contiene una lista di metodi (invocation list) che vengono chiamati durante l'invocazione. Ad ogni chiamata dell'evento la CLR itera quella lista e invoca sincronicamente tutti i metodi. (L'asincronia appare solo se aggiungete manualmente codice asincrono.)

Schema descrittivo:


[Publisher] ----- (event) ---> [Delegate (Invocation List)] --> [Handler 1]
                                                           --> [Handler 2]
                                                           --> [Handler N]

2. Costo di delegati e eventi: scomposizione

Costo dello storage

  • Ogni delegato è un oggetto a tutti gli effetti.
  • Ogni handler (metodo subscriber) crea un altro delegato.
  • Più subscriber = più oggetti = più memoria utilizzata.

Nei casi semplici l'overhead o eventuali leak sono trascurabili. Ma se hai migliaia di handler, vale la pena pensarci!

Costo della chiamata

  • La chiamata di un evento = iterazione dell'invocation list.
  • Ogni metodo viene chiamato sincronicamente (uno dopo l'altro).
  • Se un handler fa lavoro pesante o "dorme" a lungo, rallenta tutti gli altri.

Esempio: implementazione semplice


public class Counter
{
    public event EventHandler Counted;

    public void Increment()
    {
        // ... omettiamo la logica di conteggio
        // I subscriber vengono chiamati sincronicamente!
        Counted?.Invoke(this, EventArgs.Empty);
    }
}

Se abbiamo 1000 subscriber i cui handler fanno Thread.Sleep(10), la chiamata dell'evento durerà circa 10 secondi...

3. Subscriber "pesanti" — il nemico delle performance

Perché gli handler dovrebbero essere "leggeri"?

  • Gli eventi vengono chiamati sincronicamente, il thread chiamante aspetta la fine di tutti gli handler.
  • Un handler lento rallenta tutta la catena.
  • Se un handler può "crollare" con un'eccezione — gli altri potrebbero non essere invocati (se non proteggi la chiamata con try/catch).

Dimostrazione


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

        // Misuriamo il tempo
        var watch = System.Diagnostics.Stopwatch.StartNew();
        publisher.Increment();
        watch.Stop();
        Console.WriteLine($"Tutti gli handler sono stati chiamati in {watch.ElapsedMilliseconds} ms.");
    }
}

Provatelo: vedrete una pausa significativa. Il primo handler è quasi immediato, il secondo introduce il "ritardo" e solo dopo viene eseguito il terzo.

Conclusione pratica

  • Non mettere logica di business pesante direttamente negli handler degli eventi!
  • Meglio spostare quel lavoro in un thread separato, in un task o usare handler asincroni.

4. Eccezioni negli handler: trappole per le performance

Se uno dei subscriber lancia un'eccezione, l'elaborazione dell'evento viene interrotta — i successivi handler potrebbero non essere chiamati!


publisher.Counted += (s, e) => throw new Exception("Errore!");
publisher.Counted += (s, e) => Console.WriteLine("Non vedrai questa riga.");

Per evitare ciò e non bloccare tutto a causa di una "mela marcia", usa l'iterazione manuale proteggendo ogni handler.

Versione avanzata della chiamata eventi


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($"Errore nell'handler: {ex.Message}");
                // Logging, o gestione speciale dell'errore
            }
        }
    }
}

Questo rende l'evento più "resiliente": anche se un subscriber fallisce, gli altri vengono eseguiti.

5. Eventi asincroni (fire-and-forget)

Se un evento può essere lento, a volte vuoi lanciare gli handler in thread o task separati per non bloccare il thread principale.

Opzione 1: lanciare ogni handler in un task separato


protected virtual void OnCountedAsync()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            // Fire-and-forget: non aspettiamo il completamento!
            System.Threading.Tasks.Task.Run(() =>
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            });
        }
    }
}

Attenzione al parallelismo

  • Se i subscriber condividono una risorsa comune — possono verificarsi race conditions.
  • Le eccezioni in handler fire-and-forget sono difficili da catturare.
  • Se è importante aspettare la fine di tutti i subscriber — raccogli i task e usa Task.WhenAll.

Per UI (WinForms/WPF) — non chiamare mai gli handler fuori dal UI-thread, altrimenti otterrai InvalidOperationException.

In generale — gli eventi asincroni richiedono un design attento!

6. Ottimizzazione dello storage e della chiamata degli eventi

Eventi "vuoti": risparmiare memoria

Se nella tua classe ci sono molti eventi, la maggior parte dei quali usati raramente (per esempio tanti eventi in un componente UI), c'è un trucco: EventHandlerList.

Come funziona

I controlli .NET (per esempio in WinForms) non tengono un delegato separato per ogni evento, ma mettono tutti gli eventi in una struttura (EventHandlerList) — solo se almeno un handler è registrato.

Esempio di creazione manuale di EventHandlerList

using System.ComponentModel; // EventHandlerList vive qui!

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);
    }
}

Perché farlo: risparmi memoria, evitando di creare delegati inutili per centinaia di eventi "vuoti".

7. Thread-safety: evitare race e lock

Gli eventi in .NET di per sé NON sono thread-safe! Mentre un subscriber si iscrive o si disiscrive, un altro thread può nel frattempo lanciare l'evento. Questo può portare al fatto che il delegato diventi null immediatamente prima della chiamata, causando un NullReferenceException.

Best practice

  • Usa l'operatore ?. (Counted?.Invoke(...)) — protegge dal null.
  • Per casi complessi — proteggi l'accesso all'evento con lock.

Esempio


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);
}

Quando serve questa complessità?

  • In applicazioni multithread (ad esempio server, parser multithread, ecc.).
  • Se l'iscrizione/disiscrizione avviene da thread diversi e l'evento viene invocato da un altro.

8. Accessor add/remove per controllo e ottimizzazione

In casi particolari (per esempio se vuoi loggare tutte le iscrizioni o limitare il numero di subscriber) puoi implementare l'evento manualmente tramite accessor:


private EventHandler _event;
public event EventHandler MyEvent
{
    add
    {
        if (_event == null || _event.GetInvocationList().Length < 10)
            _event += value;
        else
            Console.WriteLine("Limitazione: non è possibile avere più di 10 subscriber.");
    }
    remove { _event -= value; }
}

Questo permette di:

  • Iniettare logica custom.
  • Rendere gli eventi thread-safe.
  • Controllare limiti o loggare iscrizioni/disiscrizioni.

9. Dettagli utili

Lambda, closure e performance

Le lambda sono comode per iscriversi "al volo":


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

Ma se la lambda cattura variabili — si crea una closure, che può aumentare l'uso di memoria. Nella maggior parte dei casi UI non è un problema, ma in codice a basso livello è bene monitorare il numero di closure e il lifetime degli oggetti catturati.

Curiosità:
Se aggiungi due lambda identiche in sequenza, saranno due oggetti delegato distinti e il metodo verrà eseguito due volte.

Profiling di eventi e delegati

Quando un'app diventa grande e complessa, è necessario profilare gli eventi come qualsiasi altro codice.

Come misurare la velocità di un evento?

  • Usa Stopwatch per misurare il tempo tra la chiamata dell'evento e il completamento degli handler.
  • Usa strumenti di profiling della memoria (per esempio dotMemory, strumenti integrati di Visual Studio) per trovare subscriber che non sono stati rimossi e rimangono in memoria.
  • Per cercare "subscriber zombie" guarda liste lunghe di invocation list su oggetti long-lived.

Tabella "Ottimizzazioni e trappole"

Problema/Scenario Soluzione
Molti eventi long-lived (e inutili) Usare EventHandlerList
Un subscriber rallenta tutti Spostare logica pesante in task/thread separato
Thread-safety Copiare il delegato prima della chiamata, usare lock in add/remove
Eccezioni negli handler Catturare in try/catch attorno a ogni handler
Leaks di memoria da "subscriber zombie" Disiscriversi sempre, implementare IDisposable, profilare

Diagramma: "Ciclo di vita di un evento ottimizzato"


+----------------+       +------------------+       +---------------------+
| Subscriber crea|  -->  | Iscrizione (+=)  |  -->  | Entra in Invocation |
+----------------+       +------------------+       +---------------------+
                                |                                ^
                                |                                |
                   Disiscrizione (-=) |                    Eccezione |
                                v                                |
+----------------+       +--------------------+      +----------------------+
| Subscriber Dispo|  -->  | Rimosso dalla lista|  --> | Non è più uno zombie |
+----------------+       +--------------------+      +----------------------+

10. Come spiegare "gestione eventi" in un colloquio

Se ti viene chiesto "Perché gli eventi in C# possono essere inefficienti?" o "Quando serve ottimizzare gli eventi?", rispondi:

  • Gli eventi sono utili per loose coupling, ma inefficienti con molte iscrizioni o handler pesanti.
  • Non sono thread-safe di default.
  • Richiedono disiscrizione manuale (altrimenti ci sono leak di memoria).
  • Per scenari con grandi numeri di producer/consumer — usare EventHandlerList e accessor personalizzati add/remove.
  • Il controllo fine serve raramente — nella maggior parte dei casi lo standard pattern è sufficiente.

Nella prossima lezione analizzeremo scenari avanzati e esempi pratici di programmazione con eventi e delegati, dove vedrete come tutte queste ottimizzazioni si applicano a casi reali.

Miti comuni e anti-pattern

  • Pensare che gli eventi in .NET siano sempre veloci — lo sono finché non ci sono molti subscriber o handler pesanti.
  • Contare sul GC che "pulisca tutto" — no, se non ti disiscrivi, l'oggetto resterà in vita!
  • Usare eventi per collegamenti "lontani" tra layer di business — meglio pattern espliciti (per esempio Mediator).
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION