1. Introdução
Quando a gente escreve
worker.WorkCompleted += listener.OnWorkCompleted;
na verdade a gente está adicionando um ponteiro para um método na "invocation list" (multicast delegate) do evento. Essa "lista" dentro do evento é só uma sequência de métodos que vão ser chamados quando o evento for disparado. Em C# o evento é construído sobre um delegate que suporta múltiplos assinantes.
Imagina uma newsletter: você tem uma lista de assinantes (endereços de email). Quando você envia a newsletter (dispara o evento), todos recebem a mensagem. Se alguém se desinscrever, é removido da lista e não recebe mais nada.
Como adicionar ou remover um assinante
Assinar (+=) e desassinar (-=) mexem com o delegate dentro do evento. Aqui vai um exemplo com uma lambda que pode ser assinada e depois desassinada:
EventHandler<WorkCompletedEventArgs> handler = (sender, e) =>
{
Console.WriteLine($"[Lambda] Trabalho concluído: {e.Message}");
};
worker.WorkCompleted += handler; // Assina
worker.WorkCompleted -= handler; // Desassina
No caso de métodos normais a desinscrição é igual:
worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted -= listener.OnWorkCompleted;
Repare: se você assinou o mesmo método várias vezes, ele será chamado tantas vezes, e para remover você precisa chamar -= o mesmo número de vezes.
2. Por que gerenciar isso manualmente?
Por que é importante gerenciar assinantes?
Em aplicações reais, especialmente as de longa duração (por exemplo, desktop ou servidor), gerenciamento incorreto das assinaturas pode causar memory leaks. Se o objeto assinante não é mais necessário mas ainda "fica" na invocation list do evento, ele não será coletado pelo GC — porque ainda existe uma referência dele a partir do delegate do evento.
Ilustração
| Ação | Resultado para o assinante |
|---|---|
| += (assinou) | Adicionado na lista |
| -= (desassinou) | Removido da lista |
| Objeto assinante removido | Se NÃO desassinado! — NÃO removido, já que ainda existe referência no evento |
| Objeto assinante removido | Se DESASSINADO — será removido normalmente |
Como saber quem está inscrito no evento?
Eventos encapsulam a lista de assinantes, então de fora da classe-publisher você não consegue obter essa lista diretamente — só pode adicionar (+=) ou remover (-=) handlers.
Porém dentro da própria classe onde o evento é declarado sobre um delegate (por exemplo EventHandler), dá pra pegar a lista atual de assinantes usando GetInvocationList():
// Dentro da classe-publisher
if (WorkCompleted != null)
{
foreach (Delegate subscriber in WorkCompleted.GetInvocationList())
{
Console.WriteLine($"Handler: {subscriber.Method.Name}, Objeto: {subscriber.Target}");
}
}
Essa técnica é raramente necessária no dia a dia, mas pode ser útil para debugging ou para implementar um unsubscribe em massa de todos os assinantes.
3. Chamada segura de eventos: "minas" e como contorná-las
O que pode dar errado ao chamar um evento?
Tudo parece simples: você chama
WorkCompleted?.Invoke(this, args);
e tudo funciona... Na maior parte do tempo! Mas tem nuances. Eis elas:
1. Perigo multithread
Em aplicação multithread pode acontecer de entre a checagem de null e a invocação outro thread alterar as assinaturas. Por exemplo:
1) Thread A checa: WorkCompleted != null.
2) No mesmo momento thread B desassina do evento (-=), e a lista de handlers fica vazia.
3) Thread A tenta chamar WorkCompleted.Invoke(...) — ocorre NullReferenceException, porque não há mais handlers.
Essa é uma race condition clássica ao trabalhar com eventos.
2. Exceções inesperadas em handlers
Se um dos assinantes lança uma exceção durante o processamento do evento, a chamada dos outros handlers é interrompida. Ou seja, o evento "quebra" no primeiro erro, e os demais assinantes não recebem a notificação. Para evitar isso, recomenda-se envolver a chamada de cada handler em um try-catch se for importante que todos recebam o sinal.
3. Vazamento indesejado de referência
O handler de evento costuma ser um método de instância que captura referência ao objeto assinante (this). Se o assinante esquece de desassinar do publisher, a referência a ele fica guardada na invocation list do publisher. Como resultado o GC não consegue liberar esse objeto — surge leak de memória.
Como chamar evento de forma segura?
1) Copiar o delegate pra variável local
Chamar via variável local garante que durante a invocação a lista de assinantes não vai mudar:
// Jeito clássico
var handler = WorkCompleted;
if (handler != null)
{
handler(this, args);
}
Ou, de forma moderna, com o operador null-conditional:
WorkCompleted?.Invoke(this, args);
Na maioria dos casos isso é suficiente, já que o compilador C# "entende" essa construção e faz a cópia interna da referência (veja a documentação oficial).
2) Proteção contra exceções dos handlers
Se for crítico que todos os handlers sejam chamados (mesmo se um falhar), percorra manualmente:
var handler = WorkCompleted;
if (handler != null)
{
foreach (EventHandler<WorkCompletedEventArgs> subscriber in handler.GetInvocationList())
{
try
{
subscriber(this, args);
}
catch (Exception ex)
{
// Logamos, mas não deixamos o evento "cair" por completo
Console.WriteLine($"Erro no handler: {ex.Message}");
}
}
}
Esse padrão é raro em cenários UI simples, mas faz sentido em bibliotecas, loggers e sistemas complexos.
3) Prevenir vazamentos de memória
Se o assinante vive menos que o publisher (por exemplo, uma janela que assinou um evento da aplicação), ele precisa se desinscrever:
worker.WorkCompleted -= listener.OnWorkCompleted;
Caso contrário o garbage collector não conseguirá liberar listener, mesmo que não existam mais "referências explícitas" a ele.
4. Exemplo prático: manager de assinaturas e desassinaturas em massa
Vamos expandir o exemplo didático. Imagina que temos vários listeners — e queremos assinar e desassinar eles dinamicamente enquanto o programa roda.
public class WorkListener
{
private readonly string _name;
public WorkListener(string name)
{
_name = name;
}
public void OnWorkCompleted(object sender, WorkCompletedEventArgs e)
{
Console.WriteLine($"Listener {_name}: {e.Message}");
}
}
No programa principal:
var worker = new Worker();
var listeners = new List<WorkListener>
{
new WorkListener("Ivan"),
new WorkListener("Maria"),
new WorkListener("Denis")
};
// Assina todos os listeners
foreach (var listener in listeners)
worker.WorkCompleted += listener.OnWorkCompleted;
// Dispara o evento
worker.DoWork();
// Desinscrição em massa
foreach (var listener in listeners)
worker.WorkCompleted -= listener.OnWorkCompleted;
// Verifica que ninguém mais reage
worker.DoWork();
No console, após o primeiro DoWork aparecerão 3 mensagens; após o segundo — nenhuma.
5. Dicas para trabalhar com eventos de forma segura
- Desinscreva em tempo, se o ciclo de vida do assinante for menor que o do publisher.
- Se você implementar o padrão "publisher de longa vida — assinante temporário", sempre faça unsubscribe, por exemplo em Dispose(), ao fechar a janela ou ao encerrar explicitamente o objeto.
- Para eventos one-shot dá pra usar um handler anônimo (lambda) e desinscrever dentro dele:
EventHandler<WorkCompletedEventArgs> handler = null;
handler = (s, e) =>
{
Console.WriteLine("Evento processado uma vez!");
worker.WorkCompleted -= handler;
};
worker.WorkCompleted += handler;
- Não guarde referências a assinantes ou handlers só pra checar "quem está inscrito" — isso não é necessário na lógica de negócio normal. Faça isso somente pra debugging.
6. Erros comuns e como evitá-los
Erro #1: esqueceu de desinscrever do evento — vazamento de memória.
Se um assinante não se desinscreveu, especialmente em aplicações grandes com muitos eventos e assinantes, objetos podem ficar na memória por mais tempo do que o necessário. Esse erro costuma demorar a aparecer, mas provoca aumento do consumo de memória e degradação de performance.
Erro #2: chamar evento sem checar null.
Se não houver assinantes e você tentar invocar o evento diretamente, vai ocorrer NullReferenceException. Em versões modernas do C# o operador null-conditional ?. ajuda, mas se você trabalha com código antigo ou itera handlers manualmente, não esqueça da verificação de null.
Erro #3: exceção num handler interrompe os demais.
Se um dos handlers lançar uma exceção, os handlers seguintes não serão chamados. Se for importante notificar todos os assinantes, percorra a invocation list em loop e envolva cada chamada num bloco try/catch.
GO TO FULL VERSION