Laszlo

Hello, I am Laszlo

Software-Enginner, .NET developer

Contact Me

Task over ValueTask<>

I recently ran into some code that extensively used ValueTask types along with Task. I became curious if it is a good idea to mix async ValueTask<>s and Tasks. Moreover, .NET allows decorating methods returning ValueTask<> types with [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] that adds pooling behavior, which reduces allocations.

There are many ways to invoke async methods from another async method. The invoked async method can return a Task, ValueTask<>, or a pooled ValueTask<>; the calling method can also return any of these response types, and be a sync or async method. In this post, I create combinations of these methods to measure their performance footprint. The inner method may complete synchronously, asynchronously, or with a probability set between 0..1, where 1 means synchronous completion.

In this blog post, 'sync' completion refers to returning the result of an underlying async method without awaiting it, or without .Result / .GetAwaiter().GetResult() calls. It does not refer to the 'sync-over-async' anti-pattern.

I used BenchmarkDotNet to measure the performance and allocations of these method combinations. Please note that the allocations show 'non-round' numbers due to BenchmarkDotNet's aggregation when the probability falls between 0 and 1 exclusively. Async completions invoke Task.Yield(); - which yields execution of the current task, allowing other tasks to execute. While there should be no other tasks running in the benchmark, the Mean performance results include a non-trivial waiting duration, that is for the task continuation to execute.

The tables below show a guidance to choose certain designs, however, they must not be taken for granted. Actual allocations and performance must be measured in the production environment of production codebase, that reflects the real probabilty of sync/async completions.

Not all combinations are applicable in all situations. For example, it is intriguing to make the method at the callsite sync (ie. it returns the invoked method result without awaiting it), this might not be possible in all cases.

These benchmarks apply to .NET 10 Preview 7, at the time of writing the .NET team is working on a runtime implementation of these async concepts, where these results will likely not be applicable.

Measurement Results

Inner method returns Task:

Method

Probability

Mean

Error

StdDev

Median

Gen0

Allocated

TaskOverTask

0

655.691 ns

8.7030 ns

7.7150 ns

657.775 ns

0.0162

104 B

AsyncTaskOverTask

0

771.259 ns

8.9512 ns

8.3730 ns

770.903 ns

0.0324

208 B

AsyncValueTaskOverTask

0

795.772 ns

15.7088 ns

28.7243 ns

808.156 ns

0.0343

216 B

TaskOverTask

0.7

210.112 ns

2.4135 ns

2.1395 ns

210.149 ns

0.0048

31 B

AsyncTaskOverTask

0.7

221.908 ns

1.4695 ns

1.3026 ns

221.495 ns

0.0098

62 B

AsyncValueTaskOverTask

0.7

251.400 ns

4.9236 ns

5.6700 ns

251.458 ns

0.0100

65 B

TaskOverTask

1

4.745 ns

0.0711 ns

0.0594 ns

4.730 ns

-

-

AsyncTaskOverTask

1

9.519 ns

0.0939 ns

0.0878 ns

9.529 ns

-

-

AsyncValueTaskOverTask

1

11.429 ns

0.1802 ns

0.1686 ns

11.426 ns

-

-

Inner method returns ValueTask<>:

Method

Probability

Mean

Error

StdDev

Median

Gen0

Allocated

AsyncTaskOverValueTask

0

698.018 ns

4.4714 ns

3.7338 ns

698.640 ns

0.0353

224 B

TaskOverValueTask

0

636.997 ns

12.5737 ns

18.0328 ns

639.814 ns

0.0172

112 B

AsyncValueTaskOverValueTask

0

778.559 ns

10.4735 ns

9.2845 ns

776.305 ns

0.0362

232 B

ValueTaskOverValueTask

0

632.201 ns

9.8670 ns

8.7468 ns

630.164 ns

0.0172

112 B

AsyncTaskOverValueTask

0.7

229.884 ns

1.7155 ns

1.6046 ns

230.216 ns

0.0105

67 B

TaskOverValueTask

0.7

192.642 ns

2.6676 ns

2.3648 ns

192.177 ns

0.0052

34 B

AsyncValueTaskOverValueTask

0.7

249.126 ns

2.3743 ns

2.1048 ns

249.379 ns

0.0110

69 B

ValueTaskOverValueTask

0.7

221.527 ns

4.2711 ns

5.7019 ns

222.980 ns

0.0052

34 B

AsyncTaskOverValueTask

1

9.015 ns

0.0743 ns

0.0659 ns

9.005 ns

-

-

TaskOverValueTask

1

7.113 ns

0.1049 ns

0.0876 ns

7.117 ns

-

-

AsyncValueTaskOverValueTask

1

12.030 ns

0.2674 ns

0.2627 ns

11.969 ns

-

-

ValueTaskOverValueTask

1

8.038 ns

0.1870 ns

0.2226 ns

8.065 ns

-

-

Inner method returns Pooled ValueTask<>:

Method

Probability

Mean

Error

StdDev

Median

Gen0

Allocated

AsyncTaskOverPooledValueTask

0

664.594 ns

6.5432 ns

5.8004 ns

663.004 ns

0.0343

222 B

TaskOverPooledValueTask

0

626.785 ns

4.8582 ns

4.3067 ns

627.650 ns

0.0315

199 B

AsyncValueTaskOverPooledValueTask

0

808.679 ns

12.5851 ns

11.7721 ns

811.347 ns

0.0362

230 B

ValueTaskOverPooledValueTask

0

626.790 ns

9.8143 ns

9.1803 ns

628.191 ns

-

-

AsyncTaskOverPooledValueTask

0.7

223.083 ns

3.9836 ns

3.7263 ns

225.211 ns

0.0105

67 B

TaskOverPooledValueTask

0.7

211.490 ns

3.4034 ns

3.1836 ns

212.761 ns

0.0095

60 B

AsyncValueTaskOverPooledValueTask

0.7

252.595 ns

2.0967 ns

1.8586 ns

252.913 ns

0.0110

70 B

ValueTaskOverPooledValueTask

0.7

196.758 ns

3.5091 ns

3.2824 ns

198.201 ns

-

-

AsyncTaskOverPooledValueTask

1

9.963 ns

0.1269 ns

0.1125 ns

10.007 ns

-

-

TaskOverPooledValueTask

1

7.449 ns

0.1580 ns

0.1478 ns

7.484 ns

-

-

AsyncValueTaskOverPooledValueTask

1

12.031 ns

0.2713 ns

0.2405 ns

12.127 ns

-

-

ValueTaskOverPooledValueTask

1

7.902 ns

0.1924 ns

0.1889 ns

7.976 ns

-

-

Outer method returns ValueTask inner method returns Pooled ValueTask<>:

Method

Probability

Mean

Error

StdDev

Gen0

Allocated

AsyncNgValueTaskOverValueTask

0

873.42 ns

17.418 ns

21.390 ns

0.0353

224 B

AsyncNgValueTaskOverPooledValueTask

0

779.27 ns

11.096 ns

10.379 ns

0.0353

223 B

AsyncNgValueTaskOverValueTask

0.7

281.29 ns

2.312 ns

2.162 ns

0.0105

67 B

AsyncNgValueTaskOverPooledValueTask

0.7

241.65 ns

2.119 ns

1.879 ns

0.0105

67 B

AsyncNgValueTaskOverValueTask

1

11.07 ns

0.218 ns

0.204 ns

-

-

AsyncNgValueTaskOverPooledValueTask

1

12.30 ns

0.178 ns

0.167 ns

-

-

Analysis

  • Being synchronous provides the best performance.

  • Sync outer methods (not sync-over-async) reduce allocations

  • Inner methods completing synchronously typically avoid allocations altogether

  • When the inner method returns ValueTask<> and the outer method is sync, the return type (Task or ValueTask<>) doesn't impact performance

  • For asynchronous completion with async outer methods:

    • ValueTask<> allocates 8 bytes (1 reference) more than Task

    • Async Task shows slightly better Mean execution performance

  • Inner Pooled ValueTask<> completely eliminates allocations when the outer method also avoids allocations by:

    • Completing synchronously and returning ValueTask<>

  • Async ValueTask<> with an inner method of Pooled ValueTask<> is the slowest overall solution; using an async Task appears to be a better choice

  • Async case when converts a ValueTask<> to ValueTask the performance more-or-less matches the non-coverting one with slightly less allocations

Conclusion

The data highlights the trade-offs between performance, allocations, and code complexity when choosing between Task, ValueTask<>, and Pooled ValueTask<>. There is no one-size-fits-all solution, and the optimal choice depends on the specific requirements of the application.

Source Code Reference


[SimpleJob, MemoryDiagnoser]
public class Benchmarks
{
    [Params(0, 0.7, 1)]
    public double Probability { get; set; }

    [Benchmark]
    public async Task<bool> AsyncTaskOverValueTask() => await GetValue();

    [Benchmark]
    public Task<bool> TaskOverValueTask() => GetValue().AsTask();

    [Benchmark]
    public Task<bool> TaskOverTask() => GetTaskValue();

    [Benchmark]
    public async Task<bool> AsyncTaskOverTask() => await GetTaskValue();

    [Benchmark]
    public async ValueTask<bool> AsyncValueTaskOverTask() => await GetTaskValue();

    [Benchmark]
    public async ValueTask<bool> AsyncValueTaskOverValueTask() => await GetValue();

    [Benchmark]
    public async Task<bool> AsyncTaskOverPooledValueTask() => await GetValuePooled();

    [Benchmark]
    public Task<bool> TaskOverPooledValueTask() => GetValuePooled().AsTask();

    [Benchmark]
    public async ValueTask<bool> AsyncValueTaskOverPooledValueTask() => await GetValuePooled();

    [Benchmark]
    public ValueTask<bool> ValueTaskOverPooledValueTask() => GetValuePooled();

    [Benchmark]
    public ValueTask<bool> ValueTaskOverValueTask() => GetValue();

    [Benchmark]
    public async ValueTask AsyncNgValueTaskOverValueTask() => await GetValue();

    [Benchmark]
    public async ValueTask AsyncNgValueTaskOverPooledValueTask() => await GetValuePooled();

    [AsyncMethodBuilder(typeof(PoolingAsyncValueTask<>MethodBuilder<>))]
    public async ValueTask<bool> GetValuePooled()
    {
        if (Random.Shared.NextDouble() > Probability)
            await Task.Yield();
        return true;
    }

    public async ValueTask<bool> GetValue()
    {
        if (Random.Shared.NextDouble() > Probability)
            await Task.Yield();
        return true;
    }

    public async Task<bool> GetTaskValue()
    {
        if (Random.Shared.NextDouble() > Probability)
            await Task.Yield();
        return true;
    }
}