As diretrizes de uso assíncrono / aguardado no C # não estão contradizendo os conceitos de boa arquitetura e camadas de abstração?

103

Esta pergunta diz respeito à linguagem C #, mas espero que abranja outras linguagens como Java ou TypeScript.

A Microsoft recomenda práticas recomendadas sobre o uso de chamadas assíncronas no .NET. Entre essas recomendações, vamos escolher duas:

  • altere a assinatura dos métodos assíncronos para que eles retornem Tarefa ou Tarefa <> (no TypeScript, isso seria uma promessa <>)
  • altere os nomes dos métodos assíncronos para terminar com xxxAsync ()

Agora, ao substituir um componente síncrono de baixo nível por um componente assíncrono, isso afeta toda a pilha do aplicativo. Como o assíncrono / espera tem um impacto positivo apenas se usado "até o fim", significa que os nomes de assinatura e método de cada camada no aplicativo devem ser alterados.

Uma boa arquitetura geralmente envolve a colocação de abstrações entre cada camada, de forma que a substituição de componentes de baixo nível por outras não seja vista pelos componentes de nível superior. Em C #, as abstrações assumem a forma de interfaces. Se introduzirmos um novo componente assíncrono de baixo nível, cada interface na pilha de chamadas precisará ser modificada ou substituída por uma nova interface. A maneira como um problema é resolvido (assíncrono ou sincronizado) em uma classe de implementação não está mais oculto (abstraído) para os chamadores. Os chamadores precisam saber se é sincronizado ou assíncrono.

As melhores práticas assíncronas / aguardam não estão em contradição com os princípios da "boa arquitetura"?

Isso significa que cada interface (por exemplo, IEnumerable, IDataAccessLayer) precisa de sua contrapartida assíncrona (IAsyncEnumerable, IAsyncDataAccessLayer), para que possam ser substituídas na pilha ao alternar para dependências assíncronas?

Se formos um pouco mais longe, não seria mais simples supor que todos os métodos sejam assíncronos (retornar uma tarefa <> ou Promessa <>) e que os métodos sincronizem as chamadas assíncronas quando elas não são realmente assíncrono? Isso é algo que se espera das futuras linguagens de programação?

corentinaltepe
fonte
5
Embora isso pareça uma ótima pergunta para discussão, acho que isso é baseado em opiniões demais para ser respondido aqui.
Euphoric
22
@Euphoric: Eu acho que o problema analisado aqui é mais profundo do que as diretrizes de C #, isso é apenas um sintoma do fato de que alterar partes de um aplicativo para comportamento assíncrono pode ter efeitos não locais no sistema geral. Então, meu instinto me diz que deve haver uma resposta não opinativa para isso, com base em fatos técnicos. Por isso, encorajo todos os presentes a não encerrarem esta pergunta muito cedo. Em vez disso, esperemos que respostas virão (e, se forem muito opinativas, ainda podemos votar no fechamento).
Doc Brown
25
@DocBrown Acho que a pergunta mais profunda aqui é "Parte do sistema pode ser alterada de síncrona para assíncrona, sem que as partes que dependem dele também tenham que mudar?" Eu acho que a resposta para isso é clara "não". Nesse caso, não vejo como "conceitos de boa arquitetura e camadas" se aplicam aqui.
Euphoric
6
@Euphoric: soa como uma boa base para uma resposta não-opinativo ;-)
Doc Brown
5
@ Alemanha: porque o C #, como muitos idiomas, não pode sobrecarregar com base apenas no tipo de retorno. Você acabaria com métodos assíncronos que têm a mesma assinatura que seus equivalentes de sincronização (nem todos podem CancellationTokenusar a e aqueles que o fazem podem desejar fornecer um padrão). A remoção dos métodos de sincronização existentes (e a quebra proativa de todo o código) é óbvia para iniciantes.
Jeroen Mostert

Respostas:

111

Qual a cor da sua função?

Talvez você esteja interessado em Qual a cor de Bob Nystrom 1 .

Neste artigo, ele descreve uma linguagem fictícia em que:

  • Cada função tem uma cor: azul ou vermelho.
  • Uma função vermelha pode chamar funções azuis ou vermelhas, sem problemas.
  • Uma função azul pode chamar apenas funções azuis.

Embora fictício, isso acontece regularmente nas linguagens de programação:

  • No C ++, um método "const" pode chamar apenas outros métodos "const" this.
  • No Haskell, uma função não IO pode chamar apenas funções não IO.
  • Em C #, uma função de sincronização só pode chamar funções de sincronização 2 .

Como você percebeu, devido a essas regras, as funções vermelhas tendem a se espalhar pela base de código. Você insere um e, aos poucos, coloniza toda a base de código.

1 Bob Nystrom, além dos blogs, também faz parte da equipe da Dart e escreveu esta pequena série de intérpretes de artesanato; altamente recomendado para qualquer aficionado por linguagem de programação / compilador.

2 Não é bem verdade, como você pode chamar uma função assíncrona e bloquear até que ela retorne, mas ...

Limitação de idioma

Essa é, essencialmente, uma limitação de idioma / tempo de execução.

O idioma com o encadeamento M: N, por exemplo, como Erlang e Go, não possui asyncfunções: cada função é potencialmente assíncrona e sua "fibra" será simplesmente suspensa, trocada e trocada novamente quando estiver pronta novamente.

O C # seguiu um modelo de segmentação 1: 1 e, portanto, decidiu apresentar a sincronicidade na linguagem para evitar o bloqueio acidental de threads.

Na presença de limitações de idioma, as diretrizes de codificação precisam se adaptar.

Matthieu M.
fonte
4
As funções de E / S tendem a se espalhar, mas com diligência, você pode isolá-las para funções próximas (na pilha ao chamar) pontos de entrada do seu código. Você pode fazer isso fazendo com que essas funções chamem as funções de E / S e, em seguida, outras funções processam a saída delas e retornam os resultados necessários para outras E / S. Acho que esse estilo facilita muito o gerenciamento e o trabalho das minhas bases de código. Gostaria de saber se existe um corolário da sincronicidade.
precisa saber é
16
O que você quer dizer com segmentação "M: N" e "1: 1"?
Capitão Man
14
@CaptainMan: 1: 1 threading significa mapear um encadeamento de aplicativo para um encadeamento do SO, este é o caso em linguagens como C, C ++, Java ou C #. Por outro lado, o encadeamento M: N significa mapear encadeamentos de aplicativos M para encadeamentos do N OS; no caso do Go, um thread de aplicativo é chamado de "goroutine", no caso de Erlang, é chamado de "ator", e você também pode ter ouvido falar deles como "green threads" ou "fibres". Eles fornecem simultaneidade sem exigir paralelismo. Infelizmente, o artigo da Wikipedia sobre o assunto é bastante escasso.
Matthieu M.
2
Isso está um pouco relacionado, mas também acho que essa ideia de "cor da função" também se aplica a funções que bloqueiam a entrada do usuário, por exemplo, caixas de diálogo modais, caixas de mensagens, algumas formas de E / S do console etc., que são as ferramentas que o estrutura teve desde o início.
Jrh
2
@MatthieuM. O C # não tem um thread de aplicativo por um thread do SO e nunca teve. Isso é muito óbvio quando você está interagindo com código nativo, e especialmente óbvio ao executar no MS SQL. E, é claro, rotinas cooperativas sempre foram possíveis (e são ainda mais simples async); de fato, era um padrão bastante comum para a criação de UI responsiva. Era tão bonito quanto Erlang? Não. Mas isso ainda é muito distante da C :)
Luaan
82

Você está certo, há uma contradição aqui, mas as "melhores práticas" não são ruins. É porque a função assíncrona faz algo essencialmente diferente do que uma função síncrona. Em vez de aguardar o resultado de suas dependências (geralmente algumas E / S), ele cria uma tarefa a ser tratada pelo loop do evento principal. Esta não é uma diferença que pode ser bem escondida sob abstração.

max630
fonte
27
A resposta é tão simples quanto esta OMI. A diferença entre um processo síncrono e assíncrono não é um detalhe de implementação - é um contrato semanticamente diferente.
Ant
11
@ AntP: Eu discordo que é assim tão simples; ele aparece na linguagem C #, mas não na linguagem Go, por exemplo. Portanto, essa não é uma propriedade inerente aos processos assíncronos; é uma questão de como os processos assíncronos são modelados no idioma especificado.
Matthieu M.
1
@MatthieuM. Sim, mas você pode usar asyncmétodos em C # para fornecer contratos síncronos também, se desejar. A única diferença é que Go é assíncrono por padrão, enquanto C # é síncrono por padrão. asyncfornece o segundo modelo de programação - async é a abstração (o que realmente faz depende do tempo de execução, agendador de tarefas, contexto de sincronização, implementação do garçom ...).
Luaan
6

Um método assíncrono se comporta de maneira diferente de um método síncrono, como tenho certeza de que você está ciente. Em tempo de execução, converter uma chamada assíncrona em síncrona é trivial, mas o contrário não pode ser dito. Portanto, a lógica se torna: por que não criamos métodos assíncronos de todos os métodos que podem exigir isso e deixamos o chamador "converter" conforme necessário para um método síncrono?

Em certo sentido, é como ter um método que gera exceções e outro que é "seguro" e não gera nem mesmo em caso de erro. Em que ponto o codificador está sendo excessivo para fornecer esses métodos que, de outra forma, podem ser convertidos um para o outro?

Nisto existem duas escolas de pensamento: uma é criar vários métodos, cada um chamando outro método possivelmente privado, permitindo a possibilidade de fornecer parâmetros opcionais ou pequenas alterações no comportamento, como ser assíncrono. A outra é minimizar os métodos de interface para o essencial, deixando ao chamador executar as modificações necessárias.

Se você é da primeira escola, existe uma certa lógica para dedicar uma aula a chamadas síncronas e assíncronas, a fim de evitar duplicar todas as chamadas. A Microsoft tende a favorecer essa escola de pensamento e, por convenção, para permanecer consistente com o estilo preferido pela Microsoft, você também precisa ter uma versão assíncrona, da mesma maneira que as interfaces quase sempre começam com um "eu". Deixe-me enfatizar que não está errado , por si só, porque é melhor manter um estilo consistente em um projeto, em vez de fazê-lo "da maneira certa" e mudar radicalmente o estilo para o desenvolvimento que você adiciona a um projeto.

Dito isto, tenho a tendência de favorecer a segunda escola, que é minimizar os métodos de interface. Se eu acho que um método pode ser chamado de forma assíncrona, o método para mim é assíncrono. O chamador pode decidir se deve ou não esperar a conclusão da tarefa antes de continuar. Se essa interface for uma interface para uma biblioteca, é mais razoável fazê-lo dessa maneira para minimizar o número de métodos que você precisaria para descontinuar ou ajustar. Se a interface for para uso interno no meu projeto, adicionarei um método para todas as chamadas necessárias em todo o projeto para os parâmetros fornecidos e nenhum método "extra" e, mesmo assim, apenas se o comportamento do método ainda não estiver coberto por um método existente.

No entanto, como muitas coisas nesse campo, é amplamente subjetivo. Ambas as abordagens têm seus prós e contras. A Microsoft também iniciou a convenção de adicionar letras indicativas do tipo no início do nome da variável e "m_" para indicar que é um membro, levando a nomes de variáveis ​​como m_pUser. Meu argumento é que nem mesmo a Microsoft é infalível e também pode cometer erros.

Dito isto, se o seu projeto estiver seguindo esta convenção Async, aconselho você a respeitá-lo e continuar o estilo. E somente quando você tiver um projeto próprio, poderá escrevê-lo da melhor maneira que achar melhor.

Neil
fonte
6
"Em tempo de execução, converter uma chamada assíncrona em síncrona é trivial" Não tenho certeza de que seja exatamente isso. No .NET, usar .Wait()method e like pode causar consequências negativas, e em js, até onde eu sei, não é possível.
Max630
2
@ max630 Não disse que não há problemas simultâneos a serem considerados, mas se foi inicialmente uma tarefa síncrona , é provável que não esteja criando impasses. Dito isto, trivial não significa "clique duas vezes aqui para converter para síncrono". Em js, você retorna uma instância do Promise e chama resolvida.
Neil
2
sim seu totalmente uma dor na bunda para converter assíncrona de volta para sincronização
Ewan
4
@ Neil Em javascript, mesmo que você chame Promise.resolve(x)e adicione retornos de chamada, esses retornos de chamada não serão executados imediatamente.
Nickl
1
@ Neil se uma interface expuser um método assíncrono, esperar que a espera na tarefa não crie um impasse não é uma boa suposição. É muito melhor que a interface mostre que é realmente síncrona na assinatura do método, do que uma promessa na documentação que pode mudar em uma versão posterior.
Carl Walsh
2

Vamos imaginar que existe uma maneira de permitir que você chame funções de forma assíncrona sem alterar sua assinatura.

Isso seria muito legal e ninguém recomendaria que você mudasse seus nomes.

Porém, funções assíncronas reais, não apenas as que aguardam outra função assíncrona, mas o nível mais baixo possuem alguma estrutura específica para sua natureza assíncrona. por exemplo

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

São essas duas informações que o código de chamada precisa para executar o código de forma assíncrona que as coisas gostam Taske asyncencapsulam.

Há mais de uma maneira de executar funções assíncronas, async Taské apenas um padrão incorporado ao compilador por meio de tipos de retorno, para que você não precise vincular manualmente os eventos

Ewan
fonte
0

Vou abordar o ponto principal de uma maneira menos C # ness e mais genérica:

As melhores práticas assíncronas / aguardam não estão em contradição com os princípios da "boa arquitetura"?

Eu diria que isso depende apenas da escolha que você faz no design da sua API e do que você deixa para o usuário.

Se você deseja que uma função da sua API seja assíncrona, há pouco interesse em seguir a convenção de nomenclatura. Apenas sempre retorne Tarefa <> / Promessa <> / Futuro <> / ... como tipo de retorno, pois é auto-documentado. Se quiser uma resposta de sincronização, ele ainda poderá fazer isso aguardando, mas se ele sempre fizer isso, será um pouco complicado.

No entanto, se você fizer apenas a sincronização da API, isso significa que, se um usuário quiser que ela seja assíncrona, ele precisará gerenciar a parte assíncrona dela mesmo.

Isso pode gerar bastante trabalho extra, no entanto, também pode dar mais controle ao usuário sobre quantas chamadas simultâneas ele permite, colocar tempo limite, novas tentativas e assim por diante.

Em um sistema grande com uma API enorme, implementar a maioria deles para sincronização por padrão pode ser mais fácil e mais eficiente do que gerenciar independentemente cada parte da API, especialmente se eles compartilharem recursos (sistema de arquivos, CPU, banco de dados, ...).

De fato, para as partes mais complexas, você poderia perfeitamente ter duas implementações da mesma parte de sua API, uma síncrona fazendo as coisas úteis, uma assíncrona confiando na síncrona, que lida com as coisas e gerencia apenas simultaneidade, cargas, tempos limite e novas tentativas. .

Talvez alguém possa compartilhar sua experiência com isso porque me falta experiência com esses sistemas.

Walfrat
fonte
2
@Miral Você usou "chamar um método assíncrono a partir de um método de sincronização" em ambas as possibilidades.
Adrian Wragg
@AdrianWragg Então eu fiz; meu cérebro deve ter tido uma condição de raça. Eu resolvo isso.
Miral
É o contrário; é trivial chamar um método assíncrono a partir de um método de sincronização, mas é impossível chamar um método de sincronização a partir de um método assíncrono. (E onde as coisas desmoronam completamente é quando alguém tenta fazer o último de qualquer maneira, o que pode levar a impasses.) Portanto, se você tivesse que escolher um, assíncrono por padrão é a melhor escolha. Infelizmente, também é a escolha mais difícil, porque uma implementação assíncrona pode chamar apenas métodos assíncronos.
Miral
(E com isso, é claro, quero dizer um método de sincronização de bloqueio . Você pode chamar algo que faça um cálculo vinculado à CPU puro de forma síncrona a partir de um método assíncrono - embora você deva tentar evitar fazê-lo, a menos que saiba que está em um contexto de trabalho ao invés de um contexto UI - mas o bloqueio de chamadas que esperam ocioso em um bloqueio ou de I / O ou para outra operação são uma má idéia).
Miral