1. Introducción
En la mayoría de aplicaciones típicas los eventos funcionan rápido y casi "gratis" — CLR (Common Language Runtime) está muy optimizado para manejarlos. Sin embargo, cuando la aplicación crece, hay muchos eventos, las cadenas de suscriptores se alargan y los requisitos de rendimiento suben, de repente se descubre que incluso una construcción tan "sencilla" como los eventos puede crear un cuello de botella. Esto es especialmente evidente en sistemas con muchas actualizaciones en tiempo real, interfaces de usuario (UI) o al procesar cientos de miles de notificaciones desde sensores en aplicaciones IoT.
En esta lección vamos a ver:
- Cómo los eventos y los delegados afectan al rendimiento.
- Cuáles son los puntos críticos.
- Cómo escribir código de eventos rápido y evitar problemas que dañan el rendimiento.
Estructura interna de los eventos en .NET
Como ya se mencionó, un evento es un envoltorio alrededor de un delegado. Un delegado es un objeto especial que contiene una lista de métodos (invocation list) que se llaman al invocar. En cada llamada al evento CLR recorre esa lista y llama a todos los métodos de forma síncrona. (La asincronía aparece sólo si añades manualmente código asíncrono.)
Esquema visual:
[Publicador] ----- (event) ---> [Delegate (Invocation List)] --> [Manejador 1]
--> [Manejador 2]
--> [Manejador N]
2. Coste de los delegados y eventos: análisis por partes
Coste de almacenamiento
- Cada delegado es un objeto completo.
- Cada manejador (método suscrito) crea otro delegado.
- Cuantos más suscriptores — más objetos, más memoria.
En casos simples las pérdidas o el overhead son casi nulos. Pero si hay miles de manejadores — ya vale la pena preocuparse.
Coste de la invocación
- Llamar al evento = recorrer la invocation list.
- Cada método se llama de forma síncrona (uno tras otro).
- Si un manejador hace trabajo pesado o "duerme" mucho, frena a los demás.
Ejemplo: implementación simple
public class Counter
{
public event EventHandler Counted;
public void Increment()
{
// ... omitiremos la lógica de conteo
// ¡Los suscriptores se llaman de forma síncrona!
Counted?.Invoke(this, EventArgs.Empty);
}
}
Si tenemos 1000 suscriptores, cuyos manejadores usan Thread.Sleep(10), la llamada al evento ya puede tardar alrededor de 10 segundos...
3. Suscriptores "pesados" — enemigo del rendimiento
¿Por qué los manejadores deben ser "ligeros"?
- Los eventos se invocan de forma síncrona; el hilo que lanza espera a que terminen todos los manejadores.
- Un manejador lento frena toda la cadena.
- Si un manejador puede "caer" con una excepción — los siguientes pueden no ejecutarse (si no proteges la invocación con try/catch).
Demostración
class Program
{
static void Main()
{
var publisher = new Counter();
// Rápido
publisher.Counted += (s, e) => Console.WriteLine("First");
// Lento
publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
// Otro más
publisher.Counted += (s, e) => Console.WriteLine("Last");
// Medición de tiempo
var watch = System.Diagnostics.Stopwatch.StartNew();
publisher.Increment();
watch.Stop();
Console.WriteLine($"Todos los manejadores se ejecutaron en {watch.ElapsedMilliseconds} ms.");
}
}
Pruébalo y verás una pausa notable. El primer manejador es casi instantáneo, el segundo introduce la "espera" y sólo después se ejecuta el tercero.
Conclusión práctica
- No metas lógica de negocio pesada directamente en los manejadores de eventos.
- Mejor saca ese trabajo a un hilo separado, una tarea o un manejador asíncrono.
4. Excepciones en los manejadores: trampas para el rendimiento
Si uno de los suscriptores lanza una excepción, el procesamiento de eventos se interrumpe — ¡los manejadores posteriores pueden no ejecutarse!
publisher.Counted += (s, e) => throw new Exception("¡Error!");
publisher.Counted += (s, e) => Console.WriteLine("No verás esta línea.");
Para evitar esto y no paralizar todo por una "manzana podrida", usa un recorrido manual protegiendo cada manejador.
Versión avanzada de invocación de eventos
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($"Error en el manejador: {ex.Message}");
// Logging, o manejo especial del error
}
}
}
}
Esto hace que el evento sea más "resistente": incluso si un suscriptor falla — los demás siguen ejecutándose.
5. Eventos asíncronos (fire-and-forget)
Si un evento puede ser lento — a veces quieres lanzar los manejadores en hilos o tasks separados para no bloquear el hilo principal.
Opción 1: lanzar cada manejador en una tarea separada
protected virtual void OnCountedAsync()
{
var handlers = Counted?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
// Fire-and-forget: ¡no esperamos a que termine!
System.Threading.Tasks.Task.Run(() =>
{
((EventHandler)handler)(this, EventArgs.Empty);
});
}
}
}
¡Pero ojo con el paralelismo!
- Si los suscriptores usan recursos compartidos — pueden aparecer races (race conditions).
- Las excepciones en manejadores fire-and-forget son difíciles de capturar.
- Si necesitas esperar a que terminen todos los suscriptores — recoge las tareas y usa Task.WhenAll.
Para UI (WinForms/WPF) — nunca llames manejadores fuera del hilo de UI, si no obtendrás InvalidOperationException.
En general — los eventos asíncronos requieren un diseño cuidadoso y pensado.
6. Optimización del almacenamiento y la invocación de eventos
Eventos "vacíos": ahorrar memoria
Si tienes en una clase muchos eventos, la mayoría de los cuales se usan raramente (por ejemplo, muchos eventos en un componente UI), hay un truco: EventHandlerList.
Cómo funciona
Los controles .NET (por ejemplo, en WinForms) no guardan un delegado separado para cada evento, sino que ponen todos los eventos en una única estructura (EventHandlerList) — y sólo si hay al menos un manejador suscrito.
Ejemplo de creación manual de EventHandlerList
using System.ComponentModel; // EventHandlerList vive aquí
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);
}
}
¿Por qué esto es útil: ahorras memoria al no crear delegados innecesarios para cientos de eventos "vacíos".
7. Seguridad entre hilos: evitar races y bloqueos
Los eventos en .NET NO son thread-safe por sí mismos. Mientras un suscriptor se está registrando o desregistrando, otro hilo puede iniciar el evento. Esto puede hacer que el delegado sea null justo antes de la invocación, lo que provocaría un NullReferenceException.
Buenas prácticas
- Usa el operador ?. (Counted?.Invoke(...)) — protege contra null.
- En casos complejos — bloquea el acceso al evento con lock.
Ejemplo
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);
}
¿Cuándo hace falta esta complejidad?
- En aplicaciones multihilo (por ejemplo, servidores, parsers multihilo, etc.).
- Si la suscripción/desuscripción viene desde hilos distintos y la invocación desde otro hilo más.
8. Accesores add/remove para control y optimización
En casos especiales (por ejemplo, si necesitas loggear todas las suscripciones o limitar el número de suscriptores) puedes implementar el evento manualmente mediante accesores:
private EventHandler _event;
public event EventHandler MyEvent
{
add
{
if (_event == null || _event.GetInvocationList().Length < 10)
_event += value;
else
Console.WriteLine("Límite: no se permiten más de 10 suscriptores.");
}
remove { _event -= value; }
}
Esto permite:
- Inyectar lógica personalizada.
- Hacer los eventos thread-safe.
- Comprobar límites o loggear suscripciones/desuscripciones.
9. Nudos útiles
Expresiones lambda, closures y rendimiento
Las lambdas son cómodas para suscribirse "al vuelo":
var button = new Button();
button.Click += (s, e) => Console.WriteLine("Button clicked");
Pero si la lambda captura variables — se crea un "closure", lo que puede aumentar el consumo de memoria. En la mayoría de casos UI esto no es grave, pero en código de bajo nivel hay que vigilar el número de closures y el tiempo de vida de los objetos capturados.
Dato interesante:
Si añades dos lambdas idénticas consecutivas, serán dos objetos-delegado diferentes y el método se ejecutará dos veces.
Perfilado de eventos y delegados
Cuando la aplicación crece y se complica, hay que perfilar los eventos igual que cualquier otro código.
¿Cómo medir la velocidad de un evento?
- Usa Stopwatch para medir el tiempo entre la invocación del evento y la finalización del procesamiento.
- Usa herramientas de profiling de memoria (por ejemplo, dotMemory, herramientas integradas de Visual Studio) para encontrar suscriptores que no se han desuscrito y permanecen en memoria.
- Para buscar "suscriptores zombi" busca listas largas de invocation list en objetos de larga vida.
Tabla "Optimizaciones y trampas"
| Problema/Escenario | Solución |
|---|---|
| Muchos eventos de larga vida (y inútiles) | Usar EventHandlerList |
| Un suscriptor frena a todos | Mover lógica pesada a task/hilo separado |
| Seguridad entre hilos | Copiar delegado antes de la invocación, usar lock al añadir/quitar |
| Excepciones en los manejadores | Capturarlas con try/catch alrededor de cada manejador |
| Fugas de memoria por "suscriptores zombi" | Desuscribirse siempre, implementar IDisposable, perfilar |
Diagrama: "Ciclo de vida de un evento optimizado"
+----------------+ +------------------+ +---------------------+
| Suscriptor crea| --> | Suscripción (+=) | --> | Entró en Invocation |
+----------------+ +------------------+ +---------------------+
| ^
| |
Desuscripción (-=) | Excepción |
v |
+----------------+ +--------------------+ +----------------------+
| Suscriptor Dispo| --> | Eliminado de la | --> | Ya no será un zombi |
+----------------+ | lista de invocación| +----------------------+
10. Cómo explicar "gestión de eventos" en una entrevista
Si te preguntan "¿En qué son ineficientes los eventos en C#?" o "¿Cuándo hace falta optimizar eventos?", recuerda:
- Los eventos son buenos para loose coupling, pero ineficientes con suscripciones masivas y manejadores pesados.
- No son thread-safe por defecto.
- Requieren desuscripción manual (si no, hay fugas de memoria).
- Para productores y consumidores masivos — EventHandlerList y accesores personalizados add/remove.
- No hace falta un control muy profundo en la mayoría de casos — el patrón estándar cubre muchas necesidades.
En la siguiente lección pasaremos a escenarios avanzados y ejemplos prácticos de programación con eventos y delegados, donde verás cómo todas estas optimizaciones funcionan en casos reales.
Mitós frecuentes y antipatrons
- Creer que los eventos en .NET siempre son rápidos — lo son hasta que hay muchos suscriptores o manejadores pesados.
- Esperar que el GC lo limpia todo — no: si no te desuscribes, el objeto vivirá indefinidamente.
- Usar eventos para conexiones "lejanísimas" entre capas de negocio — mejor usar patrones explícitos (por ejemplo, Mediator).
GO TO FULL VERSION