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).
GO TO FULL VERSION