Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with .NET 10.0.401
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: An async method that finishes at once still allocates a task · A ValueTask holds a result or a source, and is awaited once. With a pass: 3 in-depth parts and 3 interview answers, about 13 minutes.

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:

alloc.cs: bytes allocated per call, five ways C# · alloc/alloc.cs
// 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:

Interactive · message by message Where the bytes of alloc.cs come from Step through one price lookup that finds its price, then one that has to wait. The caller is on the left, the lookup in the middle, the managed heap on the right.
Where the bytes of alloc.cs come from, step 5 of 5. Messages so far: the lookup to managed heap: new Task<int>; the lookup to the caller: ValueTask(41250); the lookup to managed heap: the state machine; the lookup to the caller: ValueTask(the task).the callerawait GetPriceAsync(...)the lookupan async methodmanaged heap96 or 104 bytes a callnew Task<int>ValueTask(41250)the state machineValueTask(the task)

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 In alloc.cs a cache hit through async Task<int> cost 72 bytes. How many bytes do 10,000 such hits allocate in all?

    Type a number.

    A hint

    Every hit with a price outside the few kept values needs a task object of its own.

    Show the answer: Question 1 of 2

    Answer: 720000 bytes

    72 bytes × 10,000 hits = 720,000 bytes: the cost grows with the number of calls, not with how long each takes.

  2. Question 2 of 2 A lookup misses its cache on every call and always awaits the database. Does changing its return type from Task<int> to ValueTask<int> save allocations?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No: a call that waits moves its state machine to the heap either way

    ValueTask saves only the calls that complete at once. A method that always waits gains nothing from it and should return Task<int>.

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:

consume.cs: the safe ways to consume a ValueTask<int> C# · consume/consume.cs
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

  1. Question 1 of 2 Which of these are safe with a ValueTask<int> returned by a method you did not write?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Await it directly, once
    • Call AsTask() once and await the task twice
    • Read Result after IsCompletedSuccessfully returned true

    A ValueTask is consumed once: by one await, by one read of a completed Result, or by one AsTask() whose task is then used freely.

  2. Question 2 of 2 A new public method GetCustomerAsync queries a database on every call. Which return type fits it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Task<Customer>: it always waits, so ValueTask saves nothing, and Task is the default

    Microsoft's reference makes Task the default and keeps ValueTask for methods whose results are often synchronous and which run often enough for the allocation to show.

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.

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.

  1. Question 1 of 5 In alloc.cs, what did a hit that returned the cached value 7 through async Task<int> allocate?

    Choose one answer.

    Show the answer: Question 1 of 5

    Answer: 0 bytes: the runtime handed back a finished task it keeps for that value

    For a few common results a finished task is reused. Most real prices are not among them, which is why the typical hit cost 72 bytes.

  2. Question 2 of 5 Why can ValueTask<int> return a ready result without allocating?

    Choose one answer.

    Show the answer: Question 2 of 5

    Answer: It is a struct that can hold the int itself

    Task<int> is a class, so carrying a result needs an object; ValueTask<int> is a struct that can carry it directly.

  3. Question 3 of 5 You must pass three ValueTask<int> calls to Task.WhenAll. What do you write?

    Choose one answer.

    Show the answer: Question 3 of 5

    Answer: Call AsTask() once on each and pass the three tasks

    When a Task is needed, AsTask() once, then use the task freely, as the third line of consume.cs did.

  4. Question 4 of 5 When may code read the Result of a ValueTask<int>?

    Choose one answer.

    Show the answer: Question 4 of 5

    Answer: Only once it has completed, for example after IsCompletedSuccessfully returned true

    Check IsCompletedSuccessfully, read Result if it is true, and await otherwise: one consumption either way.

  5. Question 5 of 5 A library's ReadAsync returns ValueTask<int> backed by an object it reuses for every read. What does that make dangerous?

    Choose one answer.

    Show the answer: Question 5 of 5

    Answer: Keeping the ValueTask past the next read, or awaiting it twice

    Pooling is why the rules exist. Consume each ValueTask once, at once, and convert with AsTask() when you must keep it.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An async Task<T> method that finishes at once still allocates a task for most results, 72 bytes a call here, while ValueTask<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 Result only after IsCompletedSuccessfully, and call AsTask() once when a task is needed; return Task<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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.