Runtime Async in .NET 11
Objective
Since C# 5 an async method has been rewritten by the compiler into a state machine: fields for the
locals, a MoveNext method, a builder that owns the returned Task. The runtime sees an ordinary
class with an ordinary method and knows nothing about "suspension". .NET 11 adds a second way: with
runtime async the compiler marks the method as async and the runtime itself captures and resumes the
frames. The lesson on Task and async/await internals already has a short section on this. This
one goes deeper: what exactly switches on, what you gain and what you do not, and what to check before
you turn it on in your own project.
The honest framing comes first. Runtime async is enabled in the runtime libraries but still documented as a preview feature for your own code. Everything here was run on SDK 11.0.100 and runtime 11.0.0 (macOS arm64, Release builds unless said otherwise). Where I only have the documentation, I say so.
Use Cases
- Deciding whether to turn the flag on in an application that is already moving to
net11.0. - Reading a profiler, a debugger call stack or a log of
new StackTrace()and wondering where theMoveNextandAsyncMethodBuilderCoreframes went. - Explaining why a service that only changed its target framework now allocates less on suspending calls without a code change.
- Reviewing a claim such as "runtime async makes async 10 times faster" against what you can measure.
Deep Dive
What already comes on, and what you opt into
There are two separate things. First, the .NET 11 runtime libraries are compiled with the feature, so
the async methods in the base class library no longer have compiler state machines. I counted methods
carrying [AsyncStateMachine] in the shipped assemblies: 3 in System.Private.CoreLib, 2 in
System.Net.Http and 0 in System.IO.Compression, against 69, 119 and 51 on .NET 10. You get that
without doing anything, as soon as the app runs on the 11 runtime.
Second, your own code opts in with one property, and on net11.0 you do not need
EnablePreviewFeatures:
plaintext<PropertyGroup> <Features>runtime-async=on</Features> </PropertyGroup>
The .NET 11 runtime notes
say "Runtime Async is a preview feature", so read the opt-in as a decision about risk, not as a
switch Microsoft has declared production-ready. According to the same page the old
DOTNET_RuntimeAsync and UNSUPPORTED_RuntimeAsync environment variables were removed, and the way to
opt out per project is <UseRuntimeAsync>false</UseRuntimeAsync>. I did not test that opt-out; it is
from the documentation. I did test that with the flag on the method no longer has
AsyncStateMachineAttribute, which is an easy check that the compiler really took the other path.
The live stack gets shorter
I wrote a recursive async method that awaits Task.CompletedTask and then calls itself, and at the
bottom returns new StackTrace().FrameCount. Frames at recursion depth 0, 3 and 10:
textdepth 0 depth 3 depth 10 per level classic 9 21 49 4 runtime async 3 6 13 1
The classic build adds four frames per level, the method plus the state-machine and builder
infrastructure around it. With runtime async each call is one frame, the real call chain. The documentation example shows the same effect: 13 frames became 5. This is the
"live" stack, what a profiler or a debugger sees while the code runs, or what a log line with
new StackTrace() prints.
An exception's StackTrace did not change. For a method that yields and then throws three levels deep,
both builds printed the same five lines (four calls of the method plus Main). The documentation
explains why: the existing cleanup of exception stack traces in compiler-generated code already hid the
state-machine frames. So the exception text your logs and Sentry captured before does not get shorter
by turning the flag on.
Allocation and time, measured and not guessed
The marketing version says "lower overhead". I measured two shapes of call, both in Release. The first
awaits a method that always suspends once (await Task.Yield()), 200,000 times in a loop, and divides
GC.GetTotalAllocatedBytes by the call count:
textclassic runtime async returns Task<int> 96 B/call 56 B/call returns ValueTask<int> 104 B/call 56 B/call time, 200k Task calls 148-178 ms 59-63 ms
Suspending costs less memory in both shapes, and the Task version took about 2.5 times less time over
several runs. The ValueTask time was too noisy to claim anything (58 to 106 ms against 76 to 102 ms),
and 56 bytes is still an allocation: runtime async does not make suspension free.
The second shape is the one people use to sell ValueTask: 2 million awaits of a method whose result is
already available (await Task.CompletedTask inside). Both builds allocated nothing. After warm-up the
classic build ran it in about 8 ms and runtime async in about 12 ms (the first classic runs took 75 ms
before tiered compilation optimized them). So on the synchronous fast path I measured no gain and a
small loss. An earlier run of mine on a release candidate, in a Debug build, had shown 94 MB against
215 KB for this loop. That does not describe Release, where the classic fast path allocated nothing, and that is the reason this lesson uses Release numbers only. These are micro measurements on
one machine: they show the direction, not what your service will gain. The documentation lists the
mechanisms behind the improvement (cached continuations, tiered async compilation, JIT folding of
Task.CompletedTask), and says that continuations that have nothing to restore skip the
ExecutionContext capture, which I did not measure.
What does not change for the code you write
The contract is the same, and the lab checks the pieces:
- Results and ordering. Both builds returned the same sums, and
awaitstill resumes after the awaited work completes. ConfigureAwait. With a countingSynchronizationContextinstalled, anawait Task.Delay(5)posted one continuation to it andConfigureAwait(false)posted zero, in both modes. The rules for libraries and for application code are unchanged.AsyncLocal<T>. A value set inside an async method was visible after anawaitin that method and did not leak to the caller, in both modes.ValueTask. The usage rules (await once, do not use it concurrently) are part of the type, not of the implementation. Runtime async does not make a double await legal.- The type you return. The method still returns
Task<T>; I printedGetType()of the returned task in both modes and gotTask<int>, so nothing in your signatures changes.
Debugger and diagnostics
This part is from the documentation, not from a debugger session. It says breakpoints bind inside
runtime-async methods and that stepping crosses await without entering generated infrastructure, and
that the async profiler now reports events for both runtime-async methods and compiler state-machine
methods, so tools see one consistent model. It also says Native AOT and ReadyToRun are supported.
What I would check before enabling it in a real project is anything that reads the state machine: a tool
or an in-house script that walks MoveNext frames or state-machine fields in a dump, or a test that
looks for AsyncStateMachineAttribute by reflection. With the flag on the attribute is gone, as the lab
shows. I did not test dotnet-dump or the async stack commands against a dump of a runtime-async
process, so treat that as an open item for your own tooling.
The lab
The lab compiles one Workload.cs into two projects that differ in a single line, and the tests load
both through extern aliases. They assert properties with margins: classic adds at least twice as many
frames per level as runtime async, runtime async adds about one, the allocation per suspending call is
between 1 and 1,024 bytes in both and lower with runtime async, ConfigureAwait posts 1 or 0
continuations, and the exception traces have the same number of lines. No test asserts an exact byte
count or a time, because both change with the machine and the build configuration.
Trade-offs
- Preview for your own code. The libraries use it; your opt-in is the documented preview. Turn it on in a service with good tests and a way back, not in a library you ship to others.
- Live stacks and exception stacks differ. Profilers and debuggers get shorter stacks, but exception traces did not change, so do not promise shorter error logs.
- Gains depend on the shape of the call. Suspending calls allocated about 40 to 50 percent less here. The synchronous fast path had no allocation to remove and was slightly slower in my loop.
- Tooling that reads state machines can break. Anything that depends on
MoveNext, builders orAsyncStateMachineAttributeneeds to be checked with the flag on. - Not a reason to change code. Do not replace
TaskwithValueTaskor removeConfigureAwaitbecause of this feature; the rules from the internals lesson still decide those.