1. Introdução
Na maioria das aplicações típicas eventos funcionam rápido e quase "de graça" — o CLR (Common Language Runtime) é bem otimizado para lidar com eles. Mas quando a aplicação cresce, há muitos eventos, as cadeias de assinantes ficam longas e os requisitos de performance aumentam, acontece que até uma construção "simples" como eventos pode virar um gargalo. Isso é especialmente visível em sistemas com muitas atualizações em tempo real, interfaces de usuário (UI) ou quando se processam centenas de milhares de notificações de sensores em aplicações IoT.
Nesta palestra vamos analisar:
- Como eventos e delegates afetam a performance.
- Quais são os gargalos.
- Como escrever código de eventos rápido e evitar problemas que prejudicam a performance.
Estrutura interna de eventos no .NET
Como já foi mencionado, um evento é um wrapper em torno de um delegate. Um delegate é um objeto especial que contém uma lista de métodos (invocation list) que são chamados quando invocado. A cada chamada de evento o CLR percorre essa lista e chama todos os métodos de forma síncrona. (Assincronidade só aparece se você manualmente colocar código assíncrono lá.)
Esquema ilustrativo:
[Publisher] ----- (event) ---> [Delegate (Invocation List)] --> [Handler 1]
--> [Handler 2]
--> [Handler N]
2. Custo dos delegates e eventos: análise atômica
Custo de armazenamento
- Cada delegate é um objeto completo.
- Cada handler (método assinante) cria mais um delegate.
- Quanto mais assinantes — mais objetos e mais memória.
Em casos simples não há muitos vazamentos ou overhead. Mas se houver milhares de handlers — aí já vale pensar!
Custo de invocação
- Chamar um evento = percorrer a invocation list.
- Cada método é chamado de forma síncrona (um por vez).
- Se um handler faz trabalho pesado ou "dorme" por muito tempo, isso atrasa todos os outros.
Exemplo: implementação simples
public class Counter
{
public event EventHandler Counted;
public void Increment()
{
// ... vamos omitir a lógica de contagem
// Os assinantes são chamados de forma síncrona!
Counted?.Invoke(this, EventArgs.Empty);
}
}
Se tivermos 1000 assinantes cuja implementação do handler faz Thread.Sleep(10), a chamada do evento vai levar cerca de 10 segundos...
3. Assinantes "pesados" — inimigos da performance
Por que os handlers devem ser "leves"?
- Eventos são chamados de forma síncrona, a thread que dispara espera a conclusão de todos os handlers.
- Um handler lento atrasa toda a cadeia.
- Se um handler pode lançar uma exceção — os demais podem não ser chamados (a menos que você proteja a chamada com try/catch).
Demostração
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);
// Outro
publisher.Counted += (s, e) => Console.WriteLine("Last");
// Medida de tempo
var watch = System.Diagnostics.Stopwatch.StartNew();
publisher.Increment();
watch.Stop();
Console.WriteLine($"Todos os handlers foram chamados em {watch.ElapsedMilliseconds} ms.");
}
}
Execute e você verá uma pausa perceptível. O primeiro handler é quase instantâneo, o segundo causa o "atraso" e só então o terceiro roda.
Resumo prático
- Não coloque lógica pesada de negócio diretamente em handlers de evento!
- Melhor mover esse trabalho para outra thread, task ou handler assíncrono.
4. Exceções em handlers: armadilhas para performance
Se um dos assinantes lança uma exceção, o processamento do evento é interrompido — handlers subsequentes podem não ser chamados!
publisher.Counted += (s, e) => throw new Exception("Opa!");
publisher.Counted += (s, e) => Console.WriteLine("Voce não verá essa linha.");
Para evitar isso e não deixar um "mau" assinante atrapalhar, use iteração manual protegendo cada handler.
Versão avançada de invocação de evento
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($"Erro no handler: {ex.Message}");
// Logging, ou tratamento específico do erro
}
}
}
}
Isso torna o evento mais "resiliente": mesmo que um assinante falhe — os demais continuam funcionando.
5. Eventos assíncronos (fire-and-forget)
Se um evento pode ser lento — às vezes a ideia é disparar os handlers em threads/tarefas separadas para não bloquear a thread principal.
Opção 1: rodar cada handler em uma Task separada
protected virtual void OnCountedAsync()
{
var handlers = Counted?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
// Fire-and-forget: não esperamos a conclusão!
System.Threading.Tasks.Task.Run(() =>
{
((EventHandler)handler)(this, EventArgs.Empty);
});
}
}
}
Mas! Cuidado com o paralelismo
- Se os assinantes usam recursos compartilhados — podem ocorrer race conditions.
- Exceções em handlers fire-and-forget são difíceis de capturar.
- Se for importante aguardar todos os assinantes — junte as tasks e use Task.WhenAll.
Para UI (WinForms/WPF) — nunca chame handlers fora da UI thread, senão terá InvalidOperationException.
Em geral — eventos assíncronos exigem design cuidadoso!
6. Otimização do armazenamento e invocação de eventos
Eventos "vazios": economizando memória
Se sua classe tem muitos eventos, a maioria raramente usada (por exemplo, muitos eventos em um componente de UI), há um truque: EventHandlerList.
Como funciona
Controles .NET (por exemplo, no WinForms) não guardam um delegate separado para cada evento; colocam todos os eventos em uma única estrutura (EventHandlerList) — e só alocam algo quando há pelo menos um handler inscrito.
Exemplo de criação manual de EventHandlerList
using System.ComponentModel; // EventHandlerList vive aqui!
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 que fazer isso: você economiza memória, evitando criar delegates desnecessários para centenas de eventos "vazios".
7. Segurança de threads: evitando races e locks
Eventos no .NET NÃO são thread-safe por padrão! Enquanto um assinante está se inscrevendo ou se desinscrevendo, outro thread pode disparar o evento. Isso pode fazer com que o delegate vire null logo antes da chamada, causando NullReferenceException.
Melhores práticas
- Use o operador ?. (Counted?.Invoke(...)) — protege contra null.
- Para casos complexos — proteja o acesso ao evento com lock.
Exemplo
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 isso é necessário?
- Em apps multithread (por exemplo, servidores, parsers multithread, etc.).
- Se subscribe/unsubscribe vêm de threads diferentes e o evento é disparado de outro thread.
8. Acessores add/remove para controle e otimização
Em situações especiais (por exemplo, quando precisa logar todas as inscrições ou limitar o número de assinantes) você pode implementar o evento manualmente usando os acessores:
private EventHandler _event;
public event EventHandler MyEvent
{
add
{
if (_event == null || _event.GetInvocationList().Length < 10)
_event += value;
else
Console.WriteLine("Limite: mais de 10 assinantes não é permitido.");
}
remove { _event -= value; }
}
Isso permite:
- Injetar lógica customizada.
- Tornar eventos thread-safe.
- Checar limites ou logar inscrições/desinscrições.
9. Dicas úteis
Expressões lambda, closures e performance
Lambdas são convenientes para se inscrever "on the fly":
var button = new Button();
button.Click += (s, e) => Console.WriteLine("Button clicked");
Mas se a lambda captura variáveis — é criado um "closure", o que pode aumentar o uso de memória. Na maioria dos cenários de UI isso não é um problema, mas em código de baixo nível vale ficar atento ao número de closures e ao lifetime dos objetos capturados.
Curiosidade:
Se adicionar duas lambdas idênticas em sequência, serão dois objetos-delegate diferentes, e o método será executado duas vezes.
Profiling de eventos e delegates
Quando a aplicação cresce, é necessário profilear eventos como qualquer outro código.
Como medir a velocidade de um evento?
- Use Stopwatch para medir o tempo entre disparo do evento e término do processamento.
- Use ferramentas de profiling de memória (por exemplo, dotMemory, ferramentas do Visual Studio) para encontrar assinantes que não foram desinscritos e permanecem na memória.
- Para achar "assinantes-zumbi" procure listas longas de invocation list em objetos de longa duração.
Tabela "Otimizações e armadilhas"
| Problema/Cenário | Solução |
|---|---|
| Muitos eventos de longa vida (e inúteis) | Usar EventHandlerList |
| Um assinante atrasa todos | Mover lógica pesada para task/thread separada |
| Segurança de threads | Copiar delegate antes da chamada, usar lock ao adicionar/remover |
| Exceções em handlers | Capturar em try/catch ao redor de cada handler |
| Vazamentos de memória por "assinantes-zumbi" | Sempre desinscrever, implementar IDisposable, profilear |
Diagrama: "Ciclo de vida de um evento otimizado"
+----------------+ +------------------+ +---------------------+
| Assinante cria | --> | Inscrição (+=) | --> | Cai na Invocation |
+----------------+ +------------------+ +---------------------+
| ^
| |
Desinscrição (-=) | Exceção |
v |
+----------------+ +--------------------+ +----------------------+
| Assinante Dispose | --> | Removido da invoc | --> | Não vira mais zumbi |
+----------------+ +--------------------+ +----------------------+
10. Como explicar "gestão de eventos" em entrevista
Se te perguntarem "Por que eventos em C# são ineficientes?" ou "Quando é preciso otimizar eventos?", saiba dizer:
- Eventos são bons para loose coupling, mas ineficientes com muitos assinantes e handlers pesados.
- Não são thread-safe por padrão.
- Exigem unsubscribe manual (caso contrário vazamentos de memória).
- Para cenários massivos — usar EventHandlerList e acessores personalizados add/remove.
- Controle profundo é raramente necessário — a maioria dos casos é coberta pelo padrão padrão.
Na próxima palestra vamos para cenários avançados e exemplos práticos de programação com eventos e delegates, onde você verá como essas otimizações funcionam em problemas reais.
Mitos comuns e antipadrões
- Achar que eventos no .NET são sempre rápidos — são rápidos até que haja muitos assinantes ou handlers pesados.
- Confiar que o GC vai "limpar" tudo — não, se você não desinscreveu, o objeto vai viver enquanto houver referência!
- Usar eventos para ligações "distantes" entre camadas de negócio — melhor usar padrões explícitos (por exemplo, Mediator).
GO TO FULL VERSION