Skip to content

Latest commit

 

History

History
415 lines (307 loc) · 27.3 KB

feature - arbitrary async returns.md

File metadata and controls

415 lines (307 loc) · 27.3 KB

C# feature proposal: arbitrary async returns

In C#6, async methods must return either void or Task or Task<T>. This proposed feature allows them to return any tasklike type.

  • TRY IT OUT ONLINE: tryroslyn.azurewebsites.net
  • Download: ArbitraryAsyncReturns.zip [22mb]
  • Install in VS2015: From the zip-file, double-click on RoslynDeployment.VSIX
  • Install in .NETCore (OSX/Linux/Windows): From the zip-file, copy the DLLs into (OSX) /usr/local/share/dotnet/shared/Microsoft.NETCore.App/1.0.0-rc2-3002702 or (Windows) C:\Program Files\dotnet\shared\Microsoft.NETCore.App\1.0.0-rc2-3002702
  • Test: In the zip-file there are sample projects for VS2015 and for .NETCore.
  • Uninstall: For VS2015 go to Tools>Extensions, search for Roslyn, and uninstall the preview. For .NETCore you're on your own!
  • Watch: I coded this prototype live in livecoding.tv, and you can watch recordings if you want: livecoding.tv/ljw1004
  • Discuss: please read the [Design rationale and alternatives](feature - arbitrary async returns - discussion.md), and then go to the discussion thread

The primary benefit is to allow a ValueTask<T> that reduces the "async overhead cost" on the hot path: ![perf](feature - arbitrary async returns.png)

  • Such a ValueTask<T> has already been checked into corefx (corefx#4857)
  • Some teams decide the perf benefits of ValueTask<T> are so great that they're worth the cumbersome manual code you have to write today (rightmost column) -- e.g. System.Xml (corefx#4936), which had previously been using an internal form of ValueTask.
  • Other teams decide that the cumbersome code is so ugly they're willing to forego the perf benefits for now, at least until the C# compiler gets support for ValueTask<T> -- e.g. ASP.NET FormReader (aspnet#556). Using ValueTask<T> would have saved 10% of heap allocations for 3k requests, up to 90% for 30k requests.

(This feature proposal merely allows the middle column, to remove the necessity of heap allocation and to improve perf somewhat. Could we build upon this feature proposal to allow the rightmost column, with additional compiler unrolling to achieve optimum perf? It seems hard because the unrolled version has different semantics aroud context-capture, and the incremental perf wins seem comparitively minor. See issue#10449.)

The secondary benefit is to allow async methods to return other domain-specific tasklike values. Here are some examples. However, they won't be allowed with the current proposal until such time as "extension static methods" get added to C#.

// IAsyncAction in Windows programming. Today you write wrapper code like this:
//    IAsyncAction TestAsync(int p, int q) => TestAsyncInner(p, q).AsAsyncAction();
//    async Task TestAsyncInner(int p, int q) { ... }
// It would be neater if you could just return directly:
async IAsyncAction TestAsync(int p, int q) { ... }

// ITask<out T>. The Task that we're forced to use today is invariant. Sometimes you want covariance:
async ITask<string> TestAsync() { ... }
ITask<object> = TestAsync();

// IObservable. Today you write wrapper code like this:
//    IObservable<int> TestAsync(int p, int q) => TestAsyncInner(p,q).ToObservable();
//    async Task<int> TestAsyncInner(int p, int q) { ... }
// It would be neater if you could just return directly:
async IObservable<int> TestAsync(int p, int q) { ... }

Actually that IObservable example is still up for discussion. It's not clear whether IObservable would prefer to be like an async method or an async enumerable method. We need to hear from IObservable experts.

A third "benefit" (it's arguable whether this is a benefit at all) is that people will be able to weave hooks into their async methods, for instance to call into their own function before and after every cold-path await:

async InstrumentedTask TestAsync()
{
    await InstrumentedTask.Configure(actionBeforeEachAwait, actionAfterEachAwait);
    await Task.Delay(10);
	await Task.Delay(20);
}

Some other examples: implement a C# version of Haskell's Maybe monad with do notation [link]; implement async iterator methods [link].

Key uses cases

These use-cases are written out as unit-tests at the end of this proposal. Here they are in a nutshell, in order of importance:

  1. I should be able to use ValueTask as a wholesale replacement for Task, every bit as good. [link]
  2. I should be able to migrate my existing API over to ValueTask, maintaining source-compatibility and binary-compatibility. [link]
  3. I don't want to break backwards-compatibility. In particular, suppose I have a C#6 app that references a NuGet library in which ValueTask is already tasklike. When I upgrade to C#7, I don't want the behavior of my code to change. [link]

Proposal

Rule 1: Tasklike. Define:

  • A non-generic tasklike is any non-generic type with a single public static method CreateAsyncMethodBuilder(), or the type System.Threading.Tasks.Task.
  • A generic tasklike is any generic type with arity 1 with the same method, or the type System.Threading.Tasks.Task<T>.
struct ValueTask<T>
{
   [EditorBrowsable(EditorBrowsableState.Never)]
   public static ValueTaskBuilder<T> CreateAsyncMethodBuilder() { ... }
   ...
}

This uses a static method CreateAsyncMethodBuilder() to distinguish a Tasklike type and locate its builder. I've also suggested some other techniques here: (1) static method, (2) extension method, (3) attribute on tasklike type, (4) attribute on async method. I believe a static method is best, and when C# eventually gets extension static methods then it should use them too.

Rule 2: async methods. The rules for async functions currently allow an async method to return either void or Task or Task<T>; this will be changed to allow it to return either void, or any non-generic Tasklike, or generic Tasklike<T>.

async ValueTask<int> TaskAsync(int delay)
{
   await Task.Delay(delay);
   return 10;
}

Rule 3: async lambdas. The rules for anonymous function conversion currently allow an async lambda to be converted to a delegate type whose return type is either void or Task or Task<T>; this will be changed to let them its return type be void or any non-generic Tasklike or any generic Tasklike<T>. (Note that any new conversions introduce ambiguity errors; see the discussion on this matter).

Func<int, ValueTask<int>> lambda = async (x) => { return x; };

Rule 4: inferred return type. The inferred return type of a lambda expression currently takes into account the parameter types of the delegate to which the lambda is being converted. To make type inference aware of the new conversion in Rule 3, the inferred return type of an async lambda now also takes into account the return type of that delegate:

  • If the async lambda has inferred result type void:
    • if the return type of the delegate is U where U is a non-generic tasklike, then the inferred return type is U
    • otherwise the inferred return type is Task
  • Otherwise, the async lambda has inferred result type V1:
    • if the return type of the delegate is U<V2> where U is a generic tasklike, then the inferred return type is U<V1>
    • otherwise the inferred return type is Task<V1>
f(async (x) => {return x;})
void f<T>(Func<int,T> lambda);            // inferred lambda return type is Task<int>, giving T = Task<int>
void f<U>(Func<int,Task<U>> lambda);      // inferred lambda return type is Task<int>, giving U = int
void f<U>(Func<int,ValueTask<U>> lambda); // currently: inferred lambda return type is Task<int>, giving a type inference failure
void f<U>(Func<int,ValueTask<U>> lambda); // proposal:  inferred lambda return type is ValueTask<int>, giving U = int

Rule 5: overload resolution. This is subtle. It's dealt with below.

Rule 6: evaluation of async functions. The rules for evaluation of task-returning async functions currently talk in general terms about "generating an instance of the returned task type" and "initially incomplete state" and "moved out of the incomplete state". These will be changed to spell out how that returned tasklike is constructed and how its state is transitioned, as detailed in the following subsection.

struct ValueTaskBuilder<T>
{
    public static MyTaskBuilder<T> Create();
    public void SetStateMachine(IAsyncStateMachine stateMachine);
    public void Start<TSM>(ref TSM stateMachine) where TSM:IAsyncStateMachine;
    public void AwaitOnCompleted<TA, TSM>(ref TA awaiter, ref TSM stateMachine) where TA:INotifyCompletion where TSM:IAsyncStateMachine;
    public void AwaitUnsafeOnCompleted<TA, TSM>(ref TA awaiter, ref TSM stateMachine) where TA:ICriticalNotifyCompletion where TSM:IAsyncStateMachine;
    public void SetResult(T result);
    public void SetException(Exception ex);
    public ValueTask<T> Task {get;}
}

Semantics for execution of an async method

The builder type of a tasklike is the return type of the static method CreateAsyncMethodBuilder() on that tasklike. (Except: if the tasklike is System.Threading.Tasks.Task then the builder type is System.Runtime.CompilerService.AsyncTaskMethodBuilder; and if the tasklike is System.Threading.Tasks.Task<T> then the builder type is System.Runtime.CompilerService.AsyncTaskMethodBuilder<T>).

When an async tasklike-returning method is invoked,

  • It creates var sm = new CompilerGeneratedStateMachineType() where this compiler-generated state machine type represents the async tasklike method, and may be a struct or a class, and has a field BuilderType builder in it, and implements IAsyncStateMachine.
  • It assigns sm.builder = Tasklike.CreateAsyncMethodBuilder() to create a new instance of the builder type. (Except: if the tasklike is Task or Task<T>, then the assignment is instead sm.builder = BuilderType.Create().)
  • It then calls the void Start<TSM>(ref TSM sm) where TSM : IAsyncStateMachine method on builder. It is an error if this instance method doesn't exist or isn't public or has a different signature or constraints. The sm variable is that same sm as was constructed earlier. Upon being given this sm variable, the builder must invoke sm.MoveNext() on it exactly once, either now in the Start method or in the future.
  • It then retrieves the U Task {get;} property on sm.builder. The value of this property is then returned from the async method. It is an error if this instance property doesn't exist or isn't public or if its property type U isn't identical to the return type of the async tasklike-returning method.

Execution of sm.MoveNext() might cause other builder methods to be invoked:

  • If the async method completes succesfully, it invokes the method void SetResult() on sm.builder (in case of a non-generic tasklike), or the void SetResult(T result) method with the operand of the return statement (in case of a generic tasklike). It is an error if this instance method doesn't exist or isn't public.
  • If the async method fails with an exception, it invokes the method void SetException(System.Exception ex) on sm.builder. It is an error if this instance method doesn't exist or isn't public.
  • If the async method executes an await e operation, it invokes var awaiter = e.GetAwaiter().
    • If this awaiter implements ICriticalNotifyCompletion and the IsCompleted property is false, then it calls the method void AwaitUnsafeOnCompleted<TA,TSM>(ref TA awaiter, ref TSM sm) where TA : ICriticalNotifyCompletion where TSM : IAsyncStateMachine on sm.builder. It is an error if this instance method doesn't exist or isn't public or has the wrong constraints. The builder is expected to call awaiter.UnsafeOnCompleted(action) with some action that will cause sm.MoveNext() to be invoked once; or, instead, the builder may call sm.MoveNext() once itself.
    • If this awaiter implements INotifyCompletion and the IsCompleted property is false, then it calls the method void AwaitOnCompleted<TA,TSM>(ref TA awaiter, ref TSM sm) where TA : INotifyCompletion where TSM : IAsyncStateMachine on sm.builder. It is an error if this instance method doesn't exist or isn't public or has the wrong constraints. Again the builder is expected to call awaiter.OnCompleted(action) similarly, or call sm.MoveNext() itself.

In the case where the builder type is a struct, and sm is also a struct, it's important to consider what happens should the builder decide to box the struct, e.g. by doing IAsyncStateMachine boxed_sm = sm. This will always create a new copy of sm, which will in turn contain a new copy of sm.builder.

  • The builder is at liberty anytime to call boxed_sm.SetStateMachine(boxed_sm). The implementation of this method is compiler-generated, but its only effect is to invoke the void SetStateMachine(IAsyncStateMachine boxed_sm) method on boxed_sm.builder. It is an error if this instance method on the builder doesn't exist or isn't public.
  • This mechanism is typically used by struct builder types so they can box only once the sm parameter they receive in their Start or AwaitOnCompleted or AwaitUnsafeOnCompleted methods; on subsequent calls, they ignore that parameter and used the version they have already boxed.

Overload resolution

Here informally are the proposed changes to overload resolution. I've written out a digest of the existing rules of overload resolution, and added in empasis the new parts from this proposal.

  1. If the arguments exactly match one candidate, it wins. An async lambda async () => 3 is considered an exact match for a delegate with return type Task<int> and any other Tasklike<int>; it's never an exact match for a void-returning delegate.
  2. Otherwise [Better conversion target], if neither or both are an exact match and there's an implicit conversion from one parameter type but not vice versa, then the "from" parameter wins. Otherwise recursively dig in: if both types are delegates then dig into their return types and prefer non-void over void; if the types are TasklikeA<S1> and TasklikeB<S2> then dig into S1/S2.
  3. Otherwise [Better function member], if the two candidates have identical parameter types up to all tasklikes being considered the same but one candidate before substitution is more specific then prefer it.

Unit tests

Note that these unit tests are aspirational for the language feature. Not all of them are satisfied by the current proposal. Indeed, some of them are mutually contradictory!

I should be able to use ValueTask as a wholesale replacement for Task, every bit as good.

TEST a1: async methods should be able to return ValueTask

async Task<int> a1() { ... }         //  <-- I can write this in C#6 using Task
async ValueTask<int> a1() { ... }    //  <-- I should be able to write this instead with the same method body

async Task a1() { ... }              //  <-- I can write this in C#6 using Task
async ValueTask a1() { ... }         //  <-- I should be able to write this instead with the same method body

TEST a2: async lambdas should be able to return ValueTask

Func<Task<int>> a2 = async () => { ... };       //  <-- I can write this in C#6
Func<ValueTask<int>> a2 = async () => { ... };  //  <-- I should be able to write this instead

Func<Task> a2n = async () => { ... };           //  <-- I can write this in C#6
Func<ValueTask> a2n = async () => { ... };      //  <-- I should be able to write this instead

TEST a3: async lambdas are applicable in overload resolution

a3(async () => 3);
void a3(Func<Task<int>> lambda)       //  <-- This can be invoked in C#6
void a3(Func<ValueTask<int>> lambda)  //  <-- If I write this instead, it should be invocable

a3n(async () => {} );
void a3n(Func<Task> lambda)           //  <-- This can be invoked in C#6
void a3n(Func<ValueTask> lambda)      //  <-- If I write this instead, it should be invocable

TEST a4: async lambda type inference should work with ValueTask like it does with Task

a4(async () => 3);
void a4<T>(Func<Task<T>> lambda)       //  <-- This infers T=int
void a4<T>(Func<ValueTask<T>> lambda)  //  <-- If I write this instead, it should also infer T=int

TEST a5: able to write overloads that take sync and async lambdas

void a5<T>(Func<T> lambda)
void a5<T>(Func<ValueTask<T>> lambda)
a5(() => 3);                             //  <-- This should invoke the first overload
a5(async () => 3);                       //  <-- This should invoke the second overload

TEST a6: exact match better candidate

void a6(Func<ValueTask<int>> lambda)
void a6(Func<ValueTask<double>> lambda)
a6(async () => 3);                       //  <-- This should prefer the "int" candidate

TEST a7: dig into better candidate

void a7(Func<ValueTask<short>> lambda)
void a7(Func<ValueTask<byte>> lambda)
a7(async () => 3);                       //  <-- This should prefer the "byte" candidate

TEST a8: prefer over void

void a8(Action lambda)
void a8(Func<ValueTask> lambda)
a8(async () => {});                       // <-- This should prefer the "ValueTask" candidate

I should be able to migrate my existing API over to ValueTask

As I migrate, I want to maintaining source-compatibility and binary-compatibility for users of my API. And the reason I'm migrating is because I want the better performance of ValueTask, so I want users to get that as easily as possible.

TEST b1: change async return type to be ValueTask

async Task<int> b1()         //  <-- library v1 has this API
async ValueTask<int> b1()    //  <-- library v2 has this API *instead*
var v = await b1();          //  <-- This code will of course work in v2 of the library
Task<int> t = b1();          //  <-- Either this code should work in v2 of the library...
Task<int> t = b1().AsTask(); //  <-- or this one as a workaround

async Task b1n()             //  <-- library v1 has this API
async ValueTask b1n()        //  <-- library v2 has this API *instead*
await b1n();                 //  <-- This code will of course work in v2 of the library
Task t= b1n();               //  <-- Either this could should work in v2 of the library...
Task t = b1n().AsTask();     //  <-- or this one as a workaround

TEST b2: add overload where async return type is ValueTask. [this test is doomed to fail]

async Task<int> b2()       //  <-- library v1 has this API
async ValueTask<int> b2()  //  <-- library v2 has this API *additionally*
var t = b2();              //  <-- This code should work on either version of the library

async Task b2n()           //  <-- library v1 has this API
async ValueTask b2n()      //  <-- library v2 has this API *additionally*
var t = b2n();             //  <-- This code should work on either version of the library

TEST b3: change argument to ValueTask

void b3(Task<int> t)       //  <-- library has this API
ValueTask<int> vt;
b3(vt);                    //  <-- This code should work...
b3(vt.AsTask());           //  <-- or, if not, then at least this one should

void b3n(Task t)           //  <-- library has this API
ValueTask vt;
b3n(vt)                    //  <-- This code should work...
b3n(vt.AsTask());          //  <-- or, if not, then at least this one should

TEST b4: change parameter to ValueTask

void b4(Task<int> t)         //  <-- library v1 has this API
void b4(ValueTask<int> t)    //  <-- library v2 has this API *instead*
Task<int> t;
b4(t);                       //  <-- Either this code should work in v2 of the library...
b4(t.AsValueTask());         //  <-- or this one as a workaround

void b4n(Task t)             //  <-- library v1 has this API
void b4n(ValueTask t)        //  <-- library v2 has this API instead
Task t;
b4n(t);                      //  <-- Either this code should work in v2 of the library...
b4n(t.AsValueTask());        //  <-- or this one as a workaround

TEST b5: add overload with parameter ValueTask

void b5(Task<int> t)       //  <-- library v1 has this API
void b5(ValueTask<int> t)  //  <-- library v2 has this API *additionally*
Task<int> t;
b5(t);                     //  <-- This could should work in v2 of the library and pick the Task overload
ValueTask<int> vt;
b5(vt);                    //  <-- This code should work in v2 of the library and pick ValueTask overload

void b5n(Task t)           //  <-- library v1 has this API
void b5n(ValueTask t)      //  <-- library v2 has this API *additionally*
Task t;
b5n(t);                    //  <-- This code should work in v2 of the library and pick the Task overload
ValueTask vt;
b5n(vt);                   //  <-- This code should work in v2 of the library and pick ValueTask overload

TEST b6: change parameter to Func<ValueTask<T>>

void b6(Func<Task<int>> lambda)      //  <-- library v1 has this API
void b6(Func<ValueTask<int>> lambda) //  <-- library v2 has this API *instead*
b6(async () => 3);                   //  <-- This code should work in v2 of the library

void b6n(Func<Task> lambda)          //  <-- library v1 has this API
void b6n(Func<ValueTask> lambda)     //  <-- library v2 has this API *instead*
b6n(async () => {});                 //  <-- This code should work in v2 of the library

TEST b7: add overload with parameter Func<ValueTask<T>>.

void b7(Func<Task<int>> lambda)        //  <-- library v1 has this API
void b7(Func<ValueTask<int>> lambda)   //  <-- library v2 has this API *additionally*
b7(async () => 3);                     //  <-- This code should work in v2 and pick the ValueTask overload, for efficiency

void b7g<T>(Func<Task<T>> lambda)      //  <-- library v1 has this API
void b7g<T>(Func<ValueTask<T>> lambda) //  <-- library v2 has this API *additionally*
b7g(async () => 3);                    //  <-- This code should work in v2 and pick the ValueTask overload, for efficiency

void b7n(Func<Task> lambda)            //  <-- library v1 has this API
void b7n(Func<ValueTask> lambda)       //  <-- library v2 has this API *additionally*
b7n(async () => {});                   //  <-- This code should work in v2 and pick the ValueTask overload, for efficiency

I don't want to break backwards-compatibility.

In particular, suppose I have a C#6 app that references a NuGet library in which ValueTask is already tasklike. When I upgrade my project to C#7, I don't want the behavior of my code to change.

TEST c1: don't now prefer a previously-inapplicable ValueTask due to exact match. [This test fails under the current proposal]

void c1(Func<Task<double>> lambda)
void c1(Func<ValueTask<int>> lambda)
c1(async () => 3);                    //  <-- When I upgrade, this should still pick the Task<double> overload

TEST c2: don't now prefer a previously-inapplicable ValueTask due to digging in. [This test fails under the current proposal]

void c2(Func<Task<short>> lambda)
void c2(Func<ValueTask<byte>> lambda)
c2(async () => 3);                    //  <-- When I upgrade, this should still pick the Task overload

TEST c3: don't introduce ambiguity errors about newly applicable candidates [conflicts with Test b7].

void c3(Func<Task<int>> lambda)
void c3(Func<ValueTask<int>> lambda)
c3(async () => 3);                     //  <-- When I upgrade, this should still pick the Task overload

void c3g<T>(Func<Task<T>> lambda)
void c3g<T>(Func<ValueTask<T>> lambda)
c3g(async () => 3);                    //  <-- When I upgrae, this should still pick the Task overload

void c3n(Func<Task> lambda)
void c3n(Func<ValueTask> lambda)
c3n(async () => {});                   //  <-- when I upgrade, this should still pick the Task overload

TEST c4: don't now prefer a previously-inapplicable ValueTask due to tie-breakers [conflicts with Test a5]. [This test fails under the current proposal]

void c4<T>(Func<T> lambda)
void c4<T>(Func<ValueTask<T>> lambda)
c4(async () => 3);                     //  <-- When I upgrade, this should still pick the "T" overload

void c4n(Action lambda)
void c4n(Func<ValueTask> lambda)
c4n(async () => {});                   //  <-- When I upgrade, this should still pick the Action overload

TEST c5: don't now prefer a previously-inapplicable ValueTask due to more specific. [This test fails under the current proposal]

void c5<T>(Func<Task<T>> lambda)
void c5(Func<ValueTask<int>> lambda)
c5(async () => 3);                    //  <-- When I upgrade, this should still pick the "Task<T>" overload

Design rationale and alternatives

For explanation of why the proposal is this way, and to see alternatives, please read the [Design rationale and alternatives](feature - arbitrary async returns - discussion.md).