Where Runtime Async in .NET 11 saves memory and where it doesn't
.NET 11 moves async method suspension from compiler-generated state machines to the runtime, with the JIT optimizing the boundaries between async methods. The author reruns the official benchmarks on RC2 and compares allocations, stack traces and assembly size. The gains are real in synchronous await chains and exception-heavy paths, but measurement setup changes the picture.
Key points
- Runtime Async is a preview in .NET 11; enable it with <Features>$(Features);runtime-async=on</Features> and disable per project with <UseRuntimeAsync>false</UseRuntimeAsync>.
- In the author's out-of-process BenchmarkDotNet runs, a synchronously completing await chain fell from 144 B to 0 B, matching Stephen Toub's published numbers.
- With a Task.Yield chain the allocation dropped from 248 B to 168 B; an exception thrown from depth 30 went from 84.2 KB to 5.71 KB in a synthetic test.
- In-process measurements showed the opposite for some scenarios, so compare results only under identical methodology, SDK and runtime versions.
- Live stack traces lose the builder frames (3 frames in the classic run, 0 in the runtime run), so log parsers and error grouping rules may need updating.
- async void, async iterators and custom task-like types are not covered yet and keep the classic transform.
- The DOTNET_RuntimeAsync and UNSUPPORTED_RuntimeAsync environment variables are no longer supported.
Runtime Async, a preview feature in .NET 11, moves the suspension logic of async methods from compiler-generated state machines to the runtime and the JIT. The author reran the .NET team's benchmarks on RC2 and matched the published allocation numbers, but only when each benchmark ran in its own process. In that setup, synchronous await chains drop to zero allocations, deep exception chains get much cheaper, and the compiled assembly is roughly half the size.
The gains are not universal. In-process measurements gave different numbers, and in some cases with real yields the classic model allocated less. Typical I/O-bound services waiting on databases will see little latency change, while caches and parsers with many synchronously completing awaits benefit most. Stack traces also lose the builder frames, so tools that parse state machine names may need checking. async void, async iterators and custom task-like types still use the classic transform.
Real-world use
- Measuring GC pressure in high-volume await chains in caches or parsers that often complete synchronously.
- Reducing exception cost in deep async call chains in services that throw and catch errors frequently.
- Shrinking assembly size in large codebases with many async methods before deployment.
- Updating logging and error-grouping rules that depend on async state machine frame names.
Why it matters
Before enabling Runtime Async, measure your own workload with GC metrics under a consistent benchmark setup. Typical I/O-bound APIs will gain little, while hot paths with synchronously completing awaits may show the real difference.