async2 - The .NET Runtime Async experiment concludes

8/12/2024

The .NET team has been working on a new experiment called async2, which is a new implementation of the async/await pattern that is designed to be more efficient and more flexible than the current implementation. It started with green threads and ended with an experiment that moves async and await to the runtime. This post will cover the journey of async2 and the conclusion of the experiment.

Where all began - Green threads

Let's start here: "Green Thread Experiment Results". The team invested in an experiment to evaluate the feasibility of using green threads in the .NET runtime. But wait a second, what are green threads?

Green threads

Green threads are user-space threads that are managed by a runtime library or a virtual machine (VM) instead of the operating system.Source: Wikipedia They are lightweight and can be created and managed more quickly than kernel threads. Green threads are also known as "coroutines" or "fibers" in other programming languages. The idea is that you, as a developer, don't have to worry about threads.

Currently, with threads and to some extend with async/await, a new stack is created. You can easily see that in your favourite IDE, if you debug:

stack

Green threads are different. The memory of a green thread is allocated on the heap. But all of this comes with a cost: As they aren't managed by the OS, they can't take advantage of multiple cores inherently. But for I/O-bound operations, they are a good fit.

Abandoning green threads

The key-challenges were (which lead to the abandonment of the green threads experiment):

  • Complex interaction between green threads and existing async model
  • Interop with native code was complex and slower than using regular threads
  • Compatibility issues with security mitigations like shadow stacks
  • Uncertainty about whether it would be possible to make green threads faster than async in important scenarios, given the effort required for improvement.

This lead to the conclusion that green threads are not the right way to go for the .NET runtime and gave birth to the async2 experiment. From here on out, I will keep the term async2 for the experiment, as it is the codename for the experiment.

async2 - The .NET Runtime Async experiment

Now, async2 is obviously only a codename. The goal of the experiment was to move async and await to the runtime. The main motivation behind this was to make async more efficient and more flexible. As async is already used as an identifier in C#, the team decided to use async2 as a codename for the experiment. If that thing ever makes it into the runtime, it will be called async - so it will be a replacement for the current async implementation. But let's start at the beginning.

async is a compiler feature

I talked about this from time to time in my blog posts. For example:

The current implementation of async and await is a compiler feature. The compiler generates a state machine for the async method. The runtime doesn't know anything about async and await. There is no trace of an async-like keyword in IL or in the JIT-compiled code. And that is where the experiment started.

Starting point is this nice GitHub issue: ".NET 9 Runtime Async Experiment", which basically describes the whole experiment in more detail with an ongoing discussion from the community.

async is a runtime feature

The goal of the experiment was to move async and await to the runtime. This would allow the runtime to have more control over the pattern itself. With that there would be also some different semantics:

async2 and ExecutionContext and SynchronizationContext

async2 would not have save or restore of SynchronizationContext and ExecutionContext at function boundaries, instead allowing callers to observe changes. With the ExecutionContext, this would shift a big change in how AsyncLocal behaves.

Today, AsyncLocal is used to store data that flows with the logical call context. It gets copied to the new context. That said, if a function deep down the call stack changes the value of an AsyncLocal, the caller will not see the updated value, only functions further down the logical async flow. Here an example:

await new AsyncLocalTest().DoOuter();

public class AsyncLocalTest
{
    private readonly AsyncLocal<string> _asyncLocal = new();

    public async Task DoOuter()
    {
        _asyncLocal.Value = "Outer";
        Console.WriteLine($"DoOuter: {_asyncLocal.Value}");
        await DoInner();
        Console.WriteLine($"DoOuter: {_asyncLocal.Value}");
    }

    private async Task DoInner()
    {
        _asyncLocal.Value = "Inner";
        Console.WriteLine($"DoInner: {_asyncLocal.Value}");
        await Task.Yield();
        Console.WriteLine($"DoInner: {_asyncLocal.Value}");
    }
}

The output of this code is:

DoOuter: Outer
DoInner: Inner
DoInner: Inner
DoOuter: Outer

With async2 those changes are not "reverted" which would lead to a different output:

DoOuter: Outer
DoInner: Inner
DoInner: Inner
DoOuter: Inner

Comparison with the current implementation and some results

The whole document, that describes all the details, can be found here: https://github.com/dotnet/runtimelab/blob/feature/async2-experiment/docs/design/features/runtime-handled-tasks.md

The team found out that the approach of putting async into the JIT might yield the best results overall. Here a basic overview:

Feature async async2
Performance Generally slower than async2, especially for deep call stacks Generally faster than async, with performance comparable to synchronous code in non-suspended scenarios
Exception Handling Slow and inefficient, causing GC pauses and impacting responsive performance of applications Improved EH handling, reducing the impact on application responsiveness
Stack Depth Limitation Limited by stack depth, which can cause issues for deep call stacks No explicit limitations on stack depth, allowing async2 to handle deeper call stacks more efficiently
Memory Consumption Generally lower than async2, especially in scenarios with many suspended tasks Higher memory consumption due to capturing entire stack frames and registers, but still acceptable compared to other factors like pause times

Where do we go from here?

As the name suggests, this is just an experiment, that may lead to a replacement of async in some years. Yes, years. It might take a while until this is production-ready. And for the transition phase, there has to be interop for asyncasync2. Anyway - a very good starting point and I am looking forward to the future of async2.

The state machine in C# with async/await

You often here that the async/await keywords leads to a state machine. But what does that mean? Let's discuss this with a simple example.

ASP.NET Core - Why async await is useful

Did you ever wonder why you "should" use async and await in your ASP.NET Core applications? Most probably, you heard something about performance. And there is some truth to it, but not in the way you might think.

So let's discuss this with smaller examples.

Async Await Pitfalls / Guidelines - .NET User Group Zurich Presentation

On 6th of July I had the honor to present some topics about async/await. Mainly:

  • What is asynchronous programming
  • Deadlocks and ConfigureAwait
  • How does the state machine work
  • Pitfalls and general Guidelines
  • ValueTask

You'll find all the slides and the whole talk in the blog.

An error has occurred. This application may no longer respond until reloaded. Reload x