Updated on
Return Task by default. Use ValueTask only for a method that is called constantly and usually finishes without ever awaiting anything (a cache lookup that normally hits, a buffered read that normally has data).
The reason is what each one costs when it completes synchronously. Task normally allocates an object to say “already done”, unless a cached one applies; ValueTask is a struct and, on that path, allocates nothing. When the method actually goes asynchronous, ValueTask gives that saving back and adds rules about how it may be consumed.
What Is Task<T> in C#?
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 ten milliseconds 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.
That delay is deliberately asynchronous. If we are unsure why it is not a blocking sleep, our comparison of Thread.Sleep versus Task.Delay covers what changes when a thread waits instead of yielding.
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.
We wait with await rather than task.Wait() on purpose, and our article on await versus Task.Wait() explains why the blocking form is the one that deadlocks.
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> in C#?
ValueTask<T> is a struct that can hold a result that is already available, or wrap a Task<T> or an IValueTaskSource<T> for work that is still running.
That dual nature is the whole design. When an async method returns a value it already had (a cache hit, a buffer that still has bytes), ValueTask<T> carries the value directly, and nothing is allocated on the heap. When the method genuinely has to wait, it falls back to holding a task, and we are back to an allocation.
So ValueTask<T> does not make asynchronous work cheaper. It makes skipping the asynchronous work cheaper, on methods where that is the common case.
It also comes with a rule that Task does not have: a ValueTask may be awaited once. Awaiting it twice, storing it, or handing it to two threads is undefined behaviour, not a slow path.
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("Denver")]
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.
Both methods take the same city argument on purpose. The two benchmarks differ only in their return type, so giving them different cities would have made them differ in their input as well, and the rows would no longer be comparable.
Now, we can inspect the sample result:
| Method | city | Mean | Error | StdDev | Gen0 | Allocated | |------------------- |------- |---------:|---------:|---------:|-------:|----------:| | TaskBenchmark | Denver | 85.36 ns | 2.166 ns | 6.216 ns | 0.0172 | 144 B | | ValueTaskBenchmark | Denver | 60.61 ns | 1.514 ns | 4.392 ns | - | - |
The Gen0 and Allocated columns indicate the GC collection and memory allocation information respectively. After the first call, both methods find the city in _cache and return without ever awaiting, and on that path Task allocates 144 bytes per call while ValueTask allocates nothing at all.
That zero is the entire result. The mean times are close enough that they move between runs on the same machine, but the allocation column does not move: it is 144 B against 0 B, on the synchronous path, every time.
Task vs ValueTask in C#: What Is the Difference?
Task is a class and ValueTask is a struct, and every practical difference follows from that.
Because Task is a class, returning one allocates an object on the heap, even when the work is already finished. Because ValueTask is a struct, returning an already-known result allocates nothing: the value travels inside the struct itself.
Because Task is a class, it is safe to pass around, store, hand to Task.WhenAll, and await from several places. A Task remembers its outcome and will hand it out again.
ValueTask promises none of that. Once we have awaited it, the object backing it may be recycled and handed to something else entirely, so a second await may throw, or may quietly hand back another operation’s result.
When we need any of Task‘s freedoms (storing it, sharing it, awaiting it twice), AsTask() converts the ValueTask once and gives all of them back.
The reference states the constraint directly: “A ValueTask<TResult> instance may only be awaited once, and consumers may not read Result until the instance has completed”.
Conversion runs in both directions, and the constructors are all we need:
Task<int> existingTask = Task.FromResult(42); ValueTask<int> fromTask = new ValueTask<int>(existingTask); ValueTask<int> fromValue = new ValueTask<int>(42); Task<int> backToTask = fromValue.AsTask();
One constructor wraps a task that is already running, the other wraps a value we already have, and AsTask() converts back. Wrapping a value we already have is the interesting case, and it is the same question our article on what to return from an async method that has nothing to await answers for Task.
Benefits of ValueTask<T>
The saving comes from one place: the path where an asynchronous method finishes without ever suspending.
It is tempting to explain it as “structs live on the stack, so nothing is allocated”, and that explanation is wrong in the way that leads to ValueTask being put on every asynchronous method. A struct that is a field of a class lives on the heap along with that class, and a ValueTask returned by a method that really does suspend ends up on the heap too, boxed into the state machine.
What the compiler-generated builder does is more specific. When the method completes synchronously, the builder keeps the result in a field and never allocates a task object at all. When the method suspends, it falls back to the task-based builder and allocates the state-machine box, which itself derives from Task<TResult> — one object, the same count a Task-returning method pays.
So ValueTask does not make the asynchronous path cheaper. It removes the allocation from the synchronous path and leaves the asynchronous path exactly where it was, which is why the benchmark above reads 0 B for ValueTask on a cache hit and would read the same 144 B on a cache miss. The hot path is a section of our code that executes frequently, and that is the only place the saving repays the extra rules.
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.
That shape (mostly buffered, occasionally not) is common enough that it has its own place in the asynchronous programming patterns in .NET.
When Should We Use ValueTask Instead of Task?
Use ValueTask when three things are all true: the method is on a hot path, it usually completes without awaiting, and we have measured that the allocations matter.
If any one of them is false, return Task. Microsoft’s ValueTask<TResult> reference says “the default choice for any asynchronous method should be to return a Task or Task<TResult>”, and the object it allocates is small and short-lived, which is the case the garbage collector handles best.
The clearest fit is a method that reads from something buffered. Stream.ReadAsync returns ValueTask<int> because most reads are served from a buffer already in memory, and only occasionally touch the disk.
The clearest mistake is putting ValueTask on an API that always does real work (a database call, an HTTP request). There is no synchronous path to optimise, so the rules arrive without the benefit.
| Criterion | Task / Task<T> | ValueTask / ValueTask<T> |
|---|---|---|
| Kind | Class (reference type) | Struct (value type) |
| Allocation when completing synchronously | An object, unless a cached one applies | None |
| Allocation when completing asynchronously | An object | An object; the struct is copied, not allocated a second time |
| Await it more than once | Allowed | Not allowed |
| Store it in a field or a collection | Allowed | Unsafe: call AsTask() or Preserve() first |
await from multiple threads | Allowed | Not allowed |
Use with Task.WhenAll / WhenAny | Directly | Call AsTask() first |
| Cached "already finished" instance | Task.CompletedTask, Task.FromResult | ValueTask.CompletedTask, ValueTask.FromResult(value) |
| Default choice for a public API | Yes | Only with a measurement behind it |
Those rules are worth spelling out, because each one is a way of using a ValueTask that the type does not support:
ValueTaskis suitable for an asynchronous operation that involves synchronous hot paths. We should not useValueTaskin asynchronous operations that may take a long time to complete.- We should await a
ValueTaskonly once. Once we await aValueTaskwe should not do anything with it. Because the underlying object might already be recycled. For example, if we store aValueTaskin 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
ValueTaskmultiple times, we must first convert it into aTaskby calling theAsTaskmethod. However, it is important to note that we can not callAsTaskmore than once. - We can not access
ValueTaskfrom 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.
The rule that survives all of it is short: return Task unless a benchmark tells us otherwise. When we do reach for ValueTask, it is usually because a method streams results a piece at a time, which is the same territory as asynchronous streams with IAsyncEnumerable.
Tested with .NET 10.0.10.
