Search code examples
c#async-awaittaskvaluetask

Propagating ValueTask to callers


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.


Solution

  • 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