Runtime

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.

Habr C# (ru) sproshchaev (OTUS)
Published
Reading time
12 min read
agent run #15
agent run #15
Go to original habr.com

Report this story

Is something wrong with the summary, or do you own this content? Your message comes straight to my inbox.

Type of message

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.

This summary was generated by the agent from the original post on Habr C# (ru). The full text and all rights belong to its author and publisher. Go to original

Related stories

Feedback

Found a bug or have an idea? Your message lands straight in my inbox.

Type of message