How to pass additional context information along with tasks to Task.WhenAll?

Viewed 221

Consider the following ideal code (which doesn't work). I'm essentially trying to create a list of Tasks that return a specific object, associate them with a string identifier, then execute all of them in bulk with Task.WhenAll. At the end of the execution, I need to have the results of those Tasks still associated with the string identifiers they were originally created with:

public async Task<SomeObject> DoSomethingAsync(string thing)
{
    // implementation elided
}

public async Task<SomeObject> DoSomethingElseAsync(string thing)
{
    // different implementation elided
}

public async Task<IEnumerable<(string, SomeObject)>>
    DoManyThingsAsync(IEnumerable<string> someListOfStrings, bool condition)
{
    var tasks = new List<(string, Task<SomeObject>)>();

    foreach (var item in someListOfStrings)
    {
        if (condition)
        {
            tasks.Add((item, DoSomethingAsync(item)));
        }
        else
        {
            tasks.Add((item, DoSomethingElseAsync(item)));
        }
    }

    // this doesn't compile, i'm just demonstrating what i want to achieve
    var results = await Task.WhenAll(tasks);

    return results;
}

This can be rewritten to the following:

public async Task<(string, SomeObject)> DoSomethingWrapperAsync(string thing)
    => (thing, await DoSomethingAsync(thing));

public async Task<(string, SomeObject)> DoSomethingElseWrapperAsync(string thing)
    => (thing, await DoSomethingElseAsync(thing));

public async Task<IEnumerable<(string, SomeObject)>>
    DoManyThingsAsync(IEnumerable<string> someListOfStrings, bool condition)
{
    var tasks = new List<Task<(string, SomeObject)>>();

    foreach (var thing in someListOfStrings)
    {
        if (condition)
        {
            tasks.Add(DoSomethingWrapperAsync(thing));
        }
        else
        {
            tasks.Add(DoSomethingElseWrapperAsync(thing));
        }
    }

    // this does compile
    var results = await Task.WhenAll(tasks);

    return results;
}

The problem is that I need an extra wrapper method for every possible discrete async function I'm going to call, which feels unnecessary and wasteful and is a lot of code (because there will be MANY of these methods). Is there a simpler way of achieving what I need?

I looked into implementing the awaitable/awaiter pattern, but can't see how I could get it to work with Task.WhenAll which requires a collection of Task or Task<TResult>, since the guidance seems to be "don't extend those classes".

2 Answers

You can either do the zipping as you go:

public async Task<IEnumerable<(string, SomeObject)>>
    DoManyThingsAsync(IEnumerable<string> someListOfStrings, bool condition)
{
  var tasks = someListOfStrings
      .Select(async item =>
          condition ?
          (item, await DoSomethingAsync(item)) :
          (item, await DoSomethingElseAsync(item)))
      .ToList();
  return await Task.WhenAll(tasks);
}

Or, you can keep the input as a separate collection and zip it later:

public async Task<IEnumerable<(string, SomeObject)>>
    DoManyThingsAsync(IEnumerable<string> someListOfStrings, bool condition)
{
  // Reify input so we don't enumerate twice.
  var input = someListOfStrings.ToList();

  var tasks = input
      .Select(item =>
          condition ?
          DoSomethingAsync(item) :
          DoSomethingElseAsync(item))
      .ToList();
  var taskResults = await Task.WhenAll(tasks);

  return input.Zip(taskResults, (item, taskResult) => ((item, taskResult)));
}

From what I can gather you are using condition to determine which method (of the exact same signature) is being called. Why not pass a callback that is called for each item instead of doing the logic inside the foreach loop?

public async Task<SomeObject> DoSomethingAsync(string thing)
{
    // ...
}

public async Task<SomeObject> DoSomethingElseAsync(string thing)
{
    // ...
}

public async Task<IEnumerable<(string, SomeObject)>> DoManyThingsAsync(IEnumerable<string> someListOfStrings, bool condition)
{
    Func<string, Task<SomeObject>> callback = condition ? DoSomethingAsync : DoSomethingElseAsync;

    var results = await Task.WhenAll(
        someListOfStrings.Select(async thing => (thing, await callback(thing)))
    );
    return results;
}

Furthermore, you can extract this as an extension method.

public static class AsyncExtensions
{
    public static async Task<IEnumerable<(T, TResult)>> WhenAllAsync(this IEnumerable<T> collection, Func<T, Task<TResult>> selector)
    {
        var results = await Task.WhenAll(
            collection.Select(async item => (item, await selector(item)))
        );
        return results;
    }
}

public async Task MyMethodAsync()
{
    // ...

    var results = await myListOfStrings.WhenAllAsync(condition ? DoSomethingAsync : DoSomethingElseAsync);

    // ...
}

Or do the mapping in the callback.

public static class AsyncExtensions
{
    public static Task<IEnumerable<TResult>> WhenAllAsync(this IEnumerable<T> collection, Func<T, Task<TResult>> selector)
        => Task.WhenAll(collection.Select(selector)));
}

public async Task MyMethodAsync()
{
    // ...

    var results = await myListOfStrings.WhenAllAsync(async thing => (thing, await (condition ? DoSomethingAsync(thing) : DoSomethingElseAsync(thing))));

    // ...
}
Related