Task.WhenAll for ValueTask

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.


As @stuartd pointed out, it is not supported by design, I had to implement this manually:

public static async Task<IReadOnlyCollection<T>> WhenAll<T>(this IEnumerable<ValueTask<T>> tasks)
{
    var results = new List<T>();
    var toAwait = new List<Task<T>>();

    foreach (var valueTask in tasks)
    {
        if (valueTask.IsCompletedSuccessfully)
            results.Add(valueTask.Result);
        else
            toAwait.Add(valueTask.AsTask());
    }

    results.AddRange(await Task.WhenAll(toAwait).ConfigureAwait(false));

    return results;
}

Of course this will help only in high throughput and high number of ValueTask as it adds some other overheads.

NOTE: As @StephenCleary pointed out, this does not keep the order as Task.WhenAll does, if it is required it can be easily changed to implement it.


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)
{
    ArgumentNullException.ThrowIfNull(tasks);
    if (tasks.Length == 0)
        return Array.Empty<T>();

    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.

Concurrency
Even though it looks like we execute the tasks sequentially, this is not really the case as the tasks are already started (i.e. are in a hot state) when this method is called. Therefore we only wait as long as the longest task in the array (thanks Sergey for asking about this in the comments).

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

public static async ValueTask<T[]> WhenAll<T>(params ValueTask<T>[] tasks)
{
    ArgumentNullException.ThrowIfNull(tasks);
    if (tasks.Length == 0)
        return Array.Empty<T>();

    // 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(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.
public static ValueTask<T[]> WhenAll<T>(
    IEnumerable<ValueTask<T>> tasks)
{
    return WhenAll(tasks?.ToList());
}

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

Theodor in the 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. This could make sense if they batch await tasks, which sounds like a fairly specialized scenario to me, but for completeness' sake:

// Arrays implement `IList<T>`
public static async ValueTask WhenAll<T>(ValueTask<T>[] source, IList<T> target)
{
    ArgumentNullException.ThrowIfNull(source);
    ArgumentNullException.ThrowIfNull(target);

    if (source.Length != target.Count)
        throw new ArgumentException(
            "Source and target lengths must match",
            nameof(target));

    List<Exception>? exceptions = null;

    for (var i = 0; i < source.Length; i++)
        try
        {
            target[i] = await source[i].ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            exceptions ??= new(source.Length);
            exceptions.Add(ex);
        }

    if (exceptions is not null)
        throw new AggregateException(exceptions);
}