Migrating to runtime async

 
 
  • 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 is the last post of the series proper, with one bonus post to follow. The previous two posts showed what runtime async does to the IL and to the numbers. This one is about the code you already have.

The short version: almost nothing changes, and that is the point. The longer version is worth reading before you turn it on in anything that matters.

#What does not change

Every behaviour this series described still holds. The sample for this post is a test project compiled with <Features>runtime-async=on</Features>, checking exactly that:

  • An async method still runs synchronously until its first suspending await. Calling it runs the body, same as before.
  • Exceptions are still captured on the returned task, not thrown at the call site.
  • AsyncLocal<T> still flows across an await, and ExecutionContext works the same way.
  • Cancellation still produces TaskStatus.Canceled, distinct from faulted.
  • Async streams and await using behave identically.
  • ConfigureAwait, SynchronizationContext, TaskCompletionSource, ValueTask all work as before.

That is the design goal. Runtime async is an implementation change beneath a language feature whose semantics are unchanged.

#What does change

Five things, in descending order of how likely you are to notice.

Live stack traces get shorter. Each async method appears once, with no AsyncMethodBuilderCore.Start frames, and a resumed method no longer sits on top of state machine and ExecutionContext frames. This is an improvement everywhere except in code that parses stack traces, which does exist: error-grouping logic, log scrubbers, and tests asserting on frame counts.

[AsyncStateMachine] is gone. Anything reflecting over it to find a method's state machine type now finds nothing. Some mocking frameworks, serialisers and diagnostic tools do this. It is also, in this preview, the only reliable way to detect whether an assembly was compiled with runtime async:

C#
var isRuntimeAsync = method.GetCustomAttribute<AsyncStateMachineAttribute>() is null;

Allocation profiles change shape. The AsyncTaskMethodBuilder<T>.AsyncStateMachineBox<TStateMachine> objects that dominate async allocation profiles today are replaced by continuation objects with different names. Any dashboard or alert keyed on those type names needs updating.

Custom AsyncMethodBuilder types. If you use [AsyncMethodBuilder] with your own builder, the compiler keeps generating a state machine for those methods. They are not broken; they simply do not participate. The same applies to PoolingAsyncValueTaskMethodBuilder, which is now largely redundant given the runtime's own continuation reuse.

IL-level restrictions. No suspension inside a catch, filter, finally or fault block; ref locals, ref-like structs and pinned locals do not survive a suspension; no localloc or tail prefix. Valid C# already respected all of these, so this matters only if you emit IL yourself.

#Turning it on

csproj (MSBuild project file)
<PropertyGroup>
  <Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

It is per assembly, which is the useful property: you can enable it on one project, measure, and expand. Mixed assemblies interoperate fine, because both forms produce ordinary Task and ValueTask values.

To opt a project out when a higher-level setting turned it on:

csproj (MSBuild project file)
<PropertyGroup>
  <UseRuntimeAsync>false</UseRuntimeAsync>
</PropertyGroup>

The DOTNET_RuntimeAsync and UNSUPPORTED_RuntimeAsync environment variables from earlier previews have been removed.

#A migration order that works

  1. Move to .NET 11 first, with runtime async off. Separating the runtime upgrade from the feature is what lets you attribute a regression to one or the other. The benchmarks in the previous post only made sense because of that middle column.
  2. Establish a baseline. Allocation rate, gen0 collections, p99 latency. dotnet-counters on System.Runtime is enough.
  3. Turn it on in one project, preferably a leaf library with good tests.
  4. Run the tests. Especially anything asserting on stack traces or reflecting over compiler-generated types.
  5. Compare the baseline. Expect fewer allocations on synchronous paths and little change elsewhere.
  6. Expand.

The one thing not to do is enable it across a solution on the day .NET 11 ships and then try to work out which of two changes caused a regression.

#For library authors

You do not have to do anything. A library compiled without runtime async works perfectly when consumed by an application that uses it, and vice versa.

Enabling it is safe and local. It changes only your assembly's IL, not your public API. Consumers cannot tell, except in stack traces.

Drop the pooling builder if you added it for performance. [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] opts a method out of runtime async and brings back the single-await rules. Measure before keeping it.

Reconsider ValueTask on deep call chains, not everywhere. ValueTask still wins where a method genuinely returns a value it already has. The weaker argument now is "use ValueTask because the chain is deep", since runtime async collapses those chains anyway. Given the rules ValueTask imposes on callers, dropping it where it was only a depth optimisation is a real simplification.

Multi-targeting works. Enable it under a net11.0 condition and leave older targets alone:

csproj (MSBuild project file)
<PropertyGroup Condition="'$(TargetFramework)' == 'net11.0'">
  <Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

#What to take away

  • Behaviour is unchanged. Eager start, exceptions on the task, AsyncLocal flow, cancellation, streams and disposal all work identically.
  • Live stack traces get shorter, [AsyncStateMachine] disappears, and allocation profiles change type names.
  • Custom AsyncMethodBuilder methods keep their compiler-generated state machines and opt out of the feature.
  • Enable it per assembly with <Features>runtime-async=on</Features>, opt out with <UseRuntimeAsync>false</UseRuntimeAsync>.
  • Upgrade to .NET 11 first, measure, then turn it on. Do not do both at once.
  • Library authors need do nothing; enabling it is safe and invisible to consumers.
  • It is preview in .NET 11. Verify against the SDK you ship on.

One bonus post follows: Sync vs Async File I/O in .NET takes everything here and applies it to a single subsystem, where the standard advice to always prefer async turns out to be only half the story.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?