Is there an equivalent of Task.WhenAll accepting ValueTask?

I can work around it using

Task.WhenAll(tasks.Select(t => t.AsTask()))

This will be fine if they're all wrapping a Task but it will force the useless allocation of a Task object for real ValueTask.


Solution 1:

By design, no. From the docs:

Methods may return an instance of this value type when it's likely that the result of their operations will be available synchronously and when the method is expected to be invoked so frequently that the cost of allocating a new Task for each call will be prohibitive.

For example, consider a method that could return either a Task<TResult> with a cached task as a common result or a ValueTask<TResult>. If the consumer of the result wants to use it as a Task<TResult>, such as to use with in methods like Task.WhenAll and Task.WhenAny, the ValueTask<TResult> would first need to be converted into a Task<TResult> using AsTask, which leads to an allocation that would have been avoided if a cached Task<TResult> had been used in the first place.

Solution 2:

Unless there is something I'm missing, we should be able to just await all the tasks in a loop:

public static async ValueTask<T[]> WhenAll<T>(params ValueTask<T>[] tasks)
{
    // Argument validations omitted

    var results = new T[tasks.Length];
    for (var i = 0; i < tasks.Length; i++)
        results[i] = await tasks[i].ConfigureAwait(false);

    return results;
}

Allocations
Awaiting a ValueTask that is completed synchronously shouldn't cause a Task to be allocated. So the only "extra" allocation happening here is of the array we use for returning the results.

Order
Order of the returned items are the same as the order of the given tasks that produce them.

Exceptions
When a task throws an exception, the above code would stop waiting for the rest of the exceptions and just throw. If this is undesirable, we could do:

public static async ValueTask<T[]> WhenAll<T>(params ValueTask<T>[] tasks)
{
    // We don't allocate the list if no task throws
    List<Exception>? exceptions = null;

    var results = new T[tasks.Length];
    for (var i = 0; i < tasks.Length; i++)
        try
        {
            results[i] = await tasks[i].ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            exceptions ??= new List<Exception>(tasks.Length);
            exceptions.Add(ex);
        }

    return exceptions is null
        ? results
        : throw new AggregateException(exceptions);
}

Extra considerations

  • We can have this as an extension method.
  • We can have overloads that accept IEnumerable<ValueTask<T>> and IReadOnlyList<ValueTask<T>> for wider compatibility.

Sample signatures:

// There are some collections (e.g. hash-sets, queues/stacks,
// linked lists, etc) that only implement I*Collection interfaces
// and not I*List ones, but A) we're not likely to have our tasks
// in them and B) even if we do, IEnumerable accepting overload
// below should handle them. Allocation-wise; it's a ToList there
// vs GetEnumerator here.
public static async ValueTask<T[]> WhenAll<T>(
    IReadOnlyList<ValueTask<T>> tasks)
{
    // Our implementation above.
}

// ToList call below ensures that all tasks are initialized, so
// calling this with an iterator wouldn't cause the tasks to run
// sequentially (Thanks Sergey from comments to mention this
// possibility, which led me to add this Considerations section).
public static ValueTask<T[]> WhenAll<T>(
    IEnumerable<ValueTask<T>> tasks) =>
    WhenAll(tasks?.ToList() ?? throw new ArgumentNullException(nameof(tasks)));

// Arrays already implement IReadOnlyList<T>, but this overload
// is still useful because of params that allows callers to
// pass individual tasks like they are different arguments.
public static ValueTask<T[]> WhenAll<T>(
    params ValueTask<T>[] tasks) =>
    WhenAll(tasks as IReadOnlyList<ValueTask<T>>);

Theodor in comments mentioned the approach of having the result array/list passed as an argument, so our implementation would be free of all extra allocations but the caller will still have to create it, which could make sense if they batch await tasks but that sounds like a fairly specialized scenario, so if you find yourself needing that you probably don't need this answer 🙂