Por que alguém usaria a tarefa <T> sobre ValueTask <T> em c #?

168

A partir do C # 7.0, os métodos assíncronos podem retornar ValueTask <T>. A explicação diz que ele deve ser usado quando temos um resultado em cache ou simulamos assíncrono via código síncrono. No entanto, ainda não entendo qual é o problema de usar o ValueTask sempre ou, de fato, por que o async / waitit não foi criado com um tipo de valor desde o início. Quando o ValueTask falharia ao executar o trabalho?

Stilgar
fonte
7
Suspeito que esteja relacionado aos benefícios de ValueTask<T>(em termos de alocações) não se materializar para operações que são realmente assíncronas (porque nesse caso ValueTask<T>ainda precisará de alocação de heap). Há também a questão de Task<T>ter muito outro suporte nas bibliotecas.
Jon Skeet
3
As bibliotecas existentes do @JonSkeet são um problema, mas isso levanta a questão: a Task deve ter sido ValueTask desde o início? Os benefícios podem não existir ao usá-lo para coisas assíncronas reais, mas são prejudiciais?
Stilgar
8
Veja github.com/dotnet/corefx/issues/4708#issuecomment-160658188 por mais sabedoria do que eu seria capaz de transmitir :)
Jon Skeet
3
@JoelMueller engrossa trama :)
Stilgar

Respostas:

245

Dos documentos da API (ênfase adicionada):

Os métodos podem retornar uma instância desse tipo de valor quando é provável que o resultado de suas operações esteja disponível de forma síncrona e quando se espera que o método seja chamado com tanta frequência que o custo de alocar um novo Task<TResult>para cada chamada seja proibitivo.

Existem vantagens em usar a em ValueTask<TResult>vez de a Task<TResult>. Por exemplo, embora um ValueTask<TResult>possa ajudar a evitar uma alocação no caso em que o resultado bem-sucedido esteja disponível de forma síncrona, ele também contém dois campos, enquanto a Task<TResult>como tipo de referência é um único campo. Isso significa que uma chamada de método acaba retornando dois campos de dados em vez de um, que são mais dados para copiar. Isso também significa que se um método que retorna um deles for aguardado dentro de um asyncmétodo, a máquina de estado para esse asyncmétodo será maior devido à necessidade de armazenar a estrutura de dois campos em vez de uma única referência.

Além disso, para outros usos além de consumir o resultado de uma operação assíncrona via await, ValueTask<TResult>pode levar a um modelo de programação mais complicado, que por sua vez pode realmente levar a mais alocações. Por exemplo, considere um método que possa retornar a Task<TResult>com uma tarefa em cache como resultado comum ou a ValueTask<TResult>. Se o consumidor do resultado quiser usá-lo como a Task<TResult>, como usar em métodos como Task.WhenAlle Task.WhenAny, o ValueTask<TResult>primeiro precisará ser convertido em um Task<TResult>uso AsTask, o que leva a uma alocação que seria evitada se um cache Task<TResult>tivesse sido usado em primeiro lugar.

Como tal, a opção padrão para qualquer método assíncrono deve ser retornar um Taskou Task<TResult>. Somente se a análise de desempenho provar que vale a pena deve ValueTask<TResult>ser usada em vez de Task<TResult>.

Stephen Cleary
fonte
7
@MattThomas: ele salva uma Taskalocação única (que é pequena e barata hoje em dia), mas com o custo de aumentar a alocação existente do chamador e dobrar o tamanho do valor de retorno (afetando a alocação de registros). Embora seja uma escolha clara para um cenário de leitura em buffer, aplicá-lo por padrão a todas as interfaces não é algo que eu recomendaria.
Stephen Cleary
1
Certo, pode Taskou ValueTaskpode ser usado como um tipo de retorno síncrono (com Task.FromResult). Mas ainda há valor (heh) ValueTaskse você tiver algo que espera ser síncrono. ReadByteAsyncsendo um exemplo clássico. Eu acredito que ValueTask foi criado principalmente para os novos "canais" (fluxos de baixo nível de byte), possivelmente também utilizados em núcleo ASP.NET onde o desempenho realmente importa.
Stephen Cleary
1
Oh, eu sei que lol, só estava me perguntando se você tinha algo a acrescentar a esse comentário específico;)
julealgon
2
Será que este PR mudar o equilíbrio até preferindo ValueTask? (ref: blog.marcgravell.com/2019/08/… )
stuartd
2
@stuartd: Por enquanto, eu ainda recomendaria usar Task<T>como padrão. Isso ocorre apenas porque a maioria dos desenvolvedores não está familiarizada com as restrições existentes ValueTask<T>(especificamente, a regra "apenas consumir uma vez" e a regra "sem bloqueio"). Dito isto, se todos os desenvolvedores da sua equipe forem bons ValueTask<T>, eu recomendaria uma diretriz de preferência no nível da equipeValueTask<T> .
Stephen Cleary
104

No entanto, ainda não entendo qual é o problema de usar o ValueTask sempre

Tipos de estrutura não são livres. A cópia de estruturas maiores que o tamanho de uma referência pode ser mais lenta que a cópia de uma referência. Armazenar estruturas maiores que uma referência requer mais memória do que armazenar uma referência. Estruturas maiores que 64 bits podem não ser registradas quando uma referência pode ser registrada. Os benefícios de uma menor pressão de coleta não podem exceder os custos.

Os problemas de desempenho devem ser abordados com uma disciplina de engenharia. Estabeleça metas, avalie seu progresso em relação às metas e, em seguida, decida como modificar o programa se as metas não forem cumpridas, medindo ao longo do caminho para garantir que suas alterações sejam realmente melhorias.

por que async / waitit não foi construído com um tipo de valor desde o início.

awaitfoi adicionado ao C # por muito tempo após o Task<T>tipo já existir. Teria sido um pouco perverso inventar um novo tipo quando ele já existisse. E awaitpassou por muitas iterações de design antes de escolher a que foi enviada em 2012. O perfeito é o inimigo do bem; melhor enviar uma solução que funcione bem com a infraestrutura existente e, se houver demanda do usuário, forneça melhorias posteriormente.

Observo também que o novo recurso de permitir que tipos fornecidos pelo usuário sejam a saída de um método gerado pelo compilador adiciona riscos e encargos de teste consideráveis. Quando as únicas coisas que você pode retornar são nulas ou uma tarefa, a equipe de teste não precisa considerar nenhum cenário em que algum tipo absolutamente louco seja retornado. Testar um compilador significa descobrir não apenas quais programas as pessoas provavelmente escreverão, mas quais programas serão possíveis de escrever, porque queremos que o compilador compile todos os programas legais, não apenas todos os programas sensíveis. Isso é caro.

Alguém pode explicar quando o ValueTask falharia em fazer o trabalho?

O objetivo da coisa é melhorar o desempenho. Não funciona se não melhora o desempenho de forma mensurável e significativa . Não há garantia de que sim.

Eric Lippert
fonte
23

ValueTask<T>não é um subconjunto Task<T>, é um superconjunto .

ValueTask<T>é uma união discriminada de um T e um Task<T>, tornando-o livre de alocação para ReadAsync<T>retornar de forma síncrona um valor T disponível (em contraste com o uso Task.FromResult<T>, que precisa alocar uma Task<T>instância). ValueTask<T>é aguardável, portanto, a maior parte do consumo de instâncias será indistinguível de com a Task<T>.

O ValueTask, por ser uma estrutura, permite escrever métodos assíncronos que não alocam memória quando executados de forma síncrona sem comprometer a consistência da API. Imagine ter uma interface com um método de retorno de tarefas. Cada classe que implementa essa interface deve retornar uma tarefa, mesmo que seja executada de forma síncrona (usando o Task.FromResult). É claro que você pode ter 2 métodos diferentes na interface, um síncrono e um assíncrono, mas isso requer 2 implementações diferentes para evitar “sincronização por async” e “async por sincronização”.

Assim, você pode escrever um método assíncrono ou síncrono, em vez de escrever um método idêntico para cada um. Você pode usá-lo em qualquer lugar que usar, Task<T>mas muitas vezes não adicionaria nada.

Bem, isso acrescenta uma coisa: adiciona uma promessa implícita ao chamador de que o método realmente usa a funcionalidade adicional que ValueTask<T>fornece. Pessoalmente, prefiro escolher os tipos de parâmetro e retorno que informam ao chamador o máximo possível. Não retorne IList<T>se a enumeração não puder fornecer uma contagem; não volte IEnumerable<T>se puder. Seus consumidores não precisam procurar nenhuma documentação para saber quais dos seus métodos podem ser razoavelmente chamados de forma síncrona e quais não.

Não vejo mudanças no design futuro como um argumento convincente lá. Muito pelo contrário: se um método alterar sua semântica, ele deve interromper a construção até que todas as chamadas sejam atualizadas de acordo. Se isso for considerado indesejável (e acredite, sou solidário com o desejo de não quebrar a compilação), considere o versionamento da interface.

É para isso que serve a digitação forte.

Se alguns dos programadores que projetam métodos assíncronos em sua loja não puderem tomar decisões informadas, pode ser útil designar um mentor sênior para cada um desses programadores menos experientes e fazer uma revisão semanal do código. Se eles acharem errado, explique por que isso deve ser feito de maneira diferente. É uma sobrecarga para os caras mais velhos, mas isso levará os juniores a acelerar muito mais rapidamente do que apenas jogá-los no fundo do poço e dar a eles alguma regra arbitrária a seguir.

Se o cara que escreveu o método não sabe se pode ser chamado de forma síncrona, quem sabe ?!

Se você tem tantos programadores inexperientes escrevendo métodos assíncronos, essas mesmas pessoas também os chamam? Eles estão qualificados para descobrir por si mesmos quais são seguros para chamar de assíncrono ou começarão a aplicar uma regra igualmente arbitrária à maneira como chamam essas coisas?

O problema aqui não são seus tipos de retorno, são os programadores sendo colocados em funções para as quais não estão prontos. Isso deve ter acontecido por um motivo, por isso tenho certeza de que não pode ser trivial de corrigir. Descrevê-lo certamente não é uma solução. Mas procurar uma maneira de ocultar o problema além do compilador também não é uma solução.

15ee8f99-57ff-4f92-890c-b56153
fonte
4
Se eu vir um método retornando ValueTask<T>, presumo que o cara que escreveu o método fez isso porque o método realmente usa a funcionalidade ValueTask<T>adicionada. Não entendo por que você acha desejável que todos os seus métodos tenham o mesmo tipo de retorno. Qual é o objetivo lá?
15ee8f99-57ff-4f92-890c-b56153
2
O objetivo 1 seria permitir alterar a implementação. E se eu não estiver armazenando o resultado no cache agora, mas adicionar o código em cache posteriormente? O objetivo 2 seria ter uma orientação clara para uma equipe na qual nem todos os membros entendam quando o ValueTask é útil. Apenas faça-o sempre se não houver mal em usá-lo.
Stilgar
6
Na minha opinião, é quase como ter todos os seus métodos retornados, objectporque talvez um dia você queira que eles retornem em intvez de string.
15ee8f99-57ff-4f92-890c-b56153
3
Talvez, mas se você fizer a pergunta "por que você retornaria uma string em vez de um objeto", posso respondê-la facilmente, apontando para a falta de segurança do tipo. Os motivos para não usar o ValueTask em qualquer lugar parecem ser muito mais sutis.
Stilgar
3
@Tilgar Uma coisa que eu diria: problemas sutis devem preocupar você mais do que os óbvios.
15ee8f99-57ff-4f92-890c-b56153
20

Há algumas mudanças no .Net Core 2.1 . A partir do .net core 2.1, o ValueTask pode representar não apenas as ações concluídas síncronas, mas também a assíncrona. Além disso, recebemos o ValueTasktipo não genérico .

Deixarei o comentário de Stephen Toub relacionado à sua pergunta:

Ainda precisamos formalizar a orientação, mas espero que seja algo assim para a área de superfície pública da API:

  • Tarefa fornece mais usabilidade.

  • O ValueTask fornece mais opções para otimização de desempenho.

  • Se você estiver escrevendo um método de interface / virtual que outros substituirão, o ValueTask é a escolha padrão certa.

  • Se você espera que a API seja usada em caminhos ativos nos quais as alocações serão importantes, o ValueTask é uma boa opção.

  • Caso contrário, onde o desempenho não for crítico, o padrão é Tarefa, pois fornece melhores garantias e usabilidade.

Do ponto de vista da implementação, muitas das instâncias ValueTask retornadas ainda serão apoiadas pelo Task.

O recurso pode ser usado não apenas no .net core 2.1. Você poderá usá-lo com o pacote System.Threading.Tasks.Extensions .

unsafePtr
fonte
1
Mais de Stephen hoje: blogs.msdn.microsoft.com/dotnet/2018/11/07/…
Mike Cheel