Processing WhenAll Results
Matt McCorry, a year ago
I've been working on a codebase at work. One of the standards we are tying to implement is removing usage of Task.Result
We had some old code where we ran a number of tasks in parallel, got the result and then created a final array of the data.
var t1 = Task.Run(() => new[] { 1 });
var t2 = Task.Run(() => new[] { 2 });
await Task.WhenAll(t1, t2);
var finalArray = t1.Result.Concat(t2.Result);Easy fix, just use the result of the WhenAll call, and unwrap the nested array.
var t1 = Task.Run(() => new[] { 1 });
var t2 = Task.Run(() => new[] { 2 });
var finalArray = (await Task.WhenAll(t1, t2)).SelectMany(a => a); // [1, 2]If the types of the tasks vary, WhenAll returns a Task with no content.
var t1 = Task.Run(() => new[] { 1m });
var t2 = Task.Run(() => new[] { 2 });
var finalArray = await Task.WhenAll(t1, t2); // Build errorWe can create a function to convert all the the Tasks to the same type, e.g. Task<decimal[]>
async Task<decimal[]> ToDecimal(Task<int[]> t) => (await t).Select(Convert.ToDecimal).ToArray();Giving us:
var t1 = Task.Run(() => new[] { 1m });
var t2 = ToDecimal(Task.Run(() => new[] { 2 }));
var finalArray = (await Task.WhenAll(t1, t2)).SelectMany(a => a); // [1m, 2m]It is possible to create generic converters to convert Task<Child> to Task<Parent> to simplify dealing with inherited task results.
In some cases, we want to process the results of the WhenAll individually. In TypeScript, I would write something equivalent to:
var t1 = Task.Run(() => new[] { 1 });
var t2 = Task.Run(() => new[] { 2 });
var [t1Result, t2Result] = await Task.WhenAll(t1, t2);
DoThingWith1(t1Result);
DoThingWith2(t2Result);We can add a deconstructor like this:
public static void Deconstruct<T>(this T[] array, out T s1, out T s2)
{
s1 = array[0];
s2 = array[1];
}Then split the result of the WhenAll
var t1 = Task.Run(() => new[] { 1 });
var t2 = Task.Run(() => new[] { 2 });
var (t1Result, t2Result) = await Task.WhenAll(t1, t2);
// deal with the results individually here
DoThingWith1(t1Result);
DoThingWith2(t2Result);When using the deconstructor, we need to be careful that we don't missorder the results, as they will all be of the same type. Just make sure you put a unit test in to ensure the result mapping is correct.
I was hoping the AST for the deconstructor would be interesting, but there isn't much to see:
Edit: I came across this alternative solution to allow for differing task types.
My recursive solution inspired by the above blog:
public class RecursiveTaskHelper
{
public static RecursiveTaskHelper<T1, T2> StartWith<T1, T2>(Task<T1> left, Task<T2> right) =>
new RecursiveTaskHelper<T1, T2>(left, right);
}
public class RecursiveTaskHelper<T1, T2>(Task<T1> left, Task<T2> right)
{
private readonly Task<object> Left = WrapTask(left);
private readonly Task<object> Right = WrapTask(right);
private static Task<object> WrapTask<T>(Task<T> task) =>
task.ContinueWith(t => (object)t.Result);
public RecursiveTaskHelper<(T1, T2), T3> And<T3>(Task<T3> t)
{
return new RecursiveTaskHelper<(T1, T2), T3>(WaitAllAsync(), t);
}
public async Task<(T1, T2)> WaitAllAsync()
{
var results = await Task.WhenAll(Left, Right);
return ((T1)results[0], (T2)results[1]);
}
}Usage:
var ((resultOne, resultTwo), resultThree) = await RecursiveTaskHelper.StartWith(Task.FromResult("hi"), Task.FromResult(1))
.And(Task.FromResult(1.222m))
.WaitAllAsync();It would also be possible to implement using a source generator, similar to this.
