Propagating ValueTask to callers

135 Views Asked by At

Say I have a method signature like this:

public async ValueTask<int> GetSomeValueAsync(int number);

Internally it may make an async HTTP call but it caches the result so next time it is called with the same number it can return the value from cache without the async call. Thus it returns a ValueTask as this avoids the overhead of creating a Task for the synchronous path of execution.

My question is, the caller of this method, if it makes no other async calls, should the signature be a Task or should ValueTask be propagated all the way up the call stack?

E.g. should it be this:

public async Task<int> CallingMethodAsync(int number)
{
   return await GetSomeValueAsync(number) + 1;
}

Or should it be:

public async ValueTask<int> CallingMethodAsync(int number)
{
   return await GetSomeValueAsync(number) + 1;
}

(examples above are simplified to illustrate the point)

For clarity, this question is not about whether GetSomeValueAsync() should return ValueTask or Task. I understand there are situations for both but take it as such that this evaluation has been made the outcome of that has correctly determined it should be ValueTask. The essence of the question is, should the caller of this method propagate up ValueTask in it's signature if it itself has no other async method call. Hopefully this clarification explains why the question, does not answer it.

2

There are 2 best solutions below

2
On BEST ANSWER

Assuming that the GetSomeValueAsync is expected to complete synchronously most of the time, and the CallingMethodAsync doesn't await anything else other than the GetSomeValueAsync, and also your goal is to minimize the memory allocations, then the ValueTask<TResult> is preferable.

public async ValueTask<int> CallingMethodAsync(int number)
{
   return await GetSomeValueAsync(number) + 1;
}

That's because when a ValueTask<TResult> is already completed upon creation, it wraps internally a TResult, not a Task<TResult>, so it doesn't allocate anything. On the contrary if you return a Task<TResult>, a new object will always have to be created, unless it happens to be one of the few type+value combinations that are internally cached by the .NET runtime itself, like a Task<int> with a value between -1 and 9 (see the internal static TaskCache class).

Online demo.

In my PC running on .NET 7 Release mode it outputs:

CallingMethodAsyncValueTask: 0 bytes per operation
CallingMethodAsyncTask: 74 bytes per operation
4
On

TL;DR

ValueTask<T> was introduced for perfromance-critical scenarios when reducing allocations has a significant impact. If you have such scenario - you need to explicitly test you concrete setup on your concrete hardware if using ValueTask gives actual benefits (consider using something like BenchmarkDotNet).

Assuming cache misses a relatively rare then ValueTask should be preferable. Though still I would recommend to run few benchmarks based on cache misses rates and used number ranges estimates.

Details:

First let's start with the docs:

As such, the default choice for any asynchronous method should be to return a Task or Task<TResult>. Only if performance analysis proves it worthwhile should a ValueTask<TResult> be used instead of a Task<TResult>

And:

There are tradeoffs to using a ValueTask<TResult> instead of a Task<TResult>. For example, while a ValueTask<TResult> can help avoid an allocation in the case where the successful result is available synchronously, it also contains multiple fields, whereas a Task<TResult> as a reference type is a single field. This means that returning a ValueTask<TResult> from a method results in copying more data. It also means, that if a method that returns a ValueTask<TResult> is awaited within an async method, the state machine for that async method will be larger, because it must store a struct containing multiple fields instead of a single reference.

For uses other than consuming the result of an asynchronous operation using await, ValueTask<TResult> can lead to a more convoluted programming model that requires more allocations. For example, consider a method that could return either a Task<TResult> with a cached task as a common result or a ValueTask<TResult>. If the consumer of the result wants to use it as a Task<TResult> in a method like WhenAll or WhenAny, the ValueTask<TResult> must first be converted to a Task<TResult> using AsTask, leading to an allocation that would have been avoided if a cached Task<TResult> had been used in the first place.

Lets perform a simple check for allocations:

ValueTask<int> GetSomeValueAsync(int number) => ValueTask.FromResult(number);
async Task<int> CallingMethodAsyncTask(int number) => await GetSomeValueAsync(number) + 1;
async ValueTask<int> CallingMethodAsyncValueTask(int number) => await GetSomeValueAsync(number) + 1;

await CallingMethodAsyncTask(1);
await CallingMethodAsyncValueTask(1);
var totalAllocatedBytes = GC.GetTotalAllocatedBytes();
var iters = 10_000;
for (int i = 0; i < iters; i++)
{
    await CallingMethodAsyncTask(i);
}

Console.WriteLine($"{nameof(CallingMethodAsyncTask)}: {GC.GetTotalAllocatedBytes() - totalAllocatedBytes}.");
totalAllocatedBytes = GC.GetTotalAllocatedBytes();
for (int i = 0; i < iters; i++)
{
    await CallingMethodAsyncValueTask(i);
}

Console.WriteLine($"{nameof(CallingMethodAsyncValueTask)}: {GC.GetTotalAllocatedBytes() - totalAllocatedBytes}.");

Which gives in release mode "on my machine":

CallingMethodAsyncTask: 718784.
CallingMethodAsyncValueTask: 0.

But if you change the GetSomeValueAsync to something like:

async ValueTask<int> GetSomeValueAsync(int number)
{
    await Task.Yield();
    return number;
}

Which gives in my setup:

CallingMethodAsyncTask: 2413488.
CallingMethodAsyncValueTask: 2475256.

i.e. ValueTask was less efficient in terms of allocations for completely async scenario.

Notes: