For years, whenever someone asked how async and await work in C#, the answer started with the same sentence:

The compiler turns your async method into a state machine.

With .NET 11, Microsoft is working on changing that.

.NET 11 introduces Runtime Async, also called Runtime Async V2, where much of the machinery required to suspend and resume an async method moves from compiler-generated state machines into the .NET runtime itself.

And unlike many runtime changes, this one is actually visible to developers.

How async works today

Take a normal async method:

public async Task<string> GetUserAsync(int id)
{
var response = await httpClient.GetAsync($"/users/{id}");
return await response.Content.ReadAsStringAsync();
}

The code looks simple.

The compiled version isn’t.

Traditionally, the C# compiler generates a state machine implementing IAsyncStateMachine. Conceptually, you end up with something resembling:

struct GetUserAsyncStateMachine : IAsyncStateMachine
{
public int State;
public AsyncTaskMethodBuilder<string> Builder;
public void MoveNext()
{
// Run code
// Check awaiters
// Store state
// Suspend
// Resume later
// Continue executing
}
}

There is obviously much more going on than this simplified example, but the important part is that the compiler is responsible for rewriting the method.

Every await becomes part of that state machine.

The generated state needs to remember things such as:

  • where execution stopped
  • local variables that must survive the suspension
  • the awaiter
  • the continuation
  • the eventual result or exception

This model has worked incredibly well. async and await have been part of C# for well over a decade.

But it also means that async is largely a compiler feature sitting on top of the runtime rather than something the runtime understands directly.

Microsoft started experimenting with changing this several releases ago. The original runtime-async experiment concluded that the runtime implementation was at least as good as compiler async in the configurations Microsoft measured, while potentially remaining compatible enough to act as a replacement.

.NET 11 is where that experiment is turning into a real runtime feature.

What Runtime Async changes

With Runtime Async, the compiler no longer needs to generate the traditional async state-machine class for every method.

Instead, the runtime understands that the method can suspend.

When execution reaches an incomplete await, the runtime can preserve the required state, suspend the method and resume it once the asynchronous operation completes.

Conceptually, we are moving from:

C# async method
|
v
C# compiler
|
v
Generated IAsyncStateMachine
|
v
.NET runtime

to something closer to:

C# async method
|
v
C# compiler marks it as async
|
v
.NET runtime handles suspension/resumption

You still write:

async Task DoSomethingAsync()
{
await SomethingAsync();
}

There is no new C# syntax.

Task, Task<T>, ValueTask, ConfigureAwait, cancellation and the general async programming model aren’t suddenly disappearing either.

The implementation underneath them is changing.

Microsoft describes Runtime Async as runtime-managed suspension and resumption replacing compiler-generated async state machines.

The first thing you’ll notice: much cleaner stack traces

This is probably the easiest improvement to demonstrate.

Microsoft uses an example with three methods:

await OuterAsync();
static async Task OuterAsync()
{
await Task.CompletedTask;
await MiddleAsync();
}
static async Task MiddleAsync()
{
await Task.CompletedTask;
await InnerAsync();
}
static async Task InnerAsync()
{
await Task.CompletedTask;
Console.WriteLine(
new StackTrace(fNeedFileInfo: true));
}

With traditional compiler async, the live stack in Microsoft’s example contains 13 frames.

Several of those frames come from async infrastructure such as:

AsyncMethodBuilderCore.Start<TStateMachine>

With Runtime Async enabled, the same example produces only 5 frames and essentially shows the actual application call chain:

InnerAsync
MiddleAsync
OuterAsync
Main

That’s a pretty nice improvement.

If you’ve ever looked at async code through a profiler or debugger and had to mentally filter out state-machine plumbing, you’ll immediately understand why this matters.

There is an important distinction here though.

Microsoft specifically calls this an improvement to live stack traces.

Exception stack traces are already cleaned up by the existing async infrastructure, so don’t expect every exception stack trace to suddenly become dramatically shorter.

The main improvement affects things inspecting a running stack:

  • debuggers
  • profilers
  • diagnostic tooling
  • StackTrace
  • runtime diagnostics

Debugging gets better too

Moving async into the runtime would be rather painful if stepping through code became worse.

Microsoft has been working on debugger support alongside the runtime implementation.

Breakpoints now bind inside Runtime Async methods and the debugger can step across await boundaries while mapping execution back to the original source rather than exposing the internal async machinery.

That might sound like a relatively small feature, but good debugger support is one of the reasons C# async has always felt so natural despite what is actually happening underneath.

Runtime Async needs to preserve that experience.

Microsoft is already using it inside .NET

This is where the experiment gets much more interesting.

Starting with .NET 11 Preview 4, Microsoft began building the .NET runtime libraries themselves with Runtime Async enabled.

That means the async methods inside the runtime libraries no longer contain the traditional compiler-generated state machines.

This effectively gives Microsoft a gigantic real-world test suite.

Instead of testing Runtime Async against a handful of benchmarks and samples, a significant amount of framework code is now exercising the new implementation.

Microsoft also explicitly said that it expects throughput and library-size improvements, particularly as the amount of asynchronous code increases.

That doesn’t mean your ASP.NET Core application is automatically going to become 30% faster.

It does mean Microsoft sees performance and code size as major reasons for doing this work.

What about performance?

This is probably the question most developers will ask first.

Runtime Async gives the runtime and JIT more control over something that was previously encoded into compiler-generated structures.

That creates optimization opportunities that are difficult to achieve when the runtime only sees the result of the compiler transformation.

And Microsoft has already been optimizing it aggressively.

.NET 11 Preview 5 included several improvements around suspension and resumption.

One particularly interesting case involved On-Stack Replacement, or OSR.

Runtime Async previously paid a fairly expensive transition cost when suspending methods that had been optimized through OSR.

After changing the runtime to resume directly into the optimized code, Microsoft’s suspension-heavy test went from:

6357.1 ms

to:

457.1 ms

That is a huge difference.

But don’t read that as “.NET 11 makes async 14x faster.”

This was a specific microbenchmark targeting a particularly expensive runtime path.

More representative improvements in the same preview included:

  • smaller suspension code
  • less thread-local-storage work
  • fewer write barriers
  • continuation reuse for some ValueTask scenarios
  • fewer allocations

Microsoft reported roughly an 8% improvement in one suspension-heavy microbenchmark simply from reducing generated suspension code.

Preview 6 continued the work.

The JIT can now compile a dedicated runtime-async version of synchronous Task-returning methods instead of routing them through an additional thunk. Suspension points can also be tail-merged and some continuations are cached and reused.

This is probably the more important long-term story.

Runtime Async gives the JIT much more visibility into asynchronous execution.

And if the JIT understands something, the JIT can optimize it.

ExecutionContext also gets cheaper

There is another async optimization in .NET 11 worth mentioning.

ExecutionContext carries ambient state such as AsyncLocal<T> values across asynchronous operations.

Historically, continuations have had to capture and restore this context even when there wasn’t actually anything useful to restore.

.NET 11 can now detect situations where the continuation has no relevant ExecutionContext state and skip that work.

This optimization benefits:

Task
Task<T>
ValueTask
ValueTask<T>

as well as the Runtime Async implementation itself.

For async-heavy server applications where tiny costs occur millions of times, removing work from the continuation path can matter quite a lot.

NativeAOT and ReadyToRun are supported

Runtime Async isn’t restricted to normal JIT-compiled applications.

Support for both NativeAOT and ReadyToRun has been added during the .NET 11 previews.

Microsoft has also removed restrictions that prevented Runtime Async methods from being inlined during ReadyToRun compilation.

That’s important if Runtime Async is eventually supposed to replace the existing implementation rather than simply become another optional async mechanism.

It has to work everywhere .NET async works.

How to enable Runtime Async in .NET 11

At the time of writing, Runtime Async is still a preview feature and must be explicitly enabled.

Target .NET 11 and add this to your project file:

<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>runtime-async=on</Features>
</PropertyGroup>

That’s it.

Earlier .NET 11 previews also required:

<EnablePreviewFeatures>true</EnablePreviewFeatures>

but that requirement was removed in Preview 3.

You might also find older articles mentioning:

DOTNET_RuntimeAsync
UNSUPPORTED_RuntimeAsync

Those environment variables are no longer how Runtime Async is configured.

The current mechanism is the project-level compiler feature switch.

Does this change how we write async code?

For most developers, no.

And that’s probably the best part.

You don’t need to learn:

runtimeasync

or some new collection of keywords.

You continue writing:

public async Task<User> GetUserAsync(int id)
{
return await repository.GetUserAsync(id);
}

The difference is below your code.

This is similar to many of the best runtime improvements in .NET: the platform gets smarter while your application code stays boring.

Is the compiler-generated async state machine going away?

Eventually, maybe.

But I wouldn’t write its obituary yet.

Microsoft describes Runtime Async as a significant step toward replacing compiler-generated async state machines, not as something that has already replaced them everywhere. Runtime Async remains a preview feature in .NET 11 Preview 6.

There are also years of compatibility concerns around async:

  • custom async method builders
  • debuggers
  • profilers
  • reflection
  • AOT
  • unusual awaiters
  • ExecutionContext
  • exception handling
  • runtime diagnostics

Microsoft has been gradually covering these cases across each .NET 11 preview instead of flipping everything over at once.

That’s the right way to make a change this deep.

Why Runtime Async matters

Runtime Async probably isn’t the .NET 11 feature that will produce the most exciting conference demo.

Your C# code looks exactly the same.

But architecturally, this could become one of the more important runtime changes .NET has made to asynchronous programming.

For more than a decade we’ve explained C# async like this:

The compiler rewrites your method into a state machine.

If Runtime Async succeeds, that explanation changes.

The compiler can identify the asynchronous method, while the runtime and JIT take much more responsibility for actually executing, suspending, optimizing and resuming it.

That gives us cleaner live stack traces today.

It gives Microsoft more optimization opportunities tomorrow.

And eventually, one of the most important programming models in modern .NET may stop being mostly compiler magic and become something the runtime understands natively.

Affiliate promo

If you love learning new stuff and want to support me, consider buying a course from Dometrain using this link: Browse courses – Dometrain. Thank you!

Leave a comment

Trending