Monday, August 21, 2023

Why Do You Have to Return "Task" Whenever You "await" Something in a Method in C#?

There is something that has always bothered me in C#: Whenever you "await" something in a method, the return value must be wrapped in a Task.

Note: If you prefer video to text, take a look here: YouTube: Why do you have to return a Task when you use await in a C# method?

The Behavior

Let's start with an example:


    public Person GetPerson(int id)
    {
        List<Person> people = GetPeople();
        Person selectedPerson = people.Single(p => p.Id == id);
        return selectedPerson;
    }

    public async Task<Person> GetPersonAsync(int id)
    {
        List<Person> people = await GetPeopleAsync();
        Person selectedPerson = people.Single(p => p.Id == id);
        return selectedPerson;
    }

The first method (GetPerson) does not have any asynchronous code. It calls the "GetPeople" method that returns a List of Person objects. Then it uses a LINQ method (Single) to pick out an individual Person. Lastly it returns that person. As expected, the return type for the "GetPerson" method is "Person".

The second method (GetPersonAsync) does have asynchronous code. It calls the "GetPeopleAsync" method (which returns a Task<List<Person>>) and then awaits the result. This also gives us a List of Person objects. Then it uses the same LINQ method (Single) to get the selected Person. Lastly it returns that person.

But here's where things are a bit odd: even though our return statement ("return selectedPerson") returns a "Person" type, the method itself ("GetPeopleAsync") returns "Task<Person>".

A Leaky Abstraction

I love using "await" in my code. It is so much easier than dealing with the underlying Tasks directly, and it handles the 95% scenario for me (meaning, 95% of the time, it does what I need -- I only need to drop back to using Task directly when I need more flexibility).

I also really like how "await" lets me write asynchronous code very similarly to how I write non-asynchronous code. Exceptions are raised as expected, and I can use standard try/catch/finally blocks. For the most part, I do not have to worry about "Task" at all when using "await".

It is a very good abstraction over Task.

But it does "leak" in this one spot. A leaky abstraction is one where the underlying implementation shows through. And the return type of a method that uses "await" is one spot where the underlying "Task" implementation leaks through.

This isn't necessarily a bad thing. All abstractions leak to a certain extent. But this particular one has bugged me for quite a while. And it can be difficult to grasp for developers who may not have worked with Task directly.

A Better Understanding

Most of the descriptions I've seen have just said "If you use 'await' in your method, the return type is automatically wrapped in a Task." And there's not much more in the way of explanation.

To get a better understanding of why things work this way, let's get rid of the abstraction and look at using "Task" directly for this code, building things up one line at a time.

If you would like more information on how to use Task manually (along with how to use 'await'), you can take a look at the resources available here: I'll Get Back to You: Task, Await, and Asynchronous Methods in C#.

Step 1: The Method Signature

Let's start with the signature that we would like to have:


    public Person GetPersonAsyncWithTask(int id)
    {

    }

The idea is that I can pass the "id" of a person to this method and get a Person instance back. So this would be my ideal signature.

Step 2: Call the Async Method

Next, we'll call the "GetPersonAsync" method, but instead of awaiting it, we'll get the actual Task back. When I don't know the specific type that comes back from a method, I'll use "var result" to capture the value and then let Visual Studio help me. Here's that code:


    public Person GetPersonAsyncWithTask(int id)
    {
        var result = GetPeopleAsync();
    }

If we hover the cursor over "var", Visual Studio tells us the type we can expect:



This tells us that "result" is "Task<TResult>" and that "TResult is List<Person>". This means that "GetPeopleAsync" returns "Task<List<Person>>". So let's update our code to be more explict:


    public Person GetPersonAsyncWithTask(int id)
    {
        Task<List<Person>> peopleTask = GetPeopleAsync();
    }

Now we have our explicit type, along with a better name for the result: peopleTask.

Step 3: Add a Continuation

The next step is to add a continuation to the Task. By adding a continuation, we are telling our code that after the Task is complete, we would like to "continue" by doing something else.

This is done with the "ContinueWith" method on the task:


    public Person GetPersonAsyncWithTask(int id)
    {
        Task<List<Person>> peopleTask = GetPeopleAsync();
        peopleTask.ContinueWith(task =>
        {

        });        
    }

The "ContinueWith" method takes a delegate (commonly inlined using a lambda expression). In this case, the delegate takes the "peopleTask" as a parameter (which we'll call "task" in the continuation).

For more information on delegates and lambda expressions, you can take a look at the resources available here: Get Func-y: Understanding Delegates in C#.

Step 4: Fill in the Continuation Code

The next step is to fill in the body of our continuation. This is basically "the rest of the method" that we had in our non-asynchronous version:


    public Person GetPersonAsyncWithTask(int id)
    {
        Task<List<Person>> peopleTask = GetPeopleAsync();
        peopleTask.ContinueWith(task =>
        {
            List<Person> people = task.Result;
            Person selectedPerson = people.Single(p => p.Id == id);
            return selectedPerson;
        });        
    }

The first line in the continuation takes the "Result" property of the task (which is the List<Person>) and assigns it to a variable with a friendlier name: "people". Then we use the "Single" method like we did above to get an individual record from the list. Then we return that selected "Person" object.

But Now We Have a Problem

But now we have a problem: we want to return the "selectedPerson" from the "GetPersonAsyncWithTask" method, but it is being returned inside the continuation of the Task instead.

How do we get this out?

It turns out that "ContinueWith" returns a value. Let's use the same technique we used above to figure out what that is.

Step 5: Getting a Return from ContinueWith


    public Person GetPersonAsyncWithTask(int id)
    {
        Task<List<Person>> peopleTask = GetPeopleAsync();
        var result = peopleTask.ContinueWith(task =>
        {
            List<Person> people = task.Result;
            Person selectedPerson = people.Single(p => p.Id == id);
            return selectedPerson;
        });        
    }

Here we have added "var result = " in front of of the call to "peopleTask.ContinueWith". Then if we hover the mouse over "var", we see that this is "Task<TResult>" and "TResult is Person". So this tells us that "ContinueWith" returns a "Task<Person>".

So let's be more explicit with our variable:


    public Person GetPersonAsyncWithTask(int id)
    {
        Task<List<Person>> peopleTask = GetPeopleAsync();
        Task<Person> result = peopleTask.ContinueWith(task =>
        {
            List<Person> people = task.Result;
            Person selectedPerson = people.Single(p => p.Id == id);
            return selectedPerson;
        });        
    }

Now our "result" variable is specifically typed as "Task<Person>".

Step 6: Return the Result

The last step is to return the result:


    public Person GetPersonAsyncWithTask(int id)
    {
        Task<List<Person>> peopleTask = GetPeopleAsync();
        Task<Person> result = peopleTask.ContinueWith(task =>
        {
            List<Person> people = task.Result;
            Person selectedPerson = people.Single(p => p.Id == id);
            return selectedPerson;
        });
        return result;
    }

But we can't stop here. Our return types do not match. The method (GetPersonAsyncWithTask) returns a "Person", and the actual type we return is "Task<Person>".

So we need to update our method signature:


    public Task<Person> GetPersonAsyncWithTask(int id)
    {
        Task<List<Person>> peopleTask = GetPeopleAsync();
        Task<Person> result = peopleTask.ContinueWith(task =>
        {
            List<Person> people = task.Result;
            Person selectedPerson = people.Single(p => p.Id == id);
            return selectedPerson;
        });
        return result;
    }

Now the method returns a "Task<Person>". And this is what we want. If someone awaits what comes back from this method, they will get a "Person" object back. In addition, someone could take this task and set up their own continuation. This is just the nature of Task and how we set up asynchronous code using Task.

Back to the Comparison

So let's go back to our method comparison. But instead of comparing a method with "await" to a non-asynchronous method, let's compare a method with "await" to a method that handles the asynchronous Task manually.


    public async Task<Person> GetPersonAsync(int id)
    {
        List<Person> people = await GetPeopleAsync();
        Person selectedPerson = people.Single(p => p.Id == id);
        return selectedPerson;
    }

    public Task<Person> GetPersonAsyncWithTask(int id)
    {
        Task<List<Person>> peopleTask = GetPeopleAsync();
        Task<Person> result = peopleTask.ContinueWith(task =>
        {
            List<Person> people = task.Result;
            Person selectedPerson = people.Single(p => p.Id == id);
            return selectedPerson;
        });
        return result;
    }

Here's how we can think of this code now (and the compiler magic that happens behind it): anything after the "await" is automatically put into a continuation. What the compiler does is much more complicated, but this is how we can think of it from the programmer's perspective.

If we think about it this way, it's easier to see why the method returns "Task<Person>" instead of just "Person".

"await" is Pretty Magical

When we use "await" in our code, magical things happen behind the scenes. (And I'm saying that as a good thing.) The compiler gets to figure out all of the details of waiting for a Task to complete and what to do next. When I have this type of scenario (the 95% scenario), then it is pretty amazing. It saves me a lot of hassle and let's me work with code that looks more "normal".

The abstraction that "await" gives us is not perfect, though. It does "leak" a little bit. Whenever we "await" something in a method, the return value gets automatically wrapped in a Task. This means that we do need to change the return type of the method to Task. But it is a small price to pay for the ease of using "await".

Wrap Up

I've always had a bit of trouble with having to return a Task from any method that "awaits" something. I mean, I knew that I had to do it, but there was always a bit of cognitive dissonance with saying "the code in this method returns 'Person', but the method itself returns 'Task<Person>'."

By working through this manually, my brain is a little more comfortable. I have a better understanding of what is happening behind the "await", and that if we were to do everything manually, we would end up returning a Task. So it makes sense that after we add the magic of "await", the method will still need to return a Task.

I hope that this has helped you understand things a bit as well. If you have any questions or other ways of looking at this, feel free to leave a comment.

Happy Coding!

3 comments:

  1. True, but the word "leaky" in leaky abstractions connotes an avoidable lack of intent. Clearly this was a deep and intentional design choice, and many would argue that the value of the Task return type as a handy reminder that you're working with an async method makes the "cognitive leap" worthwhile. Async would still be a lot to wrap your head around regardless. :)

    ReplyDelete
    Replies
    1. I tend to agree with Joe Spolsky that "All non-trivial abstractions, to some degree, are leaky" (https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/). Because of this, I disagree that "leaky" implies it can be avoided or that it was unintentional -- it just means that the underlying implementation shows through. When we create an abstraction, we have to look at the options and parameters and pick the one that is least "leaky" for our situation. In the case of async/await, even though the underlying Task leaks out in the method return type, I think it was a good design decision since "await" does a really good job of hiding the Task overall. And it really only "leaks" when we look at the method in isolation. When we look at the method in the chain of callers, we need that Task return type so we can await or otherwise use the Task.

      Delete
  2. Great post, Jeremy! You have an excellent way of breaking things down into easily grasped pieces to explain things in such an understandable manner.

    ReplyDelete