The cost of async: a BenchmarkDotNet study on .NET 10

 
 
  • Gérald Barré

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);
    }
}
MethodDepthMeanAllocated
Sync10.5136 ns-
AsyncCompletedSynchronously112.1864 ns144 B
AsyncSuspendingAtTheBottom11,611.0401 ns206 B
Sync50.3498 ns-
AsyncCompletedSynchronously537.8564 ns432 B
AsyncSuspendingAtTheBottom51,889.5431 ns621 B
Sync204.5263 ns-
AsyncCompletedSynchronously20140.9409 ns1512 B
AsyncSuspendingAtTheBottom202,386.8073 ns1985 B
Benchmark project, measured with BenchmarkDotNet

Two 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;
    }
}
MethodMeanRatioAllocated
NoLocals1.317 us1.0096 B
LocalsThatDoNotCrossTheAwait1.774 us1.35192 B
LocalsThatCrossTheAwait1.749 us1.33216 B
Benchmark project, measured with BenchmarkDotNet

The 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:

OperationCost
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 CancellationTokenbelow measurement
Registering a cancellation callback~8 ns, 0 B
ConfigureAwait(false) with no contextunder 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!

Follow me:
Enjoy this blog?