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:
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 async
↔ async2
. Anyway - a very good starting point and I am looking forward to the future of async2
.