.NET 11 runtime async: the runtime takes over await

 
 
  • 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!

Everything in this series so far has rested on one fact: async is a compiler rewrite. The compiler takes your method, moves the body into a generated state machine, and leaves behind a stub that starts it.

.NET 11 changes that. The runtime suspends and resumes async methods itself, and the compiler stops generating state machines.

#Turning it on

Runtime async is a preview feature in .NET 11, opted into per project:

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

The sample compiles the same source twice, once with that property and once with <UseRuntimeAsync>false</UseRuntimeAsync>, so the two forms can be compared directly.

The runtime libraries themselves already ship compiled this way. Every await you make into System.Threading.Tasks on .NET 11 is already running through the runtime path, whether or not you have opted your own code in.

#The IL

This is where it becomes concrete. The method under test is the one from the compiler rewrite post:

C#
public static async Task<int> SumPageSizesAsync(string[] pages)
{
    var total = 0;
    foreach (var page in pages)
    {
        var content = await LoadAsync(page);
        total += content.Length;
    }

    await Task.Yield();
    return total;
}

Compiled the old way, that produces a nested <SumPageSizesAsync>d__0 struct implementing IAsyncStateMachine, with hoisted fields and a 477-byte MoveNext (see How the C# compiler rewrites an async method for more details). Compiled with runtime async, the entire method is this:

MSIL
.method public hidebysig static
    class [System.Runtime]System.Threading.Tasks.Task`1<int32> SumPageSizesAsync (
        string[] pages
    ) cil managed flags(2000)
{
    // Code size: 82 (0x52)
    .locals init (
        [0] int32,
        [1] string[],
        [2] int32,
        [3] string,
        [4] valuetype YieldAwaitable/YieldAwaiter,
        [5] valuetype YieldAwaitable
    )

    IL_000b: call class Task`1<string> PageLoader::LoadAsync(string)
    IL_0010: call !!0 AsyncHelpers::Await<string>(class Task`1<!!0>)
    ...
    IL_003b: call instance bool YieldAwaitable/YieldAwaiter::get_IsCompleted()
    IL_0040: brtrue.s IL_0049
    IL_0042: ldloc.s 4
    IL_0044: call void AsyncHelpers::UnsafeAwaitAwaiter<YieldAwaiter>(!!0)
    IL_0049: ldloca.s 4
    IL_004b: call instance void YieldAwaitable/YieldAwaiter::GetResult()
    IL_0050: ldloc.0
    IL_0051: ret
}

Four things to notice.

flags(2000) on the method signature. That is MethodImplOptions.Async, and it is the entire new metadata surface: one flag telling the runtime this method may suspend.

The body is the original code. A loop, an accumulator, ordinary locals. No <>1__state, no switch, no hoisted fields, no MoveNext. total is [0], an actual local, because the runtime preserves locals across a suspension rather than the compiler needing to promote them to fields.

AsyncHelpers.Await replaces the whole save-state-and-register-a-continuation dance. For a Task<T> the compiler emits one call. For an arbitrary awaiter it still checks IsCompleted first and only calls AsyncHelpers.UnsafeAwaitAwaiter when it has to, which preserves the fast path.

The size. 82 bytes of IL against 477 for the MoveNext alone, plus the state machine type, plus the stub. Across the whole assembly, fourteen types became three.

#Cleaner stack traces

The most immediately visible change. The sample prints a live stack trace three async frames deep.

Without runtime async:

   at RuntimeAsyncSample.PageLoader.InnerAsync()
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
   at RuntimeAsyncSample.PageLoader.InnerAsync()
   at RuntimeAsyncSample.PageLoader.MiddleAsync()
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
   at RuntimeAsyncSample.PageLoader.MiddleAsync()
   at RuntimeAsyncSample.PageLoader.OuterAsync()
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
   at RuntimeAsyncSample.PageLoader.OuterAsync()
   at RuntimeAsyncSample.PageLoader.PrintLiveStackTraceAsync()
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
   ...

With it:

   at RuntimeAsyncSample.PageLoader.InnerAsync()
   at RuntimeAsyncSample.PageLoader.MiddleAsync()
   at RuntimeAsyncSample.PageLoader.OuterAsync()
   at RuntimeAsyncSample.PageLoader.PrintLiveStackTraceAsync()
   at Program.<Main>$(String[] args)

Each method appears once, in the order you would expect. Every AsyncMethodBuilderCore.Start frame and every duplicated method name is gone, because there is no builder starting a state machine: the method is simply on the stack.

This matters for anything that reads the live stack: profilers, the debugger's call stack window, and diagnostic logging that captures a stack trace.

##After a suspension

The trace above is captured before anything actually suspends: every await completes synchronously, so the whole chain is still physically on the stack. The sample also prints one after an await Task.Delay(1) in the innermost method, once it has resumed on a thread pool thread.

Without runtime async:

   at RuntimeAsyncSample.PageLoader.InnerSuspendedAsync()
   at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecutionContextCallback(Object s)
   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
   at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread, Flags flags)
   at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread)
   at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext()
   at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(IAsyncStateMachineBox box, Boolean allowInlining)
   at System.Threading.Tasks.Task.RunContinuations(Object continuationObject)
   at System.Threading.Tasks.Task.TrySetResult()
   at System.Threading.Tasks.Task.DelayPromise.CompleteTimedOut()
   at System.Threading.TimerQueueTimer.Fire(Boolean isThreadPool)
   ...

With it:

   at RuntimeAsyncSample.PageLoader.InnerSuspendedAsync()
   at System.Threading.Tasks.Task.RunContinuations(Object continuationObject)
   at System.Threading.Tasks.Task.TrySetResult()
   at System.Threading.Tasks.Task.DelayPromise.CompleteTimedOut()
   at System.Threading.TimerQueueTimer.Fire(Boolean isThreadPool)
   ...

The six state machine and ExecutionContext frames between the resumed method and whatever completed the task are gone. The callers are gone too, in both versions. MiddleSuspendedAsync and OuterSuspendedAsync are not on the stack, because nothing called InnerSuspendedAsync on this thread: the timer completed a task and the runtime resumed the method. Runtime async changes how a method is resumed, not the fact that a suspended method's callers have already returned. To find out who is waiting on a resumed method, you still need tooling that follows the chain of continuations, such as the debugger's parallel stacks window.

##In the debugger

The same simplification applies to stepping. The method you step through is the method you wrote, not a MoveNext with a switch at the top, so breakpoints bind inside runtime async methods and stepping over an await does not land in generated infrastructure. Locals are ordinary locals too, instead of fields named <total>5__2 on a state machine.

#How it works

The runtime async specification is deliberately vague about the mechanism, and that is the point: the compiler declares where a method may suspend, and the runtime decides how.

Runtime async in .NET 11

Compiler rewrite (.NET 10)

Compiler generates a state machine type

Locals become fields

Body becomes a switch in MoveNext

Builder boxes and schedules

Compiler marks the method Async

Body stays as written

Awaits become AsyncHelpers.Await

Runtime preserves the frame and resumes it

Runtime async in .NET 11

Compiler rewrite (.NET 10)

Compiler generates a state machine type

Locals become fields

Body becomes a switch in MoveNext

Builder boxes and schedules

Compiler marks the method Async

Body stays as written

Awaits become AsyncHelpers.Await

Runtime preserves the frame and resumes it

Because the runtime owns suspension, it can do things a compiler cannot: reuse continuation objects across calls, skip saving locals that have not changed, and let the JIT inline an async method whose awaits all complete synchronously. Those optimisations are measured in the next post.

#What to take away

  • .NET 11 moves suspension from the compiler into the runtime. Opt in with <Features>runtime-async=on</Features>.
  • The runtime libraries already ship compiled this way.
  • The generated IL is the method you wrote, plus a flags(2000) marker and calls to AsyncHelpers.Await. No state machine type, no MoveNext, no hoisted fields.
  • Locals stay locals, because the runtime preserves the frame.
  • Live stack traces show each async method once, with no builder frames. After a resume, the state machine frames disappear, but the callers are still not on the stack.
  • Breakpoints and stepping work on the method you wrote, not on a generated MoveNext.
  • It is still preview. Verify against the SDK you use.

The next post measures it: the same benchmarks from this series, run on .NET 10 and on .NET 11 with and without runtime async.

#Additional resources

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

Follow me:
Enjoy this blog?