C# - Usar Task ou ValueTask ?


 Hoje veremos como usar o recurso ValueTask para evitar alocação quando do retorno de objetos Task a partir de um método assíncrono.

O tipo de retorno recomendado de um método assíncrono em C# é Task, e, você poderá retornar um Task<T> se estiver criando um método assíncrono que retorne um valor ou retornar void se estiver criando um manipulador de eventos.

Até a versão 7.0 do  C#, um método assíncrono poderia retornar Task, Task<T> ou void.

A partir do C# 7.0, um método assíncrono também pode retornar ValueTask (disponível como parte do pacote System.Threading.Tasks.Extensions) ou ValueTask<T>.

Mas porque eu deveria usar ValueTask ?

Uma Task ou Tarefa representa o estado de alguma operação, ou seja, se a operação está concluída, cancelada e assim por diante. Um método assíncrono pode retornar um Task ou um ValueTask.

Agora, como Task é um tipo de referência, retornar um objeto Task de um método assíncrono implica alocar o objeto no heap gerenciado toda vez que o método for chamado.

Portanto, uma ressalva ao usar o Task é que você precisa alocar memória no heap gerenciado toda vez que retornar um objeto Task do seu método. Se o resultado da operação executada pelo seu método estiver disponível imediatamente ou for concluído de forma síncrona, essa alocação não será necessária e, portanto, será custosa.

É aqui que o novo recurso ValueTask vem nos ajudar.

Usar ValueTask<T> oferece dois grandes benefícios.

  1. Ele melhora o desempenho porque não precisa de alocação de heap;
  2. É fácil e flexível de implementar;

Ao retornar ValueTask<T> em vez de Task<T> de um método assíncrono quando o resultado estiver disponível imediatamente, você poderá evitar a sobrecarga desnecessária de alocação, pois "T" aqui representa uma struct e uma struct em C# é um tipo de valor (em contraste com o "T" em Task<T>, que representa uma classe).

Assim, Task e ValueTask representam dois tipos principais "awaitable" em C#.

Note que você não pode bloquear em uma ValueTask. Se você precisar bloquear, converta o ValueTask em uma Task usando o método AsTask e, em seguida, bloqueie o objeto Task de referência.

Observe também que cada ValueTask pode ser consumido apenas uma vez. Aqui, a palavra "consumir" implica que um ValueTask pode esperar de forma assíncrona (await) a operação concluir ou tirar proveito do AsTask para converter um ValueTask em um Task. No entanto, um ValueTask deve ser consumido apenas uma vez, após o qual o ValueTask<T> deve ser ignorado.

Resumindo:

Task<T> é uma classe e causa a sobrecarga desnecessária de sua alocação quando o resultado está disponível imediatamente.

ValueTask<T> é uma struct e foi introduzida para impedir a alocação de um objeto Task, caso o resultado da operação assíncrona já esteja disponível no momento da espera.

Exemplo de uso de ValueTask

Veremos então como usar o ValueTask na linguagem C#, disponível a partir da versão 7.0, usando o Visual Studio 2019 (versão 16.6.5).

Vamos criar uma aplicação Console do tipo (.NET Core)

Vamos criar uma classe Demo e definir dois métodos :

  1. TesteTask
  2. TesteValueTask

O método TesteTask requer a alocação no heap já o método TesteValueTask não precisa de alocação se o resultado for conhecido assincronamente.

Além disso as implementações de uma interface assíncrona que desejassem ser síncronas seriam forçadas a usar Task.Run ou Task.FromResult (resultando na penalidade de desempenho discutida acima). Portanto, há alguma pressão contra implementações síncronas.

Mas com o ValueTask<T>, as implementações são mais livres para escolher entre serem síncronas ou assíncronas sem afetar os seus chamadores.

No exemplo abaixo temos uma interface com um método síncrono:

    using System.Threading.Tasks;
    public interface IRepository<T>
    {
        ValueTask<T> GetData();
    }

A seguir temos a classe Repository<T> implementando a interface:

    using System.Threading.Tasks;
    public class Repository<T> : IRepository<T>
    {
        public ValueTask<T> GetData()
        {
            var valor = default(T);
            return new ValueTask<T>(valor);
        }
    }

Abaixo temos como chamar o método GetData() a partir do método Main da classe Program:

    class Program
    {
        static void Main(string[] args)
        {
            IRepository<int> repository = new Repository<int>();            
            var resultado = repository.GetData();
            if (resultado.IsCompleted)
            {
                Console.WriteLine("Operação encerrada...");
            }
            else
            {
                Console.WriteLine("Operação incompleta...");
            }
            Console.ReadKey();
        }
    }

Muito bem, vamos agora incluir outro método em nosso repositório.

Dessa vez vamos incluir um método assíncrono chamado GetDataAsync() iniciando com a definição do contrato na interface IRepository<T> :

    using System.Threading.Tasks;
    public interface IRepository<T>
    {
        ValueTask<T> GetData();
          ValueTask<T> GetDataAsync();
    }

Depois faremos a implementação do método na classe Repository<T>:

    using System.Threading.Tasks;
    public class Repository<T> : IRepository<T>
    {
        public ValueTask<T> GetData()
        {
            var valor = default(T);
            return new ValueTask<T>(valor);
        }
        public async ValueTask<T> GetDataAsync()
        {
              var valor = default(T);
              await Task.Delay(100);
              return valor;
          }
    }

Temos que com ValueTask o código vai funcionar com implementações síncronas ou assíncronas. Basta fazer a invocação do método conforme mostrado abaixo:

IRepository<int> repository = new Repository<int>();
var resultado = repository.GetDataAsync();

Agora você deve levar em consideração outros fatores que podem fazer com que usar ValueTask nem sempre seja o mais indicado.

ValueTask é um tipo de valor com dois campos, enquanto Task é um tipo de referência com um único campo. Portanto, usar um ValueTask significa trabalhar com mais dados, pois uma chamada de método retornaria dois campos de dados em vez de um.

Além disso, se você usar await em um método que retorne uma ValueTask, o state machine desse método assíncrono também seria maior - porque precisaria acomodar uma estrutura que contém dois campos, em vez de uma única referência no caso de usar uma Task.

Além disso, se o consumidor de um método assíncrono usar Task.WhenAll ou Task.WhenAny, ao usar ValueTask<T> como um tipo de retorno em um método assíncrono pode tornar o processo mais oneroso, pois precisaria converter o ValueTask<T> em Task<T> usando o método AsTask, o que incorreria em uma alocação que poderia ser facilmente evitada se uma Task<T> em cache tivesse sido usada em primeiro lugar.

Pegue o código do projeto aqui : CShap_ValueTask1.zip

"E em nada vos espanteis dos que resistem, o que para eles, na verdade, é indício de perdição, mas para vós de salvação, e isto de Deus. Porque a vós vos foi concedido, em relação a Cristo, não somente crer nele, como também padecer por ele"
Filipenses 1:28,29

Referências:


José Carlos Macoratti