C# & .NET (C# 14, .NET 10 LTS) Module 15 – Asynchronous programming
ValueTask, pooling and async overhead
Measure what a C# async method allocates when its result is already there, return ValueTask<T> where it pays, and avoid the misuses that break it.
What you will learn
- Measure the bytes an async method allocates when it completes at once and when it has to wait
- Use ValueTask<T> as the return type of a method that often completes at once, and consume it in one of the safe ways
- Name the misuses that break a ValueTask, and show why they fail on a reused source
- Decide from measured bytes and times whether async overhead matters on a code path
- Optimise a cache-aside lookup so that its hits allocate nothing, without breaking the async contract
Before you start
What you’ll learn · Before you start
What you will learn
- Measure the bytes an async method allocates when it completes at once and when it has to wait
- Use ValueTask<T> as the return type of a method that often completes at once, and consume it in one of the safe ways
- Name the misuses that break a ValueTask, and show why they fail on a reused source
- Decide from measured bytes and times whether async overhead matters on a code path
- Optimise a cache-aside lookup so that its hits allocate nothing, without breaking the async contract
Before you start
On this page
A price cache answers most lookups from memory, yet through an async Task<int> method each of those answers still
creates an object on the heap: 72 bytes a call, measured below. At 1 million lookups a second that is about 72 MB of
garbage a second for prices that were already there.
You will measure what an async method allocates, return ValueTask<T> where the result is often ready at once, use it in the ways that are safe, and decide from numbers when this async overhead is worth any change at all.
An async method that finishes at once still allocates a task
alloc.cs calls a price lookup 100,000 times in each of five ways and reads the garbage collector’s own count of the
bytes the calls allocated:
// Bytes allocated per call by an async price lookup, measured with the GC's own counters.
const int Calls = 100_000;
var prices = new Dictionary<string, int> { ["ABC"] = 41250, ["LOW"] = 7 };
await Task.Yield(); // from here on Main runs on a pool thread, and its own state machine is already on the heap
long total = 0;
for (int i = 0; i < 1_000; i++) // warm up every path once, so that the measured loops allocate only their calls
{
total += await FromCacheTaskAsync("ABC") + await FromCacheValueTaskAsync("ABC") + await LoadTaskAsync() + await LoadValueTaskAsync();
}
long start = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < Calls; i++) total += await FromCacheTaskAsync("ABC");
Report("Task<int>, the price is in the cache", GC.GetAllocatedBytesForCurrentThread() - start);
start = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < Calls; i++) total += await FromCacheTaskAsync("LOW");
Report("Task<int>, the cached value is 7", GC.GetAllocatedBytesForCurrentThread() - start);
start = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < Calls; i++) total += await FromCacheValueTaskAsync("ABC");
Report("ValueTask<int>, the price is in the cache", GC.GetAllocatedBytesForCurrentThread() - start);
// A call that really waits resumes on another thread, so it is counted with the process-wide counter.
start = GC.GetTotalAllocatedBytes(precise: true);
for (int i = 0; i < Calls; i++) total += await LoadTaskAsync();
Report("Task<int>, the call waits", GC.GetTotalAllocatedBytes(precise: true) - start);
start = GC.GetTotalAllocatedBytes(precise: true);
for (int i = 0; i < Calls; i++) total += await LoadValueTaskAsync();
Report("ValueTask<int>, the call waits", GC.GetTotalAllocatedBytes(precise: true) - start);
Console.WriteLine($"(sum of every price read: {total})");
async Task<int> FromCacheTaskAsync(string code)
{
if (prices.TryGetValue(code, out int price)) return price;
await Task.Yield();
return 0;
}
async ValueTask<int> FromCacheValueTaskAsync(string code)
{
if (prices.TryGetValue(code, out int price)) return price;
await Task.Yield();
return 0;
}
static async Task<int> LoadTaskAsync()
{
await Task.Yield(); // stands in for the database: the method stops and resumes on a pool thread
return 41250;
}
static async ValueTask<int> LoadValueTaskAsync()
{
await Task.Yield();
return 41250;
}
// Bytes per call, to the nearest byte: a background thread of the runtime may add a few hundred bytes to a run.
static void Report(string what, long bytes) => Console.WriteLine($"{what,-42} {Math.Round(bytes / (double)Calls),4} bytes a call"); Output
Task<int>, the price is in the cache 72 bytes a call Task<int>, the cached value is 7 0 bytes a call ValueTask<int>, the price is in the cache 0 bytes a call Task<int>, the call waits 96 bytes a call ValueTask<int>, the call waits 104 bytes a call (sum of every price read: 16665700000)
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run alloc.cs
A hit through async Task<int> cost 72 bytes a call, a hit through async ValueTask<int> cost nothing, and the
cached value 7 cost nothing even as a Task<int>. A call that really waited allocated about 100 bytes whichever type
it returned. Step through where those bytes come from:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A hit, as Task<int>. The price 41250 is in the cache, so the method returns at once. Its result must travel in a Task<int>, a class, so a new task object is made for it: 72 bytes a call in the run.
- A few results are kept. For a few common results the runtime hands back a finished task it keeps for them. The cached value 7 cost 0 bytes a call; 41250 is not one of them.
- A hit, as ValueTask<int>. Returned as a ValueTask<int>, a struct, the int 41250 travels inside the value itself, on the stack, and the caller's await takes it straight out: 0 bytes a call.
- A miss has to wait. When the price is not there, the method awaits the database. A method that really waits must keep its local variables somewhere until it resumes, so its state machine moves to the heap.
- ValueTask does not help there. That object is the task the caller awaits, whatever the return type: 96 bytes with Task<int>, 104 with ValueTask<int>, whose state machine is a little larger. ValueTask saves only the calls that finish at once.
Task<int> is a class, so a result that is ready at once still needs a new task object to carry it, unless the
runtime keeps a finished task for that value, as it did for 7. A ValueTask<int> is a struct that can hold the
int itself, so the answer travels without touching the heap. When a method really waits, it must keep its local
variables until it resumes, so its state machine moves to the heap either way. That is the whole case for ValueTask
in one line: it saves the heap allocation: Creating an object on the managed heap, which the garbage collector must later find and free. Every new class instance, array and boxed value is one, and so is each new Task<T>; a struct held in a local variable is not. of calls that finish at once, and nothing
else.
Checkpoint
Check: What an async method allocates
A ValueTask holds a result or a source, and is awaited once
A ValueTask<T>: A struct an async method can return instead of Task<T>. It holds the result itself when the result is ready at once, so that path allocates nothing, or else a Task<T> or a reusable source; it may be consumed only once. wraps one of three things: the result itself, a Task<T>, or a
source object that will produce the result. Because that object may be reused, a ValueTask may be consumed only once.
consume.cs shows the three safe ways, on a cache that loads a missing price from a database:
using System.Collections.Concurrent;
// The three safe ways to consume a ValueTask<int>, on a cache that loads a missing price once.
var cache = new PriceCache();
// 1. Await it directly, once: the usual way, and the right one almost everywhere.
int abc = await cache.GetAsync("ABC");
Console.WriteLine($"1. await: ABC {abc}");
// 2. Read Result only after IsCompletedSuccessfully said it is ready; otherwise await it. Either way it is consumed
// once, and nothing touches it afterwards.
ValueTask<int> pending = cache.GetAsync("ABC");
if (pending.IsCompletedSuccessfully)
{
Console.WriteLine($"2. complete at once, read with Result: ABC {pending.Result}");
}
else
{
Console.WriteLine($"2. not complete yet, awaited: ABC {await pending}");
}
// 3. Need a Task, for WhenAll, to keep it, or to await it twice? Convert each one once with AsTask.
Task<int>[] both = [cache.GetAsync("XYZ").AsTask(), cache.GetAsync("PQR").AsTask()];
int[] two = await Task.WhenAll(both);
Console.WriteLine($"3. AsTask and WhenAll: XYZ {two[0]}, PQR {two[1]}");
Console.WriteLine($"loads from the database: {cache.Loads}");
sealed class PriceCache
{
private readonly ConcurrentDictionary<string, int> prices = new();
private int loads;
public int Loads => loads;
public async ValueTask<int> GetAsync(string code)
{
if (prices.TryGetValue(code, out int price)) return price; // a hit: the ValueTask holds the int itself
price = await LoadAsync(code);
prices[code] = price;
return price;
}
private async Task<int> LoadAsync(string code)
{
Interlocked.Increment(ref loads);
await Task.Delay(20); // the database call
return code switch { "ABC" => 41250, "XYZ" => 9900, _ => 15000 };
}
} Output
1. await: ABC 41250 2. complete at once, read with Result: ABC 41250 3. AsTask and WhenAll: XYZ 9900, PQR 15000 loads from the database: 3
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run consume.cs
The usual way is to await the call directly, once. A hot loop may check IsCompletedSuccessfully and read Result
only when it says yes, awaiting otherwise, which is how the second line got 41250 without an await. When a task is
needed, to pass to Task.WhenAll, to keep or to await twice, call AsTask() once and use the task; the third line
combined two loads that way, and the database was called three times in all.
| Case | Task<T> |
ValueTask<T> |
|---|---|---|
| What it is | a class: every new result is a new object | a struct: the result, a Task<T> or a source |
| A result that is already there | 72 bytes a call, 0 for a few kept values | 0 bytes |
| A call that has to wait | 96 bytes | 104 bytes |
| Await it twice, or from two callers | allowed | never: call AsTask() once |
Keep it, pass it to WhenAll |
allowed | after AsTask() |
Read Result |
blocks until it is done | only once it is complete |
Pooling goes further. A ValueTask may wrap an IValueTaskSource, an object a library reuses for one operation after
another, as sockets and channels do, and the PoolingAsyncValueTaskMethodBuilder, switched on per method with an
attribute, reuses the state machines of finished calls. Both kinds of pooling make the once-only rule matter more,
because a reused object answers for the next operation, not yours.
Start with Task<T>
Microsoft’s reference for ValueTask makes Task<T> the default return type and asks for ValueTask<T> only where
performance analysis proves it worthwhile. Most code never notices this async overhead.
Checkpoint
Check: Consuming a ValueTask safely
Misusing a ValueTask, reproduced on a reused source Pro: opens with a Pro, Premium or Ultimate pass
The rules of ValueTask exist because the object behind it may be reused for the next operation. This part builds such a reused source and breaks each rule on purpose, so that every failure can be seen and recognised later.
In this part:
- A reader that reuses one completion object for every read
- What the analyzer catches, and what it asks of callers
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
When async overhead matters, measured at a 95 % hit ratio Pro: opens with a Pro, Premium or Ultimate pass
Whether to change a return type depends on how often the method runs and what else each call costs. This part measures one cache lookup in four designs, then turns bytes a call into megabytes a second at real call rates.
In this part:
- Four designs of one lookup, measured side by side
- From bytes a call to garbage a second, with worked numbers
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
A lab: a price cache whose hits allocate nothing Pro: opens with a Pro, Premium or Ultimate pass
A cache-aside lookup returns Task<int>, so every hit allocates a task. This lab changes it so that hits allocate
nothing while misses stay asynchronous and are shared between callers, with five checks.
In this part:
- The rules and the five checks
- A struct for hits, and one shared task for every miss
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
The self-check covers the first two sections: what an async method allocates, and how a ValueTask is consumed. Your weakest idea comes up first, and every answer tells you why it holds or misses:
Practice that adapts to you
Practise this lesson
5 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · When should a method return ValueTask<T> instead of Task<T>?
When the method usually has its result at once, is called often enough for the allocation of a new task per call to
show in measurements, and its callers simply await it. A cache lookup that hits most of the time is the typical case:
returned as a struct, a ready result costs no heap allocation, while Task<T> allocates for most values. When the
method really waits, both types allocate about the same, so ValueTask gains nothing there. The price is a stricter
contract: a ValueTask may be awaited only once and never by two callers, so code that keeps it or passes it to
Task.WhenAll must call AsTask(). Microsoft’s guidance is to return Task<T> by default.
-
Mid to senior
A memory profile of a pricing service shows 70 MB a second of Task<decimal> objects, all created by GetPriceAsync, which answers 95 % of its calls from an in-memory cache. What do you measure first, what do you change, and what must callers of the method stop doing?
-
Mid to senior
After a library upgrade, a socket reader sometimes returns data that belongs to the next message. The code stores the ValueTask<int> returned by ReadAsync, awaits it once to log the count and again to use it. Explain the bug, why tests never caught it, and fix it.
-
Senior to staff
You are designing the public interface of a storage client that thousands of applications will use. For Get, Put and Enumerate, would you return Task<T>, ValueTask<T> or IAsyncEnumerable<T>? Defend each choice by what it costs and what it promises callers.
Key takeaways
- An
async Task<T>method that finishes at once still allocates a task for most results, 72 bytes a call here, whileValueTask<T>carries a ready result without allocating. - A method that really waits moves its state machine to the heap whatever it returns, so ValueTask saves only the calls that complete at once.
- Await a ValueTask once and directly, read
Resultonly afterIsCompletedSuccessfully, and callAsTask()once when a task is needed; returnTask<T>unless measurements say otherwise.
Terms in this lesson
- heap allocation
- Creating an object on the managed heap, which the garbage collector must later find and free. Every new class instance, array and boxed value is one, and so is each new Task<T>; a struct held in a local variable is not.
- ValueTask<T>
- A struct an async method can return instead of Task<T>. It holds the result itself when the result is ready at once, so that path allocates nothing, or else a Task<T> or a reusable source; it may be consumed only once.
References
- ValueTask<TResult> struct (Microsoft Learn)
- Async return types (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
- ManualResetValueTaskSourceCore<TResult> struct (Microsoft Learn)
- PoolingAsyncValueTaskMethodBuilder<TResult> struct (Microsoft Learn)
- CA2012: Use ValueTasks correctly (Microsoft Learn)
- Diagnosers (BenchmarkDotNet (.NET Foundation))
- .NET runtime metrics (Microsoft Learn)
- What's new in .NET 11 (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress