Task.WhenAny - How to cancel other tasks

18/07/2021

How to cancel remaining tasks

The magic lies in the CancellationTokenSource. The idea is to create a token which we pass to every asynchronous action. Once a task is completed we abort every other task. Let's do it:

CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken ct = cts.Token;

// Abort after 1 second
// If any of these tasks need longer than this time period you'll get an
// System.Threading.Tasks.TaskCanceledException and all tasks are cancelled
cts.CancelAfter(1000);
Task<string> taskWinner = await Task.WhenAny(new[] 
{ GetStringAsync("someurl", ct), GetStringAsync("anotherurl", ct) });

// Cancel all remaining tasks
cts.Cancel();

// As long as one Task finishes without getting cancelled or without
// an exception we have always a result without an error
string content = await taskWinner;

// Pass the token down the chain
private async Task GetStringAsync(string url, CancellationToken token)
{
  using var new httpClient = new HttpClient();
  // Token gets passed to 
  return await httpClient.GetStringAsync(url, token);
}

The first line creates the CancellationTokenSource. From that object we can get the token itself. We also say that the operations in question are not allowed to run longer than 1000 milliseconds / 1 second.

 await Task.WhenAny(new[] 
{ GetStringAsync("someurl", ct), GetStringAsync("anotherurl", ct) });

We are passing the same token to every asynchronous task and await Task.WhenAny. Once the first tasks finishes Task.WhenAny will return and we immediately cancel the CancellationTokenSource thus every tasks receive the "cancel" signal. This can save precious resources.

Special cases

The first finishes task is in faulty state

Let's have a look at this quick test:

[Fact]
public async Task ShouldThrow()
{
    var faultTask = Task.FromException(new Exception("Test"));
    var normalTask = Task.Delay(100);

    Func<Task> any = async () => await Task.WhenAny(faultTask, normalTask);

    await any.Should().NotThrowAsync();
}

This test will pass. Even though the first tasks throws an exception, the second one finishes without any problem. Therefore Task.WhenAny will return the successful ran task.

Timeout

Now back to our original example. Let's assume none of our tasks will make it in time. What will happen? Or general speaking what will happen if all tasks are in faulty state?

[Fact]
public async Task ShouldThrow()
{
    var source = new CancellationTokenSource();
    source.CancelAfter(50);
    var token = source.Token;
    var timeoutTask = Task.Delay(500, token);

    Func<Task> any = async () => await Task.WhenAny(new[] { timeoutTask });

    await any.Should().ThrowAsync<TaskCanceledException>();
}

This test will not pass. Task.WhenAny will not throw an exception! Even if all your tasks are throwing exceptions, Task.WhenAny will always return a Task-object with state RanToCompletion. Let's change the test in that manner, that we await the returning task of Task.WhenAny:

public async Task ShouldNotThrow()
{
    var source = new CancellationTokenSource();
    source.CancelAfter(50);
    var token = source.Token;
    var timeoutTask = Task.Delay(500, token);
    
    var taskFromWhenAny = await Task.WhenAny(new[] { timeoutTask });

    Func<Task> any = async () => await taskFromWhenAny;

    await any.Should().ThrowAsync<TaskCanceledException>();
}

Now we are awaing the returned task and boom, we get a passing test. That means the awaited result of Task.WhenAny throws an exception.

Conclusion

Task.WhenAny can be a good canidate if you want to get a resource from multiple sources and abort all other tasks once the first finishes. Be aware that Task.WhenAny doesn't throw any TaskCancelledException so that you have to check the state of the task afterwards.

0
Buy Me a Coffee at ko-fi.com
An error has occurred. This application may no longer respond until reloaded. Reload x