Rudey
Rudey

Reputation: 4975

How to properly cancel Task.WhenAll and throw the first exception?

I have multiple tasks that accept a cancellation token and call ThrowIfCancellationRequested accordingly. These tasks will run concurrently using Task.WhenAll. I want all tasks to be cancelled when any tasks throw an exception. I achieved this using Select and ContinueWith:

var cts = new CancellationTokenSource();

try
{
    var tasks = new Task[] { DoSomethingAsync(cts.Token), ... } // multiple tasks here
        .Select(task => task.ContinueWith(task =>
        {
            if (task.IsFaulted)
            {
                cts.Cancel();
            }
        }));

    await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch (SpecificException)
{
    // Why is this block never reached?
}

I'm not sure if this is the best way to do this, it seems to have some issues. It appears the exception will be caught internally, code after WhenAll is always reached. I don't want the code after WhenAll to be reached when an exception has occurred, I'd rather have the exception to be thrown so I can catch it manually on another level of the call stack. What's the best way to achieve this? If possible I'd like the call stack to remain intact. If multiple exceptions occur it would be best if only the first exception is rethrown, no AggregateException.


On a related note, I tried passing the cancellation token to ContinueWith like so: task.ContinueWith(lambda, cts.Token). However, when an exception in any task occurs, this will eventually throw a TaskCanceledException instead of the exception I'm interested in. I figured I should pass the cancellation token to ContinueWith because this would cancel ContinueWith itself, which I don't think is what I want.

Upvotes: 13

Views: 12875

Answers (3)

Theodor Zoulias
Theodor Zoulias

Reputation: 43475

What you are doing with the Select and ContinueWith is basically correct. The thing that is missing is the propagation of the status of the antecedent task to the continuation. The best way to do this is by returning the antecedent task, and unwrapping the resulting Task<Task> using the Unwrap method. This way every detail of the antecedent task is propagated, such as the canceling CancellationToken, or multiple exceptions if present. Also this way there is no added friction caused by catching and rethrowing exceptions, and also ugly nested AggregateExceptions are avoided.

/// <summary>
/// Creates a task that will complete when all of the supplied tasks have completed.
/// If any task completes as faulted, the CancellationTokenSource is canceled.
/// </param>
public static Task WhenAll_OnFaultedCancel(Task[] tasks, CancellationTokenSource cts)
{
    ArgumentNullException.ThrowIfNull(tasks);
    ArgumentNullException.ThrowIfNull(cts);

    if (Array.IndexOf(tasks, null) != -1) throw new ArgumentException(
        $"The {nameof(tasks)} collection contained a null task.");

    IEnumerable<Task> continuations = tasks.Select(t => t.ContinueWith(t =>
    {
        if (t.IsFaulted) cts.Cancel();
        return t;
    }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously,
        TaskScheduler.Default).Unwrap());

    return Task.WhenAll(continuations);
}

Usage example:

await WhenAll_OnFaultedCancel(tasks, cts);

Upvotes: 0

Stephen Cleary
Stephen Cleary

Reputation: 456457

You shouldn't use ContinueWith. The correct answer is to introduce another "higher-level" async method instead of attaching a continuation to each task:

private async Task DoSomethingWithCancel(CancellationTokenSource cts)
{
    try
    {
        await DoSomethingAsync(cts.Token).ConfigureAwait(false);
    }
    catch
    {
        cts.Cancel();
        throw;
    }
}
var cts = new CancellationTokenSource();
try
{
    var tasks = new Task[] { DoSomethingWithCancel(cts), ... };
    await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch (SpecificException)
{
    // ...
}

Upvotes: 19

Luis Ferrao
Luis Ferrao

Reputation: 1503

Based on @Stephen's answer, usage: await someTask.CancelOnError(cts)

It requires two extension methods to handle both Task and Task`T:

public static async Task CancelOnError(this Task task, CancellationTokenSource cts)
{
    try
    {
        await task.ConfigureAwait(false);
    }
    catch
    {
        cts.Cancel();

        throw;
    }
}

public static async Task<TTaskResult> CancelOnError<TTaskResult>(this Task<TTaskResult> task, CancellationTokenSource cts)
{
    await ((Task)task).CancelOnError(cts);

    return task.Result;
}

Comments are welcome, give it a try!

Upvotes: 2

Related Questions