This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!
This series has measured one thing at a time. This post puts the numbers together, because the interesting question is not "what does an await cost" but "when does any of it show up in my application".
It is also the last post before .NET 11, where the runtime takes over and several of these numbers change.
#The two paths
Everything reduces to a single distinction. An async method either completes without suspending, or it suspends.
Completing synchronously costs a Task allocation and a few nanoseconds. The state machine is a struct in Release, it never leaves the stack, and there is no scheduling.
Suspending costs the state machine being boxed onto the heap, a continuation being registered, and a trip through the thread pool to get running again. That is roughly 1.3 microseconds on this machine, two orders of magnitude more than the synchronous path.
Everything else is a detail on top of those two.
#Depth
Real applications do not have one async method. They have a controller calling a service calling a repository calling a client. Each layer is its own state machine.
The benchmark builds a chain of a given depth three ways: plain synchronous calls, async calls that never suspend, and async calls that suspend exactly once at the bottom.
C#
private static int SyncCore(int depth) => depth == 0 ? 42 : SyncCore(depth - 1);
private static async Task<int> AsyncCompletedAsync(int depth)
=> depth == 0 ? 42 : await AsyncCompletedAsync(depth - 1);
private static async Task<int> AsyncSuspendingAsync(int depth)
{
if (depth == 0)
{
await Task.Yield();
return 42;
}
return await AsyncSuspendingAsync(depth - 1);
}
Full benchmark class
C#
[MemoryDiagnoser]
public class DepthBenchmarks
{
[Params(1, 5, 20)]
public int Depth { get; set; }
[Benchmark(Baseline = true)]
public int Sync() => SyncCore(Depth);
[Benchmark]
public int AsyncCompletedSynchronously() => AsyncCompletedAsync(Depth).GetAwaiter().GetResult();
[Benchmark]
public int AsyncSuspendingAtTheBottom() => AsyncSuspendingAsync(Depth).GetAwaiter().GetResult();
private static int SyncCore(int depth) => depth == 0 ? 42 : SyncCore(depth - 1);
private static async Task<int> AsyncCompletedAsync(int depth)
=> depth == 0 ? 42 : await AsyncCompletedAsync(depth - 1);
private static async Task<int> AsyncSuspendingAsync(int depth)
{
if (depth == 0)
{
await Task.Yield();
return 42;
}
return await AsyncSuspendingAsync(depth - 1);
}
}
| Method | Depth | Mean | Allocated |
|---|
| Sync | 1 | 0.5136 ns | - |
| AsyncCompletedSynchronously | 1 | 12.1864 ns | 144 B |
| AsyncSuspendingAtTheBottom | 1 | 1,611.0401 ns | 206 B |
| Sync | 5 | 0.3498 ns | - |
| AsyncCompletedSynchronously | 5 | 37.8564 ns | 432 B |
| AsyncSuspendingAtTheBottom | 5 | 1,889.5431 ns | 621 B |
| Sync | 20 | 4.5263 ns | - |
| AsyncCompletedSynchronously | 20 | 140.9409 ns | 1512 B |
| AsyncSuspendingAtTheBottom | 20 | 2,386.8073 ns | 1985 B |
Benchmark project, measured with BenchmarkDotNetTwo readings, and the second is the one that matters.
On the synchronous path, cost is linear in depth. Roughly 72 bytes and 7 ns per layer. Twenty layers of async methods that all complete synchronously cost 1.5 KB and 140 ns. That is the case where an async abstraction over something already in memory is genuinely wasteful, and where ValueTask earns its complexity.
On the suspending path, depth barely matters. Going from 1 to 20 layers adds 775 ns to a 1611 ns operation, less than 50% for twenty times the depth. Only the layers below the suspension point pay the boxing cost, and the single scheduling round trip is shared by the whole chain.
The practical consequence: deep async chains are not the problem people think they are, as long as they suspend for a real reason. A twenty-layer chain that does one network call costs 2.4 µs of machinery around an operation that takes milliseconds.
#Locals
Every local that is still needed after an await becomes a field of the state machine, and that field is carried for the whole operation.
The two methods below do identical work. The only difference is whether the locals are still needed after the await, which is what decides whether the compiler hoists them into fields.
C#
private static async Task<int> ShortLivedLocalsAsync()
{
// Used and finished with before the await, so these stay ordinary locals.
var a = Guid.NewGuid();
var b = a.ToString();
var c = b.Length;
_ = c;
await Task.Yield();
return 42;
}
private static async Task<int> LongLivedLocalsAsync()
{
// Still needed after the await, so each one becomes a field of the state machine.
var a = Guid.NewGuid();
var b = a.ToString();
var c = b.Length;
await Task.Yield();
return a.GetHashCode() + b.Length + c;
}
Full benchmark class
C#
[MemoryDiagnoser]
public class StateSizeBenchmarks
{
[Benchmark(Baseline = true)]
public int NoLocals() => NoLocalsAsync().GetAwaiter().GetResult();
[Benchmark]
public int LocalsThatDoNotCrossTheAwait() => ShortLivedLocalsAsync().GetAwaiter().GetResult();
[Benchmark]
public int LocalsThatCrossTheAwait() => LongLivedLocalsAsync().GetAwaiter().GetResult();
private static async Task<int> NoLocalsAsync()
{
await Task.Yield();
return 42;
}
private static async Task<int> ShortLivedLocalsAsync()
{
// Used and finished with before the await, so these stay ordinary locals.
var a = Guid.NewGuid();
var b = a.ToString();
var c = b.Length;
_ = c;
await Task.Yield();
return 42;
}
private static async Task<int> LongLivedLocalsAsync()
{
// Still needed after the await, so each one becomes a field of the state machine.
var a = Guid.NewGuid();
var b = a.ToString();
var c = b.Length;
await Task.Yield();
return a.GetHashCode() + b.Length + c;
}
}
| Method | Mean | Ratio | Allocated |
|---|
| NoLocals | 1.317 us | 1.00 | 96 B |
| LocalsThatDoNotCrossTheAwait | 1.774 us | 1.35 | 192 B |
| LocalsThatCrossTheAwait | 1.749 us | 1.33 | 216 B |
Benchmark project, measured with BenchmarkDotNetThe gap between the last two rows is 24 bytes, which is the three hoisted fields. The gap between the first two is 96 bytes, which is the Guid.ToString() the second and third methods do and the first does not: that is the work, not the machinery.
So the effect is real but small, and mostly it tells you where not to look. Restructuring a method to shorten its locals' lifetimes buys tens of bytes. Not allocating the string in the first place buys four times as much.
Where it does matter is a state machine that captures something large: a big array, a buffer, or a closure over an entire request object. That reference is held for the whole operation, including while it is suspended, which turns a short-lived object into one that survives a gen0 collection. The fix there is not to shorten the local, it is to not put the large thing in the async method.
#Putting a number on it
Assembling the measurements from across the series, on this machine, on .NET 10:
| Operation | Cost |
|---|
await on an already-completed awaiter | ~5 ns, 0 B beyond the enclosing task |
An async Task<T> method that never suspends | ~7 ns, 72 B |
The same returning ValueTask<T> | ~2 ns, 0 B |
| One suspension | ~1300 ns, ~96 B |
| Queueing a work item to the thread pool | ~200 ns |
Checking a CancellationToken | below measurement |
| Registering a cancellation callback | ~8 ns, 0 B |
ConfigureAwait(false) with no context | under 1 ns |
An uncontended SemaphoreSlim acquisition, async | ~14 ns, 72 B |
The shape of that table is the summary of the whole series: everything is cheap except suspending, and suspending is cheap compared to the I/O that caused it.
#When it actually matters
Given those numbers, async overhead is worth thinking about in exactly three situations.
An async method that almost never suspends, called very often. A cache lookup, a property-like accessor, a parser over a buffer. Here the 72 bytes per call is the entire cost, and it is pure overhead. This is what ValueTask is for, and it is the only case where the pooling builder is worth its rules.
A hot loop that suspends per item. Awaiting once per element of a million-element sequence is a million scheduling round trips. The fix is batching, not micro-optimising the await.
Anything that blocks. This is not really about overhead at all, and it dwarfs everything else in the table: 1000 concurrent operations took 3.3 seconds blocking and 53 ms async, a factor of 62. No amount of shaving nanoseconds off a state machine compares to not starving the thread pool.
Everywhere else, the answer is to write the clearest code. A 72-byte allocation on a code path that also does a database query is not worth a paragraph of thought.
#What to take away
- There are two paths: completing synchronously (nanoseconds, one allocation) and suspending (microseconds).
- Depth costs about 72 bytes and 7 ns per layer on the synchronous path, and almost nothing on the suspending path.
- Deep async chains are fine when they suspend for a real reason.
- Locals crossing an
await become fields. The bytes are few; the danger is holding something large for the whole operation. - The whole table is small compared to the I/O that made you go async in the first place.
- The one number that dominates everything is the cost of blocking, which is not overhead but lost concurrency.
That is the complete picture up to .NET 10, where async is a compiler rewrite. The next post is where that stops being true: .NET 11 moves suspension into the runtime, and several of these numbers change.
#Additional resources
Do you have a question or a suggestion about this post? Contact me!