Por que nem todas as funções devem ser assíncronas por padrão?

107

O padrão async-await do .net 4.5 está mudando o paradigma. É quase bom demais para ser verdade.

Tenho portado alguns códigos com muito IO para async-await porque o bloqueio é coisa do passado.

Muitas pessoas estão comparando async-await a uma infestação de zumbis e eu achei que é bastante preciso. O código assíncrono gosta de outro código assíncrono (você precisa de uma função assíncrona para aguardar em uma função assíncrona). Assim, mais e mais funções se tornam assíncronas e isso continua crescendo em sua base de código.

Alterar as funções para assíncrono é um trabalho um tanto repetitivo e sem imaginação. Jogue uma asyncpalavra - chave na declaração, envolva o valor de retorno Task<>e pronto. É bastante perturbador o quão fácil é todo o processo, e em breve um script de substituição de texto irá automatizar a maior parte da "transferência" para mim.

E agora a pergunta ... Se todo o meu código está lentamente se tornando assíncrono, por que não tornar tudo assíncrono por padrão?

A razão óbvia que presumo é o desempenho. Async-await tem sua sobrecarga e código que não precisa ser assíncrono, de preferência não deveria. Mas se o desempenho é o único problema, certamente algumas otimizações inteligentes podem remover a sobrecarga automaticamente quando não for necessária. Eu li sobre a otimização "fast path" e me parece que ela sozinha deve cuidar da maior parte disso.

Talvez isso seja comparável à mudança de paradigma provocada pelos coletores de lixo. Nos primeiros dias do GC, liberar sua própria memória era definitivamente mais eficiente. Mas as massas ainda escolheram a coleta automática em favor de um código mais seguro e simples, que pode ser menos eficiente (e mesmo isso sem dúvida não é mais verdade). Talvez devesse ser o caso aqui? Por que nem todas as funções deveriam ser assíncronas?

talkol
fonte
8
Dê à equipe C # o crédito por marcar o mapa. Como foi feito há centenas de anos, "dragões mentem aqui". Você poderia equipar um navio e ir para lá, muito provavelmente você sobreviverá com o sol brilhando e o vento em suas costas. E às vezes não, eles nunca voltaram. Muito parecido com async / await, SO está cheio de perguntas de usuários que não entenderam como saíram do mapa. Mesmo que eles tenham recebido um aviso muito bom. Agora é problema deles, não da equipe C #. Eles marcaram os dragões.
Hans Passant
1
@Sayse, mesmo que você remova a distinção entre funções de sincronização e assíncronas, as funções de chamada que são implementadas de forma síncrona ainda serão síncronas (como seu exemplo WriteLine)
talkol
2
“Agrupar o valor de retorno por Tarefa <>” A menos que seu método tenha que ser async(para cumprir algum contrato), esta provavelmente é uma má ideia. Você está obtendo as desvantagens do assíncrono (aumento do custo das chamadas de método; a necessidade de usar awaitno código de chamada), mas nenhuma das vantagens.
svick
1
Esta é uma pergunta realmente interessante, mas talvez não seja muito adequada para SO. Podemos considerar migrá-lo para programadores.
Eric Lippert
1
Talvez esteja faltando alguma coisa, mas tenho exatamente a mesma pergunta. Se async / await vêm sempre em pares e o código ainda precisa esperar até que ele termine de ser executado, por que não apenas atualizar o .NET framework existente e fazer os métodos que precisam ser async - async por padrão SEM criar palavras-chave adicionais? A linguagem já está se transformando no que foi projetada para escapar - spaghetti de palavras-chave. Eu acho que eles deveriam ter parado de fazer isso depois que o "var" foi sugerido. Agora temos "dinâmico", asyn / await ... etc ... Por que vocês não apenas .NET-ify javascript? ;))
monstro

Respostas:

127

Em primeiro lugar, obrigado por suas amáveis ​​palavras. É realmente um recurso incrível e estou feliz por ter sido uma pequena parte dele.

Se todo o meu código está lentamente se tornando assíncrono, por que não tornar tudo assíncrono por padrão?

Bem, você está exagerando; todo o seu código não está ficando assíncrono. Quando você adiciona dois inteiros "simples", não está esperando o resultado. Quando você soma dois inteiros futuros para obter um terceiro inteiro futuro - porque Task<int>é isso, é um inteiro ao qual você terá acesso no futuro - é claro que provavelmente estará aguardando o resultado.

O principal motivo para não tornar tudo assíncrono é porque o objetivo de async / await é tornar mais fácil escrever código em um mundo com muitas operações de alta latência . A grande maioria de suas operações não é de alta latência, então não faz sentido sofrer o impacto de desempenho que atenua essa latência. Em vez disso, algumas de suas operações importantes são de alta latência e essas operações estão causando a infestação de zumbis de assíncronos em todo o código.

se o desempenho é o único problema, certamente algumas otimizações inteligentes podem remover a sobrecarga automaticamente quando não for necessária.

Em teoria, teoria e prática são semelhantes. Na prática, eles nunca são.

Deixe-me dar três pontos contra esse tipo de transformação seguida por uma passagem de otimização.

O primeiro ponto novamente é: assíncrono em C # / VB / F # é essencialmente uma forma limitada de passagem de continuação . Uma enorme quantidade de pesquisas na comunidade de linguagem funcional foi aplicada para descobrir maneiras de identificar como otimizar o código que faz uso intenso do estilo de passagem de continuação. A equipe do compilador provavelmente teria que resolver problemas muito semelhantes em um mundo em que "async" fosse o padrão e os métodos não assíncronos tivessem que ser identificados e dessincronizados. A equipe C # não está realmente interessada em resolver problemas de pesquisa abertos, então há grandes pontos contra.

Um segundo ponto contra é que o C # não tem o nível de "transparência referencial" que torna esses tipos de otimizações mais tratáveis. Por "transparência referencial" quero dizer a propriedade da qual o valor de uma expressão não depende quando ela é avaliada . Expressões como 2 + 2são referencialmente transparentes; você pode fazer a avaliação em tempo de compilação, se quiser, ou adiá-la até o tempo de execução e obter a mesma resposta. Mas uma expressão como x+ynão pode ser movida no tempo porque x e y podem estar mudando com o tempo .

O Async torna muito mais difícil raciocinar sobre quando um efeito colateral ocorrerá. Antes assíncrono, se você dissesse:

M();
N();

e M()era void M() { Q(); R(); }, e N()foi void N() { S(); T(); }, e Re Sefeitos colaterais de produtos, então você sabe que efeito colateral de R acontece antes efeito colateral do S. Mas se você tiver, async void M() { await Q(); R(); }então, de repente, isso sai pela janela. Você não tem garantia se isso R()vai acontecer antes ou depois S()(a menos que M()seja esperado, é claro ; mas é claro Taskque não precisa ser aguardado até depois N().)

Agora imagine que essa propriedade de não saber mais em que ordem os efeitos colaterais acontecem se aplica a cada trecho de código em seu programa, exceto aqueles que o otimizador consegue desassincronizar. Basicamente, você não tem mais ideia de quais expressões serão avaliadas em qual ordem, o que significa que todas as expressões precisam ser referencialmente transparentes, o que é difícil em uma linguagem como C #.

Um terceiro ponto contra é que você deve perguntar "por que o assíncrono é tão especial?" Se você vai argumentar que toda operação deveria ser realmente um, Task<T>então você precisa ser capaz de responder à pergunta "por que não Lazy<T>?" ou "por que não Nullable<T>?" ou "por que não IEnumerable<T>?" Porque poderíamos facilmente fazer isso. Por que não deveria ser o caso de toda operação ser elevada a anulável ? Ou cada operação é calculada lentamente e o resultado é armazenado em cache para mais tarde , ou o resultado de cada operação é uma sequência de valores em vez de apenas um único valor . Em seguida, você deve tentar otimizar as situações em que sabe "ah, isso nunca deve ser nulo, para que eu possa gerar um código melhor" e assim por diante.

A questão é: não está claro para mim se Task<T>é realmente especial para justificar tanto trabalho.

Se esse tipo de coisa interessa a você, eu recomendo que você investigue linguagens funcionais como Haskell, que têm uma transparência referencial muito mais forte e permitem todos os tipos de avaliação fora de ordem e fazem cache automático. Haskell também tem um suporte muito mais forte em seu sistema de tipos para os tipos de "elevações monádicas" a que me referi.

Eric Lippert
fonte
Ter funções assíncronas chamadas sem esperar não faz sentido para mim (no caso comum). Se removêssemos este recurso, o compilador poderia decidir por si mesmo se uma função é assíncrona ou não (ela chama o await?). Então, poderíamos ter sintaxe idêntica para ambos os casos (assíncrono e sinc) e apenas usar await em chamadas como diferenciador. Infestação de zumbis resolvida :)
talkol
Eu continuei a discussão em programadores de acordo com sua solicitação: programmers.stackexchange.com/questions/209872/…
talkol
@EricLippert - Muito boa resposta como sempre :) Eu estava curioso se você poderia esclarecer "latência alta"? Existe um intervalo geral em milissegundos aqui? Estou apenas tentando descobrir onde está a linha limite inferior para usar assíncrono, porque não quero abusar dela.
Travis J
7
@TravisJ: A orientação é: não bloqueie o thread da IU por mais de 30 ms. Mais do que isso e você corre o risco de a pausa ser perceptível pelo usuário.
Eric Lippert
1
O que me desafia é que, se algo é feito ou não de forma síncrona ou assíncrona, é um detalhe de implementação que pode mudar. Mas a mudança nessa implementação força um efeito cascata por meio do código que o chama, do código que o chama e assim por diante. Acabamos mudando o código por causa do código do qual ele depende, algo que normalmente fazemos de tudo para evitar. Ou usamos async/awaitporque algo oculto sob camadas de abstração pode ser assíncrono.
Scott Hannen
23

Por que nem todas as funções deveriam ser assíncronas?

O desempenho é um dos motivos, como você mencionou. Observe que a opção "fast path" à qual você vinculou melhora o desempenho no caso de uma Task concluída, mas ainda requer muito mais instruções e sobrecarga em comparação com uma única chamada de método. Dessa forma, mesmo com o "caminho rápido" em vigor, você está adicionando muita complexidade e sobrecarga a cada chamada de método assíncrona.

A compatibilidade com versões anteriores, bem como a compatibilidade com outras linguagens (incluindo cenários de interoperabilidade), também se tornaria problemática.

A outra é uma questão de complexidade e intenção. As operações assíncronas adicionam complexidade - em muitos casos, os recursos da linguagem escondem isso, mas há muitos casos em que criar métodos asyncdefinitivamente adiciona complexidade ao seu uso. Isso é especialmente verdadeiro se você não tiver um contexto de sincronização, pois os métodos assíncronos podem facilmente acabar causando problemas de threading inesperados.

Além disso, existem muitas rotinas que não são, por natureza, assíncronas. Isso faz mais sentido como operações síncronas. Forçar Math.Sqrtpara ser Task<double> Math.SqrtAsyncseria ridículo, por exemplo, como não há nenhuma razão em tudo para que isso seja assíncrona. Em vez de asyncempurrar seu aplicativo, você acabaria se awaitpropagando por toda parte .

Isso também quebraria completamente o paradigma atual, além de causar problemas com propriedades (que são efetivamente apenas pares de métodos ... eles também iriam assíncronos?) E teria outras repercussões em todo o design da estrutura e da linguagem.

Se você estiver fazendo muito trabalho vinculado a IO, tenderá a descobrir que o uso asyncgeneralizado é uma ótima adição, muitas de suas rotinas o serão async. No entanto, quando você começa a fazer o trabalho vinculado à CPU, em geral, fazer as coisas asyncnão é bom - está escondendo o fato de que você está usando ciclos de CPU em uma API que parece ser assíncrona, mas não é necessariamente realmente assíncrona.

Reed Copsey
fonte
Exatamente o que eu ia escrever (desempenho), compatibilidade com versões anteriores pode ser outra coisa, dlls para serem usados ​​com linguagens mais antigas que não suportam async /
await
Tornar sqrt assíncrono não é ridículo se simplesmente removermos a distinção entre funções de sincronização e assíncronas
talkol
@talkol Eu acho que eu mudaria isso - por que cada chamada de função deveria assumir a complexidade da assincronia?
Reed Copsey
2
@talkol, eu diria que não é necessariamente verdade - a assincronia pode adicionar bugs que são piores do que bloquear ...
Reed Copsey
1
@talkol Como é await FooAsync()mais simples do que Foo()? E em vez de um pequeno efeito dominó algumas vezes, você tem um enorme efeito dominó o tempo todo e chama isso de uma melhoria?
svick
4

Desempenho à parte - assíncrono pode ter um custo de produtividade. No cliente (WinForms, WPF, Windows Phone) é uma vantagem para a produtividade. Mas no servidor, ou em outros cenários não UI, você paga produtividade. Você certamente não quer ficar assíncrono por padrão lá. Use-o quando precisar das vantagens de escalabilidade.

Use-o quando estiver no ponto ideal. Em outros casos, não.

usr
fonte
2
+1 - A simples tentativa de ter um mapa mental de código executando 5 operações assíncronas em paralelo com uma sequência aleatória de conclusão irá, para a maioria das pessoas, fornecer dor suficiente para um dia. Raciocinar sobre o comportamento do código assíncrono (e, portanto, inerentemente paralelo) é muito mais difícil do que o bom e velho código síncrono ...
Alexei Levenkov
2

Acredito que haja um bom motivo para tornar todos os métodos assíncronos se eles não forem necessários - extensibilidade. Os métodos de criação seletiva assíncronos só funcionam se seu código nunca evolui e você sabe que o método A () é sempre vinculado à CPU (você o mantém sincronizado) e o método B () é sempre vinculado à E / S (você o marca como assíncrono).

Mas e se as coisas mudarem? Sim, A () está fazendo cálculos, mas em algum momento no futuro você teve que adicionar registro lá, ou relatórios, ou retorno de chamada definido pelo usuário com implementação que não pode prever, ou o algoritmo foi estendido e agora inclui não apenas cálculos de CPU, mas também algum I / O? Você precisará converter o método para assíncrono, mas isso interromperia a API e todos os chamadores na pilha também precisariam ser atualizados (e eles podem até ser aplicativos diferentes de fornecedores diferentes). Ou você precisará adicionar a versão assíncrona junto com a versão de sincronização, mas isso não faz muita diferença - usar a versão de sincronização bloquearia e, portanto, dificilmente é aceitável.

Seria ótimo se fosse possível tornar o método de sincronização existente assíncrono sem alterar a API. Mas, na realidade, não temos essa opção, acredito, e usar a versão assíncrona, mesmo que não seja necessária no momento, é a única maneira de garantir que você nunca tenha problemas de compatibilidade no futuro.

Alex
fonte
Embora pareça um comentário extremo, há muita verdade nele. Primeiro, devido a este problema: "isso quebraria a API e todos os chamadores subiriam na pilha" async / await torna um aplicativo mais fortemente acoplado. Ele poderia facilmente quebrar o princípio de Substituição de Liskov se uma subclasse quiser usar async / await, por exemplo. Além disso, é difícil imaginar uma arquitetura de microsserviços em que a maioria dos métodos não precisasse de assíncrono / espera.
ItsAllABadJoke