If we want to optimize our code and improve the performance of our app, there is no one go-to solution. Instead, we analyze our code and try to find where to improve. Understanding Task<T> and ValueTask<T> in C# is essential because using them correctly significantly enhances our app’s performance. In this article, we will try to understand what Task<T> and ValueTask<T> are and when to choose one over the other.

To download the source code for this article, you can visit our GitHub repository.

Let’s dig in.

What is Task<T>?

The Task class resides under the System.Threading.Tasks namespace. Task help us execute a section of our code in the thread pool outside of the application thread. Tasks may or may not return a value. For tasks that don’t return value, we use Task. And for tasks that return value, we use Task<T>.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

In this article, we will focus more on the behavior of Task that differentiates it from ValueTask. To learn more about Task check our articles: Asynchronous Programming with Async and Await in ASP.NET Core, How to Execute Multiple Tasks Asynchronously in C#, and Tasks VS Threads in C#.

Task is a class that contains different methods and properties to manage the state of code execution that will complete in the future. Because Task is a class, every time a method returns a Task an object is created on the heap memory. This object contains the state of our code segment that runs either synchronously or asynchronously and will complete eventually. The different states(such as completed, canceled, and failed) of Task are accessible using different properties.

How to Use Task<T>

To better understand how we can use Task let’s inspect the example code:

public static class DummyWeatherProvider
{
    public static async Task<Weather> Get(string city)
    {
        await Task.Delay(10);
        var weather = new Weather 
        { 
            City = city, 
            Date = DateTime.Now, 
            AvgTempratureF = new Random().Next(5, 70) 
        };
        
        return weather;
    }
}

As its name suggests, the DummyWeatherProvider class contains one method named Get that returns a dummy weather value for any city. Inside the Get method, we simply wait for five seconds and then create an instance of type Weather by assigning the Date property to the current date and the average temperature (Fahrenheit) property AvgTempratureF to a random number between 5 and 70.

Next, let’s look at how we can check the status of a Task:

static async Task CheckTaskStatus()
{
    var task = DummyWeatherProvider.Get("Stockholm");
    LogTaskStatus(task.Status);

    await task;
    LogTaskStatus(task.Status);
}

static void LogTaskStatus(TaskStatus status)
{
    Console.WriteLine($"Task Status: {Enum.GetName(typeof(TaskStatus), status)}");
}

TaskStatus is an enumeration type that contains different values (such as WaitingForActivation, Running, RanToCompletion, Canceled, and more ) for different states of a Task. Inside the  CheckTaskStatus method, we are invoking the Get method of DummyWeatherProvider. Consecutively, we are calling LogTaskStatus method to print the status of the task.

Inside the LogTaskStatus we are using the Status property (that is of type TaskStatus) of a Task to print the status.  Finally, we are waiting for the task to complete and print the task status again.

Let’s run the program and take a look at the sample output:

Task Status: WaitingForActivation
Task Status: RanToCompletion

The WaitingForActivation indicates the task is waiting to be activated and scheduled internally. The next state RunToCompletion indicates successful completion of the task.

What is ValueTask<T>?

Similar to Task, ValueTask helps us to manage the status of future completing code. But unlike Task, ValueTask is of type struct which creates a fundamental difference in the way memory is allocated for Task and ValueTask.

In terms of speed, it is difficult to determine a clear winner between Task  and ValueTask depending on the specific scenario, one can be faster than the other. However, there is a significant difference in memory allocation.

Let’s take a closer look at the difference using the example code:

public class WeatherService
{
    private readonly ConcurrentDictionary<string, Weather> _cache;

    public WeatherService()
    {
        _cache = new();
    }

    public async Task<Weather> GetWeatherTask(string city)
    {
        if (!_cache.ContainsKey(city))
        {
            var weather = await DummyWeatherProvider.Get(city);
            _cache.TryAdd(city, weather);
        }

        return _cache[city];
    }

    public async ValueTask<Weather> GetWeatherValueTask(string city)
    {
        if (!_cache.ContainsKey(city))
        {
            var weather = await DummyWeatherProvider.Get(city);
            _cache.TryAdd(city, weather);
        }

        return _cache[city];   
    }
}

The WeatherService contains a private field of type collection and two methods. We are using the _cache field as an in-memory cache. The implementation logic of both GetWeatherTask and GetWeatherValueTask is identical: we start by checking if there is a weather value of a city in _cache. If the value exists, we simply return the value. Otherwise, we call DummyWeatherProvider.Get to get the weather value.

Next, we store the value in _cache and return the weather value. The key and the only difference between GetWeatherTask and GetWeatherValueTask is the return type. One returns Task<T> the other returns ValueTask<T>. 

Now, let’s run a benchmark between the two implementations.

Task and ValueTask Benchmark

In this section, we will focus more on the benchmark results of Task and ValueTask. To learn more about benchmarking in .NET, please check our article Introduction to Benchmarking in C# and ASP.NET Core Projects.

Let’s inspect the benchmark implementation for Task and ValueTask:

[MemoryDiagnoser]
public class TaskAndValueTaskBenchmark
{
    private readonly WeatherService _weatherService;

    public TaskAndValueTaskBenchmark()
    {
        _weatherService = new();
    }
    
    [Benchmark]
    [Arguments("Denver")]
    public async Task<Weather> TaskBenchmark(string city)
    {
        return await _weatherService.GetWeatherTask(city);
    }

    [Benchmark]
    [Arguments("London")]
    public async ValueTask<Weather> ValueTaskBenchmark(string city)
    {
        return await _weatherService.GetWeatherValueTask(city);
    }
}

We apply the MemoryDiagnoser attribute to TaskAndValueTaskBenchmark class. This will enable the collection of the memory usage information for the benchmark methods.  

Now, we can inspect the sample result:

|             Method |   city |     Mean |    Error |   StdDev |   Gen0 | Allocated |
|------------------- |------- |---------:|---------:|---------:|-------:|----------:|
|      TaskBenchmark | Denver | 71.93 ns | 0.084 ns | 0.070 ns | 0.0229 |     144 B |
| ValueTaskBenchmark | London | 60.40 ns | 0.502 ns | 0.470 ns |      - |         - |

The Gen0 and Allocated columns indicate the GC collection and memory allocation information respectively. For our example, a few bytes are allocated for Task, but there is no memory allocation for the ValueTask. This clearly demonstrates the advantages of ValueTask over a Task.

Benefits of ValueTask<T>

Benefits of ValueTask over Task are results of it being a struct type. structs are stored on the stack rather than the heap, and they are automatically cleaned up when they go out of scope. As a result, ValueTask significantly reduces the memory pressure on the garbage collector. Moreover, in a scenario where the hot path in our code executes synchronously, it is better to use ValueTask instead of Task. The hot path is a section of our code that executes frequently.

Let’s take the GetWeatherValueTask method as an example: Here, the code that gets executed frequently is where we check if a weather value exists for a city and return the value. We make the asynchronous call only if the weather value of a city doesn’t exist. Therefore this is not part of the hot path; as a result, there is no need to create an instance of Task which makes ValueTask the right choice.

ValueTask<T> Caveats

Up until this point, we have seen that similar to Task we can use ValueTask for asynchronous code execution. ValueTask is even less expensive memory-wise and has less overhead on GC. So, why do we need Task? Well, due to the  nature of ValueTask, it comes with certain limitations:

  • ValueTask is suitable for an asynchronous operation that involves synchronous hot paths. We should not use ValueTask in asynchronous operations that may take a long time to complete.
  • We should await a ValueTask only once. Once we await a  ValueTask we should not do anything with it. Because the underlying object might already be recycled. For example, if we store a ValueTask in a variable and then try to await it multiple times inside one or more methods, it will create a problem.
  • If we have a scenario where we have to await ValueTask multiple times, we must first convert it into a Task by calling the AsTask method. However, it is important to note that we can not call AsTask more than once.
  • We can not access ValueTask from multiple threads concurrently.

Using ValueTask introduces additional overhead, and the default return type for asynchronous operation should be Task. We should use ValueTask only if it gives us significant performance gains over a Task based on our benchmarks.

Conclusion

In this article, we have learned what Task and ValueTask are, how we use them, benefits of ValueTask and when we should use it, and last but not least, we discussed the caveats of ValueTask and when we shouldn’t use them.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!