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.
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>
.
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
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 useValueTask
in asynchronous operations that may take a long time to complete.- We should await a
ValueTask
only once. Once we await aValueTask
we should not do anything with it. Because the underlying object might already be recycled. For example, if we store aValueTask
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 aTask
by calling theAsTask
method. However, it is important to note that we can not callAsTask
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.