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:
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:
var (stringResponse, intResponse) = await (FetchStringFromApi(), FetchIntFromApi());
Here, we are trying to await
a tuple of Task
s, 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 awaitable
s. The good thing is that we can create our own awaitable
s!
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:
- implements the
INotifyCompletion
interface - has a property
bool IsCompleted { get; }
- 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:
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:
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)>
.
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