DEV Community

Cover image for What async/await really compiles to in C# (and when ValueTask is worth it)
Naze Code
Naze Code

Posted on

What async/await really compiles to in C# (and when ValueTask is worth it)

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}");
}
Enter fullscreen mode Exit fullscreen mode
1. caller             thread 2
2. LoadAsync starts   thread 2
3. caller continues   thread 2
4. after the await    thread 5
Enter fullscreen mode Exit fullscreen mode

Two things people often get wrong:

  • Calling an async method doesn't start a new thread. LoadAsync runs synchronously, on the caller's thread, right up to its first await of something that isn't finished yet.
  • At that await, the method returns to the caller (line 3) with an unfinished Task. 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;
}
Enter fullscreen mode Exit fullscreen mode

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}");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That's the whole trick:

  • <>1__state remembers which await you're paused at, so the method can resume in the right place.
  • <>t__builder creates and completes the Task<int> the caller gets.
  • id is your parameter, kept so it survives across awaits.
  • <>u__1 and <>u__2 hold 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
Enter fullscreen mode Exit fullscreen mode

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}");
Enter fullscreen mode Exit fullscreen mode
before   thread 5
after    thread 5
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • Task<int>, cache hit: 72 bytes. The method never pauses, but it still has to hand back a Task<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. A ValueTask is 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. ValueTask doesn'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

  1. An async method runs synchronously until its first await of something unfinished.
  2. The compiler turns it into a state machine: a state number, a builder, and only the locals that must survive an await.
  3. Awaiting a completed task takes a fast path: no pause, no thread switch.
  4. 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)