Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can new C# language features be used to to clean-up Task.WhenAll syntax?

With "async everywhere", the ability to fire off multiple heterogeneous operations is becoming more frequent. The current Task.WhenAll method returns its results as an array and requires all tasks to return the same kind of object which makes its usage a bit clumsy. I'd like to be able to write...

var (i, s, ...) = await AsyncExtensions.WhenAll(
                          GetAnIntFromARemoteServiceAsync(),
                          GetAStringFromARemoteServiceAsync(),
                          ... arbitrary list of tasks   
                         );
Console.WriteLine($"Generated int {i} and string {s} ... and other things");

The best implementation I've been able to come up with is

public static class AsyncExtensions
{
  public static async Task<(TA , TB )> WhenAll<TA, TB>(Task<TA> operation1, Task<TB> operation2)
  {
             return (await operation1, await operation2;
  }
}

This has the disadvantage that I need to implement separate methods of up to N parameters. According to this answer that's just a limitation of using generics. This implementation also has the limitation that void-returning Tasks can't be supported but that is less of a concern.

My question is: Do any of the forthcoming language features allow a cleaner approach to this?

like image 243
NeilMacMullen Avatar asked Oct 12 '25 18:10

NeilMacMullen


1 Answers

As of .NET 6, there is no API available in the standard libraries that allows to await multiple heterogeneous tasks, and get their results in a value tuple.

I would like to point out though, and this is the main point of this answer, that the implementation that you've shown inside the question is incorrect.

// Incorrect
public static async Task<(T1, T2)> WhenAll<T1, T2>(Task<T1> task1, Task<T2> task2)
{
    return (await task1, await task2);
}

This is not WhenAll. This is WhenAllIfSuccessful_Or_WhenFirstFails. If the task1 fails, the error will be propagated immediately, and the task2 will become a fire-and-forget task. In some cases this might be exactly what you want. But normally you don't want to lose track of your tasks, and let them running unobserved in the background. You want to wait for all of them to complete, before continuing with the next step of your work. Here is a better way to implement the WhenAll method:

// Good enough
public static async Task<(T1, T2)> WhenAll<T1, T2>(Task<T1> task1, Task<T2> task2)
{
    await Task.WhenAll(task1, task2).ConfigureAwait(false);
    return (task1.Result, task2.Result);
}

This will wait for both tasks to complete, and in case of failure it will propagate the error of the first failed task (the first in the list of the arguments, not in chronological order). In most cases this is perfectly fine. But if you find yourself in a situation that requires the propagation of all exceptions, it becomes tricky. Below is the shortest implementation I know that imitates precisely the behavior of the native Task.WhenAll:

// Best
public static Task<(T1, T2)> WhenAll<T1, T2>(Task<T1> task1, Task<T2> task2)
{
    return Task.WhenAll(task1, task2).ContinueWith(t =>
    {
        if (t.IsFaulted)
        {
            TaskCompletionSource<(T1, T2)> tcs = new();
            tcs.SetException(t.Exception.InnerExceptions);
            return tcs.Task;
        }
        if (t.IsCanceled)
        {
            TaskCompletionSource<(T1, T2)> tcs = new();
            tcs.SetCanceled(new TaskCanceledException(t).CancellationToken);
            return tcs.Task;
        }
        Debug.Assert(t.IsCompletedSuccessfully);
        return Task.FromResult((task1.Result, task2.Result));
    }, default, TaskContinuationOptions.DenyChildAttach |
        TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default).Unwrap();
}