buvinghausen / TaskTupleAwaiter

Async helper library to allow leveraging the new ValueTuple data types in C# 7.0
MIT License
70 stars 10 forks source link

Support WhenAny #15

Closed taori closed 5 years ago

taori commented 5 years ago

I know that this project was started to cover the syntactically smooth consumption of Task.WhenAll results, but i think it would also be nice if there was a way to get the first result of multiple tasks.

The fact that (Task t1, Task t2) is using Task.WhenAll is essentially hidden from the user.

It would be great if there were also a way to do something like var result = await Awaiters.FirstAsync(Task<string> a, Task<string> b)

Currently i've got a scenario where i have one long running web query and a fast web query. The long running one populates a cache, the later one provides the user with data for the current interaction level. It's an entires different scenario from the TaskTupleAwaiter, but it's in the same nieche. Interaction with concurrent Task results.

What do you think about covering this scenario within this project too?

taori commented 5 years ago

The solution would be as simple as

public static class Awaiters
{
    public static async Task<T> FirstAsync<T>(params Task<T>[] tasks)
    {
        var match = await Task.WhenAny(tasks);
        return await match;
    }
}

Without the class the usage would have to be this: var result = await await Task.WhenAny(Task.Run(() => "hi"), Task.Run(() => "hi2")); to get the same result, which always felt rather odd having to double await.

jnm2 commented 5 years ago

My experience with this says that it's hard to safely await WhenAny. The problem is that when the first task cancels or fails, causing await Task.WhenAny to throw an exception, the second task is effectively running async void in a sense. That is, any exceptions thrown in the second task will be impossible to suppress or handle. It forces the global exception handler to kick in.

var fooTask = FooAsync(); // Cancels
var barTask = BarAsync(); // Happens to fail after FooAsync cancels
var firstTask = await Task.WhenAny(fooTask, barTask); // Never throws
var firstValue = await firstTask; // Throws TaskCanceledException, dropping barTask on the floor
return firstValue; // Even if we get here, we're still dropping barTask on the floor!

Maybe this would be a good API?

// Usage
var (value, remainingTasks) = await FirstAsync(t1, t2);

// Don't risk throwing an exception before awaiting remaining tasks!
// Put anything that could throw in ShowValueAsync.
await Task.WhenAll(ShowValueAsync(value), remainingTasks);

// Straw man
public static async Task<(T firstValue, Task remainingTasks)> FirstAsync<T>(params Task<T>[] tasks)
{
    var firstTask = await Task.WhenAny(tasks).ConfigureAwait(false);
    var allTasks = Task.WhenAll(tasks);

    if (firstTask.Status != TaskStatus.RanToCompletion)
    {
        // Awaiting firstTask would throw, so make sure all potential failures are observed.
        // (`await` loses all but the first exception; the rest are suppressed. Potentially this
        // method should use a TaskCompletionSource to avoid this in case this method is itself not
        // merely awaited.)

        await allTasks.ConfigureAwait(false); // Throws
    }

    // Task.Result is safe to use (will not block or throw) iff Status == RanToCompletion
    return (firstTask.Result, allTasks);
}

That API doesn't force you to be safe and await those remaining tasks while you're still in an appropriate scope. Here's one that does, but it's harder to name:

await new[] { t1, t2 }.ContinueWithFirstResult(value => ShowValueAsync(value));

/// <summary>
/// Invokes <paramref name="onFirstResult" /> with the result of the first task that completes
/// successfully (if any). Completes when all tasks have been observed for potential exceptions,
/// including <paramref name="onFirstResult" /> if it was invoked.
/// </summary>
public static async Task ContinueWithFirstResult<T>(
    this IEnumerable<Task<T>> tasks,
    Func<T, Task> onFirstResult,
    CancellationToken cancellationToken)
{
    // ...
}

This feels like it needs more development and battle-testing, and even then it would belong in a general-purpose task API library. The name of this library imposes a pretty intense scope. I'm hesitant to add an API that has nothing to do with tuples. It is a cool idea and I am sorry that I can't just say yes. :-(

taori commented 5 years ago

My experience with this says that it's hard to safely await WhenAny. The problem is that when the first task cancels or fails, causing await Task.WhenAny to throw an exception, the second task is effectively running async void in a sense. That is, any exceptions thrown in the second task will be impossible to suppress or handle. It forces the global exception handler to kick in.

Thanks for sharing. I was not aware of that until now actually.

This feels like it needs more development and battle-testing, and even then it would belong in a general-purpose task API library. The name of this library imposes a pretty intense scope. I'm hesitant to add an API that has nothing to do with tuples. It is a cool idea and I am sorry that I can't just say yes. :-(

No Problem. I was aware of that being that case, considering the name of this project. hehe.