Skip to content

필사 모드: .NET 11 Runtime-Async: A Progress Report on Moving the Async State Machine from Compiler to Runtime

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Introduction — Async Used to Be a Feature the Runtime Didn't Know About

You've probably decompiled async Task M() in C# at least once just to see what actually happens. Roslyn erases the method entirely, generates a struct implementing IAsyncStateMachine plus a MoveNext(), and leaves only a stub in the original method's place that kicks off that state machine. Local variables become fields, every await point gets its own state integer, and exceptions are caught by an AsyncTaskMethodBuilder.

Here's the core of it — the runtime has no idea this method is async. All the JIT sees is an ordinary class with an ordinary MoveNext method. Async was purely a compiler-side source rewrite, a concept that simply didn't exist as far as the CLR was concerned. That's been true for 14 years, ever since C# 5 introduced this model in 2012.

That design has a real cost. State-machine plumbing leaks straight into stack traces, debuggers have to pick through compiler-generated code, and the JIT has no way to know "this is an async suspension point," so it has no room to optimize around it. Runtime-async flips that premise. In the spec document's own words — implementing this via compiler rewriting works too, but implementing it directly in the .NET runtime is expected to yield improvements, especially in performance (runtime-async spec draft).

This post is a status check as of .NET 11 Preview 6, released on July 14, 2026. .NET 11 is an STS release supported from November 10, 2026 through November 9, 2028 (release notes README), so we're now four months out from GA.

What Runtime-Async Actually Changes — One Flag

The mechanism starts from a surprisingly simple place. The ECMA-335 amendment draft splits methods into two kinds, 'sync' and 'async,' and defines an async method as one carrying [MethodImpl(MethodImplOptions.Async)]. That flag is added to MethodImplAttributes as value 0x2000, is expressed in IL with the async keyword, and is recognized by ilasm/ildasm.

So what Roslyn does changes shape:

// what the developer writes
async Task M()
{
    // ...
}
// what gets compiled with runtime-async (conceptual)
[MethodImpl(MethodImplOptions.Async)]
Task M()
{
    // no state-machine class. the body stays exactly where it was.
}

The state-machine class disappears, and the method body stays right where it was. Suspension points are marked by calls into the helper methods on System.Runtime.CompilerServices.AsyncHelpers.

// suspension helpers as defined by the spec (partial)
namespace System.Runtime.CompilerServices
{
    public static class AsyncHelpers
    {
        [MethodImpl(MethodImplOptions.Async)]
        public static void Await(Task task);
        [MethodImpl(MethodImplOptions.Async)]
        public static T Await<T>(Task<T> task);
        [MethodImpl(MethodImplOptions.Async)]
        public static void AwaitAwaiter<TAwaiter>(TAwaiter awaiter)
            where TAwaiter : INotifyCompletion;
        // more variants exist for ValueTask, ConfiguredTaskAwaitable, etc.
    }
}

So await C.M() lowers to this IL:

call     [System.Runtime]System.Threading.Tasks.Task C::M()
call     void [System.Runtime]System.Runtime.CompilerServices.AsyncHelpers::Await(class [System.Runtime]System.Threading.Tasks.Task)

The spec explicitly says it favors this "two calls in a row" shape — an IL sequence that calls an async method and immediately calls Await gets maximum performance, because the JIT can recognize the pattern and chain them directly without an intermediate task object.

Local variables that cross a suspension point are "hoisted" so their state is preserved. That's what the state-machine struct's fields used to do; now a runtime-managed continuation object does it instead. Phrases like "continuation reuse" and "avoiding storing unchanged locals," which keep showing up in the preview notes, are all about this object.

One principle the Roslyn design doc nails down is worth remembering — this feature is meant to be invisible at the user level, initial binding is barely affected, and the compiler doesn't even know whether a method in a referenced assembly was compiled with runtime-async (Roslyn Runtime Async Design). Manually attaching MethodImplOptions.Async yourself is also disallowed — try it, and the compiler throws an error.

The First Visible Payoff — Live Stack Traces

Before we get to performance, this is the part that lands first. The Preview 2 release notes include an example that walks through OuterAsyncMiddleAsyncInnerAsync and dumps a new StackTrace().

Without runtime-async, you get 13 frames.

   at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__InnerAsync|0_2()
   at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__MiddleAsync|0_1()
   at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__OuterAsync|0_0()
   at Program.<Main>$(String[] args) in Program.cs:line 3
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<Main>$(String[] args)
   at Program.<Main>(String[] args)

Turn it on and you get 5.

   at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
   at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
   at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
   at Program.<Main>$(String[] args) in Program.cs:line 3
   at Program.<Main>(String[] args)

(Both outputs are taken from the Preview 2 runtime notes. The $>g__ in the method names is there because they're local functions, not because they're async.)

It's worth catching a detail the release notes don't spell out for you. What improves is the "live" stack trace — what a profiler, a debugger, or a new StackTrace() called at runtime sees. The exception stack trace you get from catch (Exception ex), on the other hand, already looks the same with or without runtime-async, thanks to the existing ExceptionDispatchInfo cleanup in compiler-generated code.

So "async stack traces are finally clean" is only half true. The exception traces you look at in your logs every day were already fine; what's improving here is the profiler and debugger call-stack views. And including runtime-async frames in Environment.StackTrace and System.Diagnostics.StackFrame is still open as an API proposal. The diagnostics side is also still an open item on the epic issue.

On the debugging side, Preview 2 got breakpoints binding correctly inside runtime-async methods, and stepping across await boundaries without bouncing through compiler-generated infrastructure (dotnet/runtime #123644).

Preview 1 Through 6 — What Actually Happened

Pinning dates and commits to this makes it much clearer where the feature actually stands.

Preview 1 (2026-02-10). CoreCLR's runtime-side support became enabled by default — no more environment variable needed. NativeAOT could also compile runtime-async code. But the notes are explicit — at this point, none of the core runtime libraries are compiled with runtime-async.

Preview 2 (2026-03-10). The stack trace and debugger improvements covered above.

Preview 3 (2026-04-14). The barrier to entry dropped a notch. With the [RequiresPreviewFeatures] gate removed from the API, net11.0 projects no longer need EnablePreviewFeatures turned on just to flip runtime-async=on (dotnet/runtime #124488). NativeAOT and ReadyToRun support also landed in this preview.

 <PropertyGroup>
   <Features>runtime-async=on</Features>
-  <EnablePreviewFeatures>true</EnablePreviewFeatures>
 </PropertyGroup>

Preview 4 (2026-05-12). The biggest event of the cycle. The entire runtime library started being built with runtime-async=on. In the release notes' own phrasing, the runtime library no longer contains compiler-generated state machines and instead relies on the runtime-provided async feature. On top of that, the constraint blocking crossgen2 from inlining runtime-async methods was lifted, and 69 async tests passed on both crossgen2 and composite R2R (dotnet/runtime #125472).

Preview 5 (2026-06-09). Suspend/resume cost optimizations. Covered in detail in the next section.

Preview 6 (2026-07-14). The JIT changed so that a synchronous task-returning method now gets a dedicated runtime-async version compiled separately, instead of being routed through a thunk (dotnet/runtime #128384). And continuations can now skip ExecutionContext capture/restore (dotnet/runtime #128323) — the runtime now detects when a context snapshot would end up doing nothing anyway (no AsyncLocal values in play) and skips it entirely, instead of taking one on every resume regardless.

There's a backstory behind that first Preview 6 change. More on that later.

Reading the Numbers Honestly

Preview 5's headline number is this — a benchmark heavy on suspensions improved from Took 6357.1 ms to Took 457.1 ms (dotnet/runtime #127074). Close to 14x. How should you read that?

First, what actually changed. OSR (On-Stack Replacement) is a JIT feature that lets a long-running method switch from tier-0 code to optimized code mid-execution. The problem was that when async resumed inside an OSR method, it took the general OSR transition path (the patchpoint helper), and the PR description notes that helper isn't cheap — overhead runs roughly 10 to 20 times higher. This change jumps directly from tier-0 code to OSR code, bypassing the helper entirely.

Now, what the benchmark actually is. Looking at the repro code attached to the PR: it builds a NullAwaiter whose IsCompleted is always false, uses a 10-million-iteration warmup loop to make the method an OSR candidate, and then runs await na 10 million times inside it. An outer loop keeps calling na.Continue() to wake it back up.

In other words, this is a synthetic microbenchmark where every single await suspends, 100% of the time. In real code, a good share of awaits hit an already-completed task and pass through without suspending at all (the spec itself notes that "if all Task-like objects have already completed, no suspension is necessary"). This benchmark deliberately drives that fast path to 0% and measures the suspension path alone. So the number should be read not as "your app gets 14x faster" but as "this specific bottleneck on the suspension path shrank by this much." It's a vendor-measured figure, under the conditions described above.

The rest of Preview 5's numbers are the same kind of figure. The suspension code-size reduction is about an 8 percent improvement on suspension-heavy microbenchmarks, with the PR's sample generated code going from 766 bytes to 751 bytes (#126041). The TLS-operation and write-barrier reduction takes the PR's sample from Took 350.3 ms to Took 291.3 ms (#127336). All of these are figures the PR authors measured on their own samples.

So what's the answer to "how much faster does a real app get"? Microsoft hasn't given one yet. The Preview 4 release notes sum up the situation most accurately — they expect runtime-async to bring throughput and library-size improvements, note that the magnitude will scale with how much async you use, and say they welcome reports either way, positive or negative. For a feature four months from GA, having official docs explicitly ask for negative results too is unusual, and honest. It's not that there's no headline number — it's that there isn't one to give yet, and that's the accurate way to read it.

It's Already Running Under You Even If You Never Turn It On

This may be the single most important section in this post.

Runtime-async is an opt-in preview feature as far as your own code is concerned. To turn it on, you need this in your project file:

<PropertyGroup>
  <Features>runtime-async=on</Features>
</PropertyGroup>

But starting with Preview 4, the runtime library already ships built this way. Which means the moment you call Task.WhenAll, Stream.ReadAsync, or any socket code on .NET 11, the async inside the BCL is already running on runtime-async — whether or not you turned anything on yourself. That's the shift from Preview 1's "none of the core libraries are compiled this way" to Preview 4's "all of them are."

There's concrete evidence this isn't just an abstract claim. dotnet/runtime #126925 documents an issue where, after runtime-async was unconditionally enabled in System.Private.CoreLib, an IIS in-process-hosted ASP.NET Core app started returning HTTP 500 on every request. The app starts up fine, ANCM logs show success, and then every subsequent HTTP request fails with a 500. It was opened on April 14, 2026, and was still open as of the July 1 update — discovered via a codeflow PR carrying runtime changes into dotnet/aspnetcore.

What this one issue tells you is that this isn't "a flag only people who want to experiment touch" — it's a structural change that's already carrying load at the bottom of the platform. So if you run into an unexplained async-related anomaly on a .NET 11 preview, the fact that you never turned runtime-async=on on in your own code is not an alibi.

For reference, the old DOTNET_RuntimeAsync and UNSUPPORTED_RuntimeAsync environment variables have been removed. The official docs say to use <UseRuntimeAsync>false</UseRuntimeAsync> to turn it off on a per-project basis (What's new in .NET 11 runtime, current as of Preview 5). That said, this property is confirmed from the build files of the dotnet/runtime repo itself — I'd recommend verifying its behavior yourself in an ordinary user project.

What Doesn't Work Yet, and What's Actually Slower

This is the section this post exists for. The open issues pin down exactly where this feature currently stands.

Pooling builders are a regression. Methods annotated with [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] are showing a regression due to allocations compared to before runtime-async (dotnet/runtime #129004, June 4, 2026). The response was avoidance rather than a fix — runtime-async is disabled for any method carrying an AsyncMethodBuilder attribute for the time being, which is PR #128943. The Preview 6 release notes describe this softly as "methods that are already pooled opt out of runtime-async to avoid duplicate work," but the issue's own language is more candid. If you were using a pooling builder because you're allocation-sensitive, those methods aren't beneficiaries of this feature — they're being routed around it.

Large structs can round-trip through the heap. dotnet/runtime #120855 collects poorly-performing patterns under runtime-async, and one of them is that large structs can get copied to the heap repeatedly when suspend/resume happens frequently. It was opened in October 2025 and was still being updated as of July 9, 2026.

Synchronous task-returning wrappers used to be a de-optimization for a long time. Wrapping an async method with a synchronous Task-returning wrapper to save a state machine is a very common pattern. dotnet/runtime #115771 states it plainly — under runtime-async, this is a significant de-optimization. The issue was opened in May 2025, and was closed when PR #128384, covered above under Preview 6, merged on June 22, 2026. The last line of the PR description reads Fix #115771. A de-optimization that lived for over a year got fixed four months before GA — good news, but also a signal about how mature this feature actually is.

ExecutionContext.SuppressFlow() may not be honored. dotnet/runtime #122052's title says it plainly. The issue body points out that a suppressed execution context flowing anyway is observable behavior, and that one reason for suppressing it in the first place can be to keep secret values from leaking. There's an attached log showing System.Net.Sockets's ExecutionContextFlowTest failing with an expected value of 0 but an actual value of 42. Opened in November 2025, still being updated as of July 3, 2026. This is different in kind from the others — it's not a performance problem, it's a semantics problem.

Custom awaiters get boxed on suspension. dotnet/runtime #119842 is open on avoiding this boxing. Worth checking if you're using a hand-rolled awaiter.

Multicore JIT excludes async variants. Per dotnet/runtime #115097, there's no fundamental reason for this — it's just not implemented/tested yet (NYI).

Profiler ReJIT is a question mark. dotnet/runtime #128944 is literally an open question: "is profiler ReJIT of runtime-async method bodies meant to be supported in .NET 11?" Worth watching if you're running with an APM agent attached.

Async iterators aren't here yet. The Roslyn design doc still lists async iterators returning IAsyncEnumerable as a TODO, and in Roslyn's feature-status table, "Runtime Async Streams" is tracked as in-progress work on a separate branch. "Runtime Async" itself is listed in that table as merged to main as preview.

The epic issue (#109632) was opened in November 2024 and remained open as of July 13, 2026, with diagnostics (StackTrace formatting/output) and "what to do about function pointers to async methods" still listed as unresolved items.

Constraints the Spec Nails Down — These May Never Change

If the issues above are "not yet" problems, there are also constraints the spec draft explicitly says are likely to be permanent.

  • Exactly four return types. Only System.Threading.Tasks.Task, ValueTask, Task<T>, and ValueTask<T> can be the return type of a runtime-async method.
  • Byref locals cannot be hoisted across a suspension point. More specifically, the spec states that reading a byref variable after a suspension point yields null, and that byref-like structs also aren't hoisted, defaulting after suspension. The same applies to pinned locals.
  • A suspension point cannot appear inside an exception-handling block.

That last point raises an obvious question — what happens to await inside a try? The answer is in the Roslyn design doc. Compiler-generated state machines and runtime-generated async share some of the same building blocks, and this is one of them — await inside a catch/finally block gets rewritten to pend the exception, perform the await outside the catch/finally region, and restore the exception afterward if needed. This rewrite has always been there and stays exactly as it was. So even though the source-level await sits inside an EH block, the IL-level suspension point ends up outside it. From the developer's point of view, nothing changes.

The temporary constraints include a ban on the tail prefix and on the localloc instruction — the spec notes these may be lifted later.

And then there's the quietest but most important constraint. In the Roslyn design doc's own words — a method returning some other Task-like type is not converted to the runtime-async form and continues to use the C#-generated state machine. If your code leans on a type with a custom builder attached, that code keeps running the old way. The two models coexist within the same process, and that's by design, not a bug. The Mono runtime isn't a supported target either.

So What Should You Actually Do Right Now

When there's a reason to turn it on

  • If you're running an async-heavy, suspension-heavy service and plan to flip this on once .NET 11 hits GA anyway, benchmarking it on preview now is worth it. Microsoft is explicitly asking for negative results, and GA is four months out. A regression found now has a chance of getting fixed; one you find after GA is yours to live with.
  • If you frequently read async call stacks through a profiler or debugger, 13 frames down to 5 is a genuinely noticeable improvement.

When you shouldn't turn it on

  • Production. It's a preview feature, the epic issue is still open, issues like the IIS in-process 500 remain open, and ExecutionContext.SuppressFlow semantics aren't finalized.
  • If you're using pooling builders to squeeze out allocations. The regression is confirmed, and the runtime's own answer was to exclude those methods entirely.
  • If custom Task-like types or custom builders are core to your codebase. They don't get converted regardless.
  • If you're targeting Mono. It's not a supported target.
  • If your profiler or APM agent depends on ReJIT. Whether it's even meant to be supported is still an open question.

What people who never turn anything on should know

  • Upgrading to .NET 11 alone changes the BCL's async paths to runtime-async. This isn't opt-in. If you hit an unexplained difference in async behavior during migration testing, put this on your list of suspects.

Closing

Runtime-async isn't "an optimization that makes async faster" — it's a change in whose feature async is. It moves something that was a compiler-side source rewrite for 14 years into a first-class runtime concept, and that's exactly why the gains show up differently — once the state machine as an intermediate representation disappears, the JIT finally knows "this is a suspension point," and only then do things like OSR-resume-path optimization or tail-merging suspension points become possible. Everything done in Preview 5 and 6 is that category of work — optimizations that simply couldn't have been attempted under the state-machine model.

At the same time, the honest state of things as of July 2026 is this. The runtime library has already moved over, stack traces really are shorter, and a de-optimization that lived over a year got fixed last month. And pooling builders are regressing, the IIS in-process issue is open, SuppressFlow semantics are undecided, and async iterators haven't arrived yet. The epic issue has been open since November 2024.

GA is November 10, 2026. How much shorter that list gets by then is the real answer to whether you should turn this on. The most productive thing you can do right now is benchmark it against your own workload on preview and send back results — especially bad ones. That's literally what the documentation is asking for.

References

현재 단락 (1/132)

You've probably decompiled `async Task M()` in C# at least once just to see what actually happens. R...

작성 글자: 0원문 글자: 22,507작성 단락: 0/132