proposals/csharp-10.0/async-method-builders.md
[!INCLUDESpecletdisclaimer]
Champion issue: https://github.com/dotnet/csharplang/issues/1407
Allow per-method override of the async method builder to use.
For some async methods we want to customize the invocation of Builder.Create() to use a different builder type.
[AsyncMethodBuilderAttribute(typeof(PoolingAsyncValueTaskMethodBuilder<>))] // new usage of AsyncMethodBuilderAttribute type
static async ValueTask<int> ExampleAsync() { ... }
Today, async method builders are tied to a given type used as a return type of an async method. For example, any method that's declared as async Task uses AsyncTaskMethodBuilder, and any method that's declared as async ValueTask<T> uses AsyncValueTaskMethodBuilder<T>. This is due to the [AsyncMethodBuilder(Type)] attribute on the type used as a return type, e.g. ValueTask<T> is attributed as [AsyncMethodBuilder(typeof(AsyncValueTaskMethodBuilder<>))]. This addresses the majority common case, but it leaves a few notable holes for advanced scenarios.
In .NET 5, an experimental feature was shipped that provides two modes in which AsyncValueTaskMethodBuilder and AsyncValueTaskMethodBuilder<T> operate. The on-by-default mode is the same as has been there since the functionality was introduced: when the state machine needs to be lifted to the heap, an object is allocated to store the state, and the async method returns a ValueTask{<T>} backed by a Task{<T>}. However, if an environment variable is set, all builders in the process switch to a mode where, instead, the ValueTask{<T>} instances are backed by reusable IValueTaskSource{<T>} implementations that are pooled. Each async method has its own pool with a fixed maximum number of instances allowed to be pooled, and as long as no more than that number are ever returned to the pool to be pooled at the same time, async ValueTask<{T}> methods effectively become free of any GC allocation overhead.
There are several problems with this experimental mode, however, which is both why a) it's off by default and b) we're likely to remove it in a future release unless very compelling new information emerges (https://github.com/dotnet/runtime/issues/13633).
ValueTask{<T>} if that ValueTask isn't being consumed according to spec. When it's backed by a Task, you can do with the ValueTask things you can do with a Task, like await it multiple times, await it concurrently, block waiting for it to complete, etc. But when it's backed by an arbitrary IValueTaskSource, such operations are prohibited, and automatically switching from the former to the latter can lead to bugs. With the switch at the process level and affecting all async ValueTask methods in the process, whether you control them or not, it's too big a hammer.async ValueTask methods in the process rather than being selective about the ones it would most benefit is too big a hammer.async ValueTask method saw for example an ~2K binary footprint increase in aot images due to this option, and, again, that applies to all async ValueTask methods in the whole application closure.On top of all of these issues with the existing pooling, it's also the case that developers are prevented from writing their own customized builders for types they don't own. If, for example, a developer wants to implement their own pooling support, they also have to introduce a brand new task-like type, rather than just being able to use {Value}Task{<T>}, because the attribute specifying the builder is only specifiable on the type declaration of the return type.
We need a way to have an individual async method opt-in to a specific builder.
In dotnet/runtime, add AttributeTargets.Method to the targets for System.Runtime.CompilerServices.AsyncMethodBuilderAttribute:
namespace System.Runtime.CompilerServices
{
/// <summary>
/// Indicates the type of the async method builder that should be used by a language compiler:
/// - to build the return type of an async method that is attributed,
/// - to build the attributed type when used as the return type of an async method.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface | AttributeTargets.Delegate | AttributeTargets.Enum, Inherited = false, AllowMultiple = false)]
public sealed class AsyncMethodBuilderAttribute : Attribute
{
/// <summary>Initializes the <see cref="AsyncMethodBuilderAttribute"/>.</summary>
/// <param name="builderType">The <see cref="Type"/> of the associated builder.</param>
public AsyncMethodBuilderAttribute(Type builderType) => BuilderType = builderType;
/// <summary>Gets the <see cref="Type"/> of the associated builder.</summary>
public Type BuilderType { get; }
}
}
This allows the attribute to be applied on methods or local functions or lambdas.
Example of usage on a method:
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] // new usage, referring to some custom builder type
static async ValueTask<int> ExampleAsync() { ... }
It is an error to apply the attribute multiple times on a given method.
It is an error to apply the attribute to a lambda with an implicit return type.
A developer who wants to use a specific custom builder for all of their methods can do so by putting the relevant attribute on each method.
When compiling an async method, the builder type is determined by:
AsyncMethodBuilder attribute if one is present,If an AsyncMethodBuilder attribute is present, we take the builder type specified by the attribute and construct it if necessary.
If the override type is an open generic type, take the single type argument of the async method's return type and substitute it into the override type.
If the override type is a bound generic type, then we produce an error.
If the async method's return type does not have a single type argument, then we produce an error.
We verify that the builder type is compatible with the return type of the async method:
Create method with no type parameters and no parameters on the constructed builder type.Task property.Task property (a task-like type):Note that it is not necessary for the return type of the method to be a task-like type.
The builder type determined above is used as part of the existing async method design.
For example, today if a method is defined as:
public async ValueTask<T> ExampleAsync() { ... }
the compiler will generate code akin to:
[AsyncStateMachine(typeof(<ExampleAsync>d__29))]
[CompilerGenerated]
static ValueTask<int> ExampleAsync()
{
<ExampleAsync>d__29 stateMachine;
stateMachine.<>t__builder = AsyncValueTaskMethodBuilder<int>.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
With this change, if the developer wrote:
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] // new usage, referring to some custom builder type
static async ValueTask<int> ExampleAsync() { ... }
it would instead be compiled to:
[AsyncStateMachine(typeof(<ExampleAsync>d__29))]
[CompilerGenerated]
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] // retained but not necessary anymore
static ValueTask<int> ExampleAsync()
{
<ExampleAsync>d__29 stateMachine;
stateMachine.<>t__builder = PoolingAsyncValueTaskMethodBuilder<int>.Create(); // <>t__builder now a different type
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
Just those small additions enable:
Task<T> and ValueTask<T>and with minimal surface area changes or feature work in the compiler.
Note that we need the emitted code to allow a different type being returned from Create method:
AsyncPooledBuilder _builder = AsyncPooledBuilderWithSize4.Create();
Note that this mechanism to change the the builder type cannot be used when the synthesized entry-point for top-level statements is async. An explicit entry-point should be used instead.
ValueTask was made extensible via the IValueTaskSource interface to avoid that need, however.