Partager via


Why must async methods return Task?

We all know that async methods return Task or Task(Of T):

    Async Function GetNameAsync() As Task(Of String)
        Await Task.Delay(10)
        Return "ernest"
    End Function
 

Sometimes, advanced users ask for the ability to return different types out of an async method. That’s disallowed: it gives the error message “The Async modifier can only be used on Subs, or on Functions that return Task or Task(Of T) ”.

    Async Function GetNameAsync() As ITask(Of String)
        Await Task.Delay(10)
        Return "ernest"
    End Function
 

In this post I’ll explain why it’s disallowed.

 

It wasn’t always disallowed. Our first internal prototypes of the async feature allowed you to return any suitable type from an async method, and I’ll explain how. We did some powerful things with it. But in the end there were solid language-design reasons for disallowing it, and I’ll explain those.

 

Motives for flexible async return types

Why would you ever want to return something other than Task(Of T) from an async method? Here are some (very legitimate) reasons.

  1. The Task type is a reference type, which means I have to pay the cost of at least one heap allocation from every async method. I want to return a structure type to avoid that cost, and reduce the need for garbage collection.
    Let’s call such a putative return type “TaskLite(Of T)”
     
  2. I want to return IAsyncOperation(Of T) directly from my async methods.
    This request comes from people writing WinRT components. It’s a special case of the next request...
     
  3. My library/framework/app uses its own different Task-like type because Task wasn’t suitable for whatever reason. I want to be able to return my own Task-like type from async methods.
     
  4. I want an interface type ITask(Of T) instead of Task(Of T). That would be a better object-oriented design. ITask(Of T) wouldn’t have any of the blocking synchronous members like Wait() or Result. It would also allow me to provide my own implementations of ITask(Of T).  

Early prototype of async had flexible return types

Our first internal prototype of async allowed for arbitrary return types. Here’s how it worked:

    Async Function GetNameAsync() As Taskoid
        Await Task.Delay(10)
        Return "ernest"
    End Function

 

(1) The compiler needs to know, from the return type “Taskoid”, how to get a builder for it. We wanted to allow extension methods, so we did it with a “fake static method” like this:

    Dim builder = CType(Nothing, Taskoid).GetBuilder()
 

(2) The compiler turns each async method into a compiler-generated stub, plus a compiler-generated state-machine class. Here’s the stub. Observe that it’s the builder who’s completely responsible for figuring out how to produce the ultimate task-like thing that’s returned from the method:

    Function GetNameAsync() As Taskoid
        Dim sm As New GetNameAsync$SM
        sm.builder = CType(Nothing, Taskoid).GetBuilder()
        Return sm.builder.Build(sm)
    End Function
 

(3) The state machine has a MoveNext method that embodies the user’s original async method body. The way it implements “Await” or “Return” is simply by asking the builder to handle them. Once again, it’s the builder who’s completely responsible for figuring out how the flow of execution around these operators:

    Public Sub MoveNext() Implements IAsyncStateMachine.MoveNext
        Try
            SelectCase iState
                Case 0
                    ' await Task.Delay(10)
                    Awaiter1 = Task.Delay(10).GetAwaiter()
                    iState = 1 : builder.Await(Awaiter1) : Return
                Case 1
                    ' return "ernest"
                    iState = 2 : builder.Return("ernest") : Return
            End Select
        Catch ex As Exception
            builder.Throw(ex)
        End Try
    End Sub
 

(4) Actually, we also asked the builder to handle the “Yield” statement as well. I’m not going to write out the details of how we implemented various different builders – they consist of small clever tricks within a sea of boilerplate code. What was exciting was all the powerful things we could do with them:

    ' Iterators
    Async Function GetNodes() As IEnumerable(Of Integer)

    ' Async iterators
Async Function GetNodesAsync() As IAsyncEnumerable(Of Integer)

    ' WinRT operations, using Yield to report progress
    Async Function GetNodesAsync() As IAsyncOperationWithProgress(Of String, Integer)

    ' RX, using multiple Returns to produce events
    Async Function WatchEvents() As IObservable(Of String)

    ' Async methods that implicitly start on a parallel thread
    Async Function ThreadpoolWorkAsync() As ParallelTask

 

Why it didn’t work: type inference

The early prototype of async allowed flexible builders, but it didn’t support async lambdas nor generic type inference. When we started to add lambdas and type inference, we discovered it was incompatible with the flexible builders.

 

In the choice between “generics + lambdas + inferences” versus “flexible return types”, we picked the first, and it was definitely the right choice.

 

Why am I so adamant that it was the right choice? Well, think of things like Task.Run or Task.WhenAll. We pass async lambdas around all over the place. They’re essential. They’re far more common than flexible return types would have been.

 

In what way are the two options incompatible? Well, let’s start with a simple example:

    Sub f(Of T)(lambda As Func(Of IAsyncOperation(Of T)))

    f(AsyncFunction()
          Return 5
      End Function)
 

Here you’d expect it to have picked T=Integer, and to have figured out that it should have called “IAsyncOperation(Of Integer).GetBuilder()”. How would it have done that? Let’s spell out the compiler’s thoughts explicitly:

  1. There is a return statement with operand “5”
  2. Therefore, the call to builder.SetResult(5) must compile
  3. <magic>
  4. Therefore, the builder must have had type IAsyncOperationBuilder(Of Integer)
  5. <magic>
  6. Therefore, the stub method must have invoked IAsyncOperation(Of Integer).GetBuilder()
  7. Therefore the lambda must have had type Func(Of IAsyncOperation(Of Integer))
  8. Therefore, T=Integer

Here’s a more difficult example.

    Sub h(Of T)(lambda As Func(Of Unusual(Of T)))

    Class Unusual(Of T) : Implements IAsyncOperation(Of IEnumerable(Of T))

    h(Async Function()
          Return 5
      End Function)
 

I don’t know what I’d expect it to pick for T in this case. In general it would depend entirely on the vagaries of overload resolution, i.e. what overloads of builder.Return() and builder.Yield() there happen to be, and which ones happen to work with which arguments. It’s entirely possible to have builders where “T” can’t be inferred at all just from those calls to Return/Yield.

There are only a few possible solutions for the “magic” steps:

  1. We could enforce a convention/constraint: every return type from an async method must be a generic type with exactly one generic type parameter, and the corresponding builder’s Return() method must not be overloaded, and it must take exactly one parameter, and the parameter’s type must be the generic type parameter. Verdict: this wouldn’t generalize to Yield, and it wouldn’t work with lots of interesting return types, and this kind of complex and arbitrary restriction is unprecedented and ugly.
     
  2. We could add an arbitrary “constraint-solving” component to VB and C#. Whenever it encounters a type inference situation, it assembles all the knowledge it has about all overloads and return statements and yield statements, and plugs them into the solver, and sees what answers pop out. Verdict: the behavior of the compiler would now be unpredictable to everyone. It’s true that constraint-solvers do exist in other languages, e.g. F# uses the Hindley-Milner solver for type inference. But there’s no precedent for the complexity of the constraint-solver we’d need here.
     
  3. We could decide that flexible return types are allowed only for top-level methods or for lambas where their return-types are explicitly declared. Verdict: this would be ugly. If the language has a feature, you should be able to combine it with other features. If we consider how we use lambdas today, we use type inference in almost all of them.

 

Workarounds for the lack of flexible types

I started this article by outlining some of the scenarios where people want flexible return types. Now that we know they’re impossible, let’s consider the workarounds.

SCENARIO 1. I want to return a value-type from my async methods, to avoid the cost of a heap-allocated reference type. Even if the data is available immediately and I return Task.FromResult(5), say, that still incurs the cost of allocating Task on the heap.

If we’d anticipated the “async” feature five years ago we might have made the “Task” type a structure to start with. But it’s too late now. The best you can do is make your own Task-like type that’s a structure, and return it from a wrapper method. Here below is an example.

 

    Function GetNameLiteAsync() As TaskLite(Of String)
        If Not m_cachedName Is Nothing Then Return New TaskLite(Of String)(m_cachedName)
        Return New TaskLite(Of String)(GetNameInternalAsync())
    End Function

    Structure TaskLite(Of T) : Implements Runtime.CompilerServices.INotifyCompletion
        Private m_SyncValue As T
        Private m_AsyncValue As Task(Of T)

        Public Sub New(Value As T)
            m_SyncValue = Value
        End Sub

        Public Sub New(AsyncValue As Task(Of T))
            m_AsyncValue = AsyncValue
        End Sub

        Public Function GetAwaiter() As TaskLite(Of T)
            Return Me
        End Function

        Public ReadOnly Property IsCompleted As Boolean
            Get
                Return (m_AsyncValue Is Nothing) OrElse (m_AsyncValue.IsCompleted)
            End Get
        End Property

        Public Sub OnCompleted(continuation As Action) Implements INotifyCompletion.OnCompleted
            m_AsyncValue.GetAwaiter().OnCompleted(continuation)
        End Sub

        Public Function GetResult() As T
            If m_AsyncValue Is Nothing Then Return m_SyncValue
            Return m_AsyncValue.GetAwaiter().GetResult()
        End Function
    End Structure

We might have created TaskLite(Of T) ourselves in the .NET framework, and added it into the compiler, and baked in support for async methods to return either Task(Of T) or TaskLite(Of T). That would have worked, but would have been ugly framework design: every single user would have to worry all of the time about whether to return TaskLite or Task, when in reality it’s not even worth worrying about for the majority of users.

 

 

SCENARIOS 2 and 3: I want my async method to return IAsyncOperation(Of T), or some other task-like type, because my existing framework is built around my own task-like type.

The Task type is pluripotent. You can build any other task-like thing out of it. For instance we provide an extension method to turn a Task(Of T) into an IAsyncOperation(Of T). That’s how it is in general: whenever you want to return a different task-like thing, you have to do it via a wrapper method.

 

    Function GetNameRTAsync() As Windows.Foundation.IAsyncOperation(Of String)
        Dim t As Task(Of String) = GetNameInternalAsync()
        Return System.WindowsRuntimeSystemExtensions.AsAsyncOperation(t)
    End Function

SCENARIO 4: I want my async method to return an ITask(Of T).

This suggested scenario is a positively bad idea. We already as of .NET4 had a single canonical Task type. All the combinators like Task.WhenAll and Task.WhenAny operate on it, as do the Reactive Extensions, and Dataflow, and other libraries.

If we introduced ITask(Of T) as well, then suddenly all those libraries would become useless. They’d need to be rewritten to take ITask arguments rather than Task arguments. And for every async method or library-routine that you write, you’d have to decide whether it should return Task or ITask. That’s a detail that’s not worth worrying about for most programmers, and shouldn’t be forced upon them.

Also, if the compiler compiles an async method whose declared return type was ITask(Of T), it would still have to pick a concrete implementation for that return type. (Likewise, when the compiler compiles an iterator method with return type IEnumerable(Of T), it needs to pick a concrete implementation of it). So allowing an async return method to return ITask(Of T) would never change the underlying fact that it returns an object whose runtime type is Task(Of T).

 

    Function GetNameIAsync() As ITask(OfString)
        Dim t As Task(Of String) = GetNameInternalAsync()
        Return New TaskWrapper(Of String)(t)
    End Function

 

It was fascinating to work together with the other members of the VB and C# language design teams, to design the async language feature. We didn’t make any decisions lightly. The decision in this article, about flexible return types, took months of discussion and prototypes until we reached an answer. I’m confident we made the right decision in this case. Next month I’ll discuss why we allowed void-returning async methods. It was altogether more controversial...

Comments

  • Anonymous
    November 21, 2012
    Enjoyed your post! With my work in async, I have on a few occasions wished for ITask<T> - not so I can return my own implementation, but so I could use generic variance. There are a few situations where that would be really useful. Well, maybe someday generic variance will be extended to classes... :)

  • Anonymous
    November 21, 2012
    Thanks for the lengthy explanation. Enjoyed reading it :) Since I (well, the team I work in) are one of the Scenario #3 proponents, I have a follow up: We didn't want to replace Task to substitute an arbitrary adapter to our own framework but so we can replace Task with a (lightweight) implementation that is serializable. Do you have any design suggestions along the lines of what you proposed for Scenario #3 Regards, Michael

  • Anonymous
    November 22, 2012
    The comment has been removed

  • Anonymous
    November 22, 2012
    (cont'd) You gave three reasons for not doing this. I’d like to ask you to elaborate on the first two:

  • How would you expect a solution to generalize to Yield? (Btw, we can easily do our trick using iterators, we just need to install a reflection-based serialization surrogate, which works as long as all your local variables are serializable. But the syntax is awkward, e.g. instead of calling await EditObject, we’d have to do foreach (var x in EditObject) yield return x; plus there are no return values.)
  • What interesting return types would it not work with? (If you think about IAsyncOperation<IEnumerable<T>>, a possible workaround may be an interface IAsyncEnuerableOperation<T> deriving from the former. This might not resolve every problem, but there have always been border cases in C# that the type system just won’t let you do. And there have been solutions, like dynamic interface proxies.) On the third reason, I disagree strongly. You write “this kind of complex and arbitrary restriction is unprecedented and ugly”. C# already has some features that use behind-the-scene transformation, like foreach (for .Dispose()) and LINQ query expressions. Providing any unresolvable overloads for Select(), or using type parameters that cannot be inferred, will result in compilation errors. Frankly, I don’t see a real difference here. On the other hand, no single feature in C# today relies on such a complex framework type. All other dependencies of C# are a) implicit (the type does not usually show in the source code) and b) use very primitive types, like Object, ValueType or MulticastDelegate. Linking a compiler feature to a complex type that is not serializable is what is unprecedented. We discussed this at some length and think that solution #1 would be a very useful addition that does not really bring unprecedented, unexpected or ugly behavior to C#. We’d love to see you pick up this idea again for some hypothetical future version of C# ;-) Thanks for listening! Cheers, Stefan PS: sorry for all the curly braces on your VB blog, but I assume you and most of your readers read C# fluently, and I can hardly read, let alone write VB. Just figuring out what that AsyncFunction() is took me some time (the missing space didn't help).
  • Anonymous
    November 23, 2012
    @Stephen Clearly -- good point. Yes covariance would be a reason for ITask(Of T).

  • Anonymous
    November 23, 2012
    @Stefan - thanks for the serialization scenario. We'd discussed serialization in our design meetings. It's a big topic, so I wrote a separate blog article with a possible implementation of serialization. (Sorry for the missing spaces... this blog platform doesn't let me past code with syntax highlighting unless I go via raw HTML markup, which is losing spaces!) How would I expect a solution to generalize to Yield? Here are some examples. (I also count f3 and f4 as the interesting examples that I might want to return). Sub f1(lambda As IAsyncAction) ... Sub f2(Of T)(lambda As IAsyncOperation(Of TR)) ... Sub f3(Of T,U)(lambda As IAsyncOperationWithProgress(Of TR,TP)) ... Sub f4(Of T)(lambda As IObservable(Of T)) Sub f5(Of T)(lambda As IAsyncEnumerable(Of T)) f1(... : Return : ...) f2(... : Return 15 : ...)  ' infers TR=integer f3(... : Yield 12 : ... : Return "hello")  ' infers TR=string, TP=integer f4(... : Return 7 : Return 15 : Return 12 : Return ) ' infers T=integer f5(... : If b Then Return : ... : Yield 15) ' infers T=integer In f5 the Yield statement implies that the first generic parameter is the dominant type of all yield expressions, but in f3 it implies that the SECOND generic parameter is the dominant type of all yield expressions. In f1/f5 the Return operand must be void, but in f2/f3 the dominant type of return operands is taken as the first generic parameter. And in f4 we use multiple returns for a function with multiple return-values, or a void return to indicate the end of the sequence. I'll grant you that multi-return f4 might be trying to be too clever for its own good. But even just the fact that Yield sometimes binds to the first generic parameter and sometimes to the second is too ugly.

  • Anonymous
    October 15, 2014
    I have implemented an ITask interface that I have been using.  I feel it is a pretty clean solution and has provided me with the covariance I need.  See www.github.com/.../ITask