Most of us use async/await every day without knowing what it turns into. That's fine, until you need to answer a question like "should this method return ValueTask?" and realize the answer depends on what await actually does.
So let's look. Four small experiments, each run for real on .NET 10 (Release build unless noted), and one practical decision at the end.
1. What runs where, and in what order
Console.WriteLine($"1. caller thread {Environment.CurrentManagedThreadId}");
var task = LoadAsync();
Console.WriteLine($"3. caller continues thread {Environment.CurrentManagedThreadId}");
await task;
static async Task LoadAsync()
{
Console.WriteLine($"2. LoadAsync starts thread {Environment.CurrentManagedThreadId}");
await Task.Delay(100);
Console.WriteLine($"4. after the await thread {Environment.CurrentManagedThreadId}");
}
1. caller thread 2
2. LoadAsync starts thread 2
3. caller continues thread 2
4. after the await thread 5
Two things people often get wrong:
-
Calling an async method doesn't start a new thread.
LoadAsyncruns synchronously, on the caller's thread, right up to its firstawaitof something that isn't finished yet. -
At that
await, the method returns to the caller (line 3) with an unfinishedTask. When the delay completes, the rest of the method runs. In a console app there's no UI thread to return to, so it ran on a thread pool thread (5 here; it could also happen to be the same one).
2. The class (or struct) the compiler writes for you
Take an ordinary async method:
public static async Task<int> CountOrdersAsync(int id)
{
var user = await GetUserAsync(id);
var orders = await GetOrdersAsync(user);
return orders;
}
The compiler rewrites it into a hidden type. Reflection can find it:
var machine = typeof(Shop).GetNestedTypes(BindingFlags.NonPublic)
.Single(t => t.Name.Contains("CountOrdersAsync"));
Console.WriteLine($"{machine.Name} struct: {machine.IsValueType}");
foreach (var i in machine.GetInterfaces())
Console.WriteLine($" implements {i.Name}");
foreach (var f in machine.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
Console.WriteLine($" {f.FieldType.Name,-24} {f.Name}");
Release build:
<CountOrdersAsync>d__0 struct: True
implements IAsyncStateMachine
Int32 <>1__state
AsyncTaskMethodBuilder`1 <>t__builder
Int32 id
TaskAwaiter`1 <>u__1
TaskAwaiter`1 <>u__2
That's the whole trick:
-
<>1__stateremembers whichawaityou're paused at, so the method can resume in the right place. -
<>t__buildercreates and completes theTask<int>the caller gets. -
idis your parameter, kept so it survives across awaits. -
<>u__1and<>u__2hold the awaiter for each of the two awaits.
Notice that user and orders aren't fields. In Release, the compiler only keeps locals that are still needed after an await.
Debug build, same code:
<CountOrdersAsync>d__0 struct: False
implements IAsyncStateMachine
Int32 <>1__state
AsyncTaskMethodBuilder`1 <>t__builder
Int32 id
String <user>5__1
Int32 <orders>5__2
String <>s__3
Int32 <>s__4
TaskAwaiter`1 <>u__1
TaskAwaiter`1 <>u__2
In Debug it's a class, and every local (plus a couple of compiler temporaries) becomes a field. In Release it's a struct, and it only moves to the heap if the method actually has to pause.
3. The fast path: awaiting something that's already done
var cached = Task.FromResult(42);
Console.WriteLine($"before thread {Environment.CurrentManagedThreadId}");
var value = await cached; // already finished
Console.WriteLine($"after thread {Environment.CurrentManagedThreadId}");
before thread 5
after thread 5
await first asks the task "are you already complete?" If yes, it just takes the result and keeps going, with no pause, no scheduling and no thread hop. This fast path is why the next part matters.
4. What each call costs
A price lookup that's almost always served from a cache, written twice, once with each return type:
static async Task<int> GetPriceAsync(int id) // usually cached
{
if (Cache.TryGetValue(id, out var price)) return price;
await Task.Delay(10);
return 0;
}
static async ValueTask<int> GetPriceValueAsync(int id)
{
if (Cache.TryGetValue(id, out var price)) return price;
await Task.Delay(10);
return 0;
}
Measured with GC.GetTotalAllocatedBytes over 100,000 calls, after a warm-up:
Task<int>, cached 72 bytes per call
ValueTask<int>, cached 0 bytes per call
really waits 96 bytes per call
-
Task<int>, cache hit: 72 bytes. The method never pauses, but it still has to hand back aTask<int>object, and that's an allocation. (The runtime caches a few common results, but not arbitrary values like this one.) -
ValueTask<int>, cache hit: 0 bytes. AValueTaskis a struct that can carry the result directly, so nothing goes on the heap. -
A method that really waits: 96 bytes. When it pauses, the state machine from part 2 has to move to the heap so it can be resumed later.
ValueTaskdoesn't avoid that.
So when is ValueTask worth it?
When a method usually completes synchronously (cache hits, buffered reads, already-validated input) and it's called often enough that the allocations matter. That's the one case where it saves real work.
Otherwise, stay with Task. ValueTask comes with rules: await it once, don't store it to await later, and don't await it from two places at once. A Task has none of those restrictions.
Recap
- An async method runs synchronously until its first
awaitof something unfinished. - The compiler turns it into a state machine: a state number, a builder, and only the locals that must survive an await.
- Awaiting a completed task takes a fast path: no pause, no thread switch.
-
Task<T>allocates even when nothing waits.ValueTask<T>doesn't, but only use it for hot paths that usually complete synchronously.
Did any of this surprise you? I'd like to hear which part in the comments.
I make short, tested videos about C# and what's going on under the hood. More on the Naze Code YouTube channel.
Sources: Microsoft Learn, Task asynchronous programming model, ValueTask<TResult>; .NET Blog, How async/await really works in C#. Tested on .NET SDK 10.0.302, Release build unless noted.
Top comments (0)