Considere o caso de uma biblioteca destinada ao uso com outros projetos, que podem ser aplicativos de console, outras bibliotecas de classes ou aplicativos com UI. Esta biblioteca permite ao usuário enviar comandos e receber respostas de um terminal remoto, mas também pode receber mensagens de eventos do terminal remoto. É um caso de uso válido que o usuário possa enviar um comando em resposta a um evento remoto. O mecanismo de transporte utiliza async/await
métodos para transferir dados de e para o terminal remoto (por exemplo, usando um WebSocket). Em um aplicativo .NET "clássico", eu poderia projetar uma API como a seguinte ( Nota importante: o código apresentado aqui não é canônico, provavelmente não será compilado como está e foi escrito apenas para fins de discussão):
private Command currentCommand;
public event EventHandler<EventReceivedEventArgs>? EventReceived;
public CommandResponse ExecuteCommand(CommandParameters parameters)
{
currentCommand = transport.SendData(parameters.Serialize());
return currentCommand.WaitForCommandResponse();
}
protected void OnEventReceived(EventData eventData)
{
if (this.EventReceived is not null)
{
this.EventReceived(this, new EventReceivedEventArgs(eventData));
}
}
private void DataReceiver()
{
// Example only. In a real app, there would be some terminating
// condition here.
while (true)
{
// For discussion purposes, assume this an API that returns data
// from the remote end. How it works and how it is parsed can be
// considered an implementation detail.
byte[] receivedData = transport.ReceiveData();
ParsedData parsed = Parse(receivedData);
if (parsed is CommandResponse)
{
this.currentCommand.SetResponse(parsed);
}
if (parsed is EventData)
{
this.OnEventReceived(parsed);
}
}
}
No entanto, dado que ouvi repetidamente (e hoje, de forma um tanto incisiva), "eventos .NET não funcionam com async/await", "Não use async void
nunca" e "Não misture síncrono e assíncrono código." Então, dada a async/await
forma da API, parece:
private Command currentCommand;
// This, right here, I'm told, is bad, bad, bad. Totally suspect.
// Poor API design choice. Don't do it. This API, and it's caller
// below are presented merely as placeholders for discussion.
public event EventHandler<EventReceivedEventArgs>? EventReceived;
public async Task<CommandResponse> ExecuteCommandAsync(CommandParameters parameters)
{
await transport.SendCommandAsync(parameters.Serialize());
return await transport.WaitForCommandResponse();
}
protected void OnEventReceived(EventData eventData)
{
if (this.EventReceived is not null)
{
this.EventReceived(this, new EventReceivedEventArgs(eventData));
}
}
private async Task DataReceiver()
{
while (true)
{
await byte[] receivedData = transport.ReceiveData();
ParsedData parsed = Parse(receivedData);
if (parsed is CommandResponse)
{
this.currentCommand.SetResponse(parsed);
}
if (parsed is EventData)
{
this.OnEventReceived(parsed);
}
}
return Task.CompletedTask;
}
Eu entendo perfeitamente por que isso não é ideal. O manipulador de eventos do consumidor bloqueará o método de produção. Pode travar ou durar muito tempo. Pode ser que jogue. Há uma infinidade de coisas que podem ser problemáticas com esse estilo de API.
Mas aqui está a questão operativa: Qual é a alternativa? O que a comunidade de desenvolvedores .NET recomenda como forma de apresentar esse tipo de recurso ao usuário em vez de usar eventos .NET?
Eles não são um ajuste natural, não. A maioria dos eventos no .NET tem
void
um tipo de retorno, e o tipo de retorno natural para um método assíncrono sem valor de retorno éTask
, nãovoid
.Dito isto, existem várias maneiras de fazer os eventos assíncronos funcionarem. Você pode definir um delegado com um
Task
tipo de retorno e declarar um evento com esse tipo de delegado. Ou você pode usar diferimentos. Ouasync void
. Cada uma delas pode ser uma solução válida para usarasync
/await
com manipuladores de eventos, dependendo do seu caso de uso.A orientação que costumo dar é “evitar
async void
”. Não é uma regra rígida. Na verdade, para eventos de UI em particular,async void
geralmente é o que você deseja fazer.Definitivamente válido. Nenhum argumento aqui.
Primeiro, recomendo decidir se você deseja usar um padrão produtor/consumidor, um padrão Observer ou um padrão Strategy . Ao escrever código síncrono, Observer e Strategy são frequentemente confundidos e eventos são comumente usados para implementar qualquer um deles. Tecnicamente, os eventos devem ser usados para o Observer e alguma outra coisa (por exemplo, interfaces ou substituição de métodos derivados) usada para a Estratégia. E o produtor/consumidor geralmente usa uma fila threadsafe entre o(s) produtor(es) e o(s) consumidor(es).
Se o que você tem é realmente um padrão de estratégia, descarte o evento e substitua-o por uma interface. As interfaces podem definir facilmente métodos assíncronos. Mas se você tem um Observador ou produtor/consumidor, continue lendo.
Na situação Observador, você pode pensar na sua biblioteca como uma produtora de eventos, que pode ter ouvintes. Com essa perspectiva, você está lidando com um fluxo de eventos – ou um “fluxo de eventos”. (Observe que meu uso de "evento" como substantivo aqui não tem nada a ver com
event
C#; "evento" neste parágrafo se refere a cada mensagem de dados que chega).Rx (Reactive Extensions) é uma interpretação literal do padrão Observer. Do ponto de vista teórico, é um substituto fantástico para C#
event
s. Infelizmente, a curva de aprendizado é considerável e, na realidade, ela não ganhou força na comunidade .NET. Rx também tem algumas dificuldades em relação aosasync
manipuladores: ele tem muitos dos mesmos problemas que um retornovoid
diretoevent
. Em geral, normalmente não recomendo hoje em dia.Existem também as
event
abordagens com delegado incomum: tantoTask
retorno quanto adiamentos. Eu abordo isso com mais detalhes em uma postagem do blog , se você estiver interessado.Mas suponho que o padrão que você realmente deseja é produtor/consumidor. O Produtor/consumidor pode ser diferenciado do Observador porque os itens são consumidos apenas uma vez no produtor/consumidor, enquanto no Observador o mesmo item é "transmitido" para todos os ouvintes. Para o padrão produtor/consumidor, a melhor abordagem seria Canais.
System.Threading.Channels fornece canais, que você pode tratar como uma fila assíncrona de produtor/consumidor. Seu código ainda está produzindo um fluxo de eventos; eles fluiriam para uma fila (recomendo usar um canal limitado). Em seguida, você pode expor o lado de leitura dessa fila aos seus consumidores como um arquivo
IAsyncEnumerable<T>
, que eles podem consumir com mais naturalidadeawait foreach
. Esse tipo de API "pull" (em oposição à API de notificação "push" usada por eventos e Rx no padrão Observer) é mais fácil para os consumidores lidarem.Nota lateral: se seus comandos e suas respostas puderem ser correlacionados com algum identificador (o que geralmente é o caso nesses tipos de protocolos), considere usar um dicionário (simultâneo) de comandos ativos em vez de apenas um único arquivo
currentCommand
. Isso permitirá que seus consumidores tenham vários comandos durante o voo.Eu tenho uma série de vídeos (muito longa - desculpe!) Sobre o desenvolvimento de um cliente/servidor TCP/IP assíncrono . Muito disso (programação TCP/IP e soquete) não seria relevante para você (já que você está usando WebSockets), mas mostro como usar canais como fluxos de mensagens, bem como o dicionário simultâneo de ativo -técnica de comandos.
Verdadeiro
É verdade, exceto quando você é forçado por eventos na tecnologia legada (Winforms, seu código, etc.). Veja acima.
Para ser absolutamente claro, seu exemplo de código de tarefa é muito, muito ruim. Por muitos mais motivos além dos que você listou. E na verdade você nem abordou o que parecia querer melhorar, os acontecimentos. Você tem exatamente a mesma API de eventos da versão sem tarefa, apenas com um código pior que nem compila mais.
Dois pontos para ajudá-lo a se orientar em direção a uma API mais limpa:
O fato de os eventos "não funcionarem" com tarefas (leia-se: eles vazam continuações da chamada original) não parece se aplicar ao seu caso. Você está apenas acionando o evento com uma cópia dos dados (o resultado da serialização); portanto, mesmo que você perca seu quadro de pilha, seu parâmetro não será GC. Então você está bem.
Os eventos modernos de reconhecimento de tarefas do C# são encapsulados em
ICommand
implementações de MVVM, por exemplo, ReactiveUI'sReactiveCommand
ou Community MVVM'sRelayCommand
. Ambos (e outros) têm suporte total para funções de apoio assíncronas e lidam corretamente com a desativação do comando enquanto ele está em execução, por exemplo.