Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Awaiting a Tuple of Tasks in C#

5.00/5 (19 votes)
4 Sep 2023CPOL2 min read 28.6K  
Elegant replacement for awaiting a limited set of tasks
This tip introduces a more concise way to await and extract results from multiple asynchronous tasks in C# by creating custom awaitable types using extension methods.

Introduction

Consider this code:

C#
Task<int> fetchIntFromApiTask = FetchIntFromApi();
Task<string> fetchStringFromApiTask = FetchStringFromApi();
await Task.WhenAll(fetchIntFromApiTask, fetchStringFromApiTask);
string stringResponse = fetchStringFromApiTask.Result;
int intResponse = fetchIntFromApiTask.Result;

What we are trying to accomplish in the snippet above is just awaiting two asynchronous methods concurrently and assign their results to two different variables. There is a lot of boilerplate.

On the other hand, consider this one-liner:

C#
var (stringResponse, intResponse) = await (FetchStringFromApi(), FetchIntFromApi());

Here, we are trying to await a tuple of Tasks, with the goal to "extract" the Result from them. This is by far more readable, more maintainable and more intuitive. The main problem is that this is not standard C#, but the good news is that this is fairly easy to implement!

How Can We Await a Tuple of Tasks?

First of all, we have to remember what is an awaitable. An awaitable is basically a type on which we can apply the await operator. For example, Task, Task<T>, ValueTask, ValueTask<T> are all awaitables. The good thing is that we can create our own awaitables!

This is the rigorous definition of an awaitable:

A type is said to be an awaitable, if it has an instance method or an extension method called GetAwaiter() that returns a valid awaiter type.

A type is said to be an awaiter if it:

  1. implements the INotifyCompletion interface
  2. has a property bool IsCompleted { get; }
  3. has a method GetResult() that can either return something or void. This is the value returned by the await operator.

For example, a Task<T>, has an instance method with this signature TaskAwaiter<T> GetAwaiter(). The TaskAwaiter<T> follows the awaiter pattern and has a Method T GetResult(), that blocks the caller thread until the result is ready, before returning it back.

So the trick is to create an extension method over our tuple (the type ValueTuple<T1,T2>) called GetAwaiter() that returns a valid awaiter. In our scenario, we want awaiting the tuple to be equivalent to call Task.WhenAll on the elements of the tuple, so we can directly return the TaskAwaiter returned back from a Task that extracts the results. So, in practice, this is our solution:

C#
public static class TaskExtensions
{
    public static TaskAwaiter<(T1, T2)> GetAwaiter<T1, T2>(this (Task<T1>, Task<T2>) tuple)
    {
        async Task<(T1, T2)> Core()
        {
            var (task1, task2) = tuple;
            await Task.WhenAll(task1, task2);
            return (task1.Result, task2.Result);
        }

        return Core().GetAwaiter();
    }
}

Now the one-liner compiles and works perfectly:

C#
var (stringResponse, intResponse) = await (FetchStringFromApi(), FetchIntFromApi());

We can, of course, create overloads for Tuples with more elements, using the same strategy.

If we want also to implement ConfigureAwait(), we can implement it similarly as an extension method over the tuple that returns a ConfiguredTaskAwaitable<(T1,T2)>.

C#
public static ConfiguredTaskAwaitable<(T1, T2)> ConfigureAwait<T1, T2>
       (this (Task<T1>, Task<T2>) tuple, bool continueOnCapturedContext)
{
    async Task<(T1, T2)> Core()
    {
        var (task1, task2) = tuple;
        await Task.WhenAll(task1, task2).ConfigureAwait(continueOnCapturedContext);
        return (task1.Result, task2.Result);
    }

    return Core().ConfigureAwait(continueOnCapturedContext);
}

var (stringResponse, intResponse) = 
     await (FetchStringFromApi(), FetchIntFromApi()).ConfigureAwait(false);

History

  • 3rd September, 2023: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)