9

In my app I am creating some concurrent web requests and I am satisfied when any one of them completes, so I am using the method Task.WhenAny:

var urls = new string[] {
    "https://stackoverflow.com",
    "https://superuser.com",
    "https://www.reddit.com/r/chess",
};
var tasks = urls.Select(async url =>
{
    using (var webClient = new WebClient())
    {
        return (Url: url, Data: await webClient.DownloadStringTaskAsync(url));
    }
}).ToArray();
var firstTask = await Task.WhenAny(tasks);
Console.WriteLine($"First Completed Url: {firstTask.Result.Url}");
Console.WriteLine($"Data: {firstTask.Result.Data.Length:#,0} chars");

First Completed Url: https://superuser.com
Data: 121.954 chars

What I don't like to this implementation is that the non-completed tasks continue downloading data I no longer need, and waste bandwidth I would prefer to preserve for my next batch of requests. So I am thinking about cancelling the other tasks, but I am not sure how to do it. I found how to use a CancellationToken to cancel a specific web request:

public static async Task<(string Url, string Data)> DownloadUrl(
    string url, CancellationToken cancellationToken)
{
    try
    {
        using (var webClient = new WebClient())
        {
            cancellationToken.Register(webClient.CancelAsync);
            return (url, await webClient.DownloadStringTaskAsync(url));
        }
    }
    catch (WebException ex) when (ex.Status == WebExceptionStatus.RequestCanceled)
    {
        cancellationToken.ThrowIfCancellationRequested();
        throw;
    }
}

Now I need an implementation of Task.WhenAny that will take an array of urls, and will use my DownloadUrl function to fetch the data of the fastest responding site, and will handle the cancellation logic of the slower tasks. It would be nice if it had a timeout argument, to offer protection against never-ending tasks. So I need something like this:

public static Task<Task<TResult>> WhenAnyEx<TSource, TResult>(
    this IEnumerable<TSource> source,
    Func<TSource, CancellationToken, Task<TResult>> taskFactory,
    int timeout)
{
    // What to do here?
}

Any ideas?

Michał Turczyn
  • 32,028
  • 14
  • 47
  • 69
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104

2 Answers2

9

Simply pass to all of your tasks the same cancellation token, something like this:

CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken ct = cts.Token;
// here you specify how long you want to wait for task to finish before cancelling
int timeout = 5000;
cts.CancelAfter(timeout);
// pass ct to all your tasks and start them
await Task.WhenAny(/* your tasks here */);
// cancel all tasks
cts.Cancel();

Also, you need to read this thread to be aware of how to use CancellationToken correctly: When I use CancelAfter(), the Task is still running

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
Michał Turczyn
  • 32,028
  • 14
  • 47
  • 69
1

Update: better solution based on Stephen Cleary's answer and MSDN and svick's answer:

CancellationTokenSource source = new CancellationTokenSource();
source.CancelAfter(TimeSpan.FromSeconds(1));

var tasks = urls.Select(url => Task.Run( async () => 
{
    using (var webClient = new WebClient())
    {
        token.Register(webClient.CancelAsync);
        var result = (Url: url, Data: await webClient.DownloadStringTaskAsync(url));
        token.ThrowIfCancellationRequested();
        return result.Url;
    }
}, token)).ToArray();

string url;
try
{
    // (A canceled task will raise an exception when awaited).
    var firstTask = await Task.WhenAny(tasks);
    url = (await firstTask).Url;
}   
catch (AggregateException ae) {
   foreach (Exception e in ae.InnerExceptions) {
      if (e is TaskCanceledException)
         Console.WriteLine("Timeout: {0}", 
                           ((TaskCanceledException) e).Message);
      else
         Console.WriteLine("Exception: " + e.GetType().Name);
   }
}

non-optimal solution

The timeout can be solved by adding a task that just waits and completes after given time. Then you check which task completed first, if it is the waiting one, then timeout effectively occurred.

Task timeout = Task.Delay(10000);
var firstTask = await Task.WhenAny(tasks.Concat(new Task[] {timeout}));
if(firstTask == timeout) { ... } //timed out
source.Cancel();
Jakub Fojtik
  • 681
  • 5
  • 22
  • This does not solve my problem. The non completed tasks will continue running in the background. I want to cancel these tasks, to preserve bandwidth. – Theodor Zoulias May 19 '19 at 12:15
  • @TheodorZoulias Sorry, see edit, now it should work. – Jakub Fojtik May 19 '19 at 12:47
  • Jakub I tested your updated code, made some changes to overpass some compilation errors, and unfortunately it's not doing what I want. The non-completed tasks continue running. I want them `Canceled`, not `RanToCompletion`. I would also prefer I generalized solution that I could apply to different scenarios. See the signature of `WhenAnyEx` that is included in my question. – Theodor Zoulias May 19 '19 at 13:12
  • 1
    'A canceled task will raise an exception when awaited' is not true. You're awaiting it twice in your try statement, and the second await will throw an exception, but I don't recommend awaiting a task twice. – Michael Puckett II May 19 '19 at 13:21
  • LINQ's [`Append`](https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.append?view=netframework-4.8) is slightly better than `Concat` here. – Ben Voigt May 19 '19 at 14:39