CodeGym /Cursos /C# SELF /Assinantes e chamada segura de eventos

Assinantes e chamada segura de eventos

C# SELF
Nível 53 , Lição 2
Disponível

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.

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION