Trexx
Trexx

Reputation: 95

Task.WhenAll behaves different if Task.Delay used

I stumbled on something I can't really wrap my head around.

Example Code

Consider this example code.

public static void Main()
{
    Example(false)
        .ConfigureAwait(false)
        .GetAwaiter()
        .GetResult();
      
    Console.ReadLine();

}
    
public static async Task Example(bool pause)
{
    List<int> items = Enumerable.Range(0, 10).ToList();

    DateTime start = DateTime.Now;

    foreach(var item in items) {
        await ProcessItem(item, pause);
    }

    DateTime end = DateTime.Now;

    Console.WriteLine("using normal foreach: " + (end - start));
    
    var tasks = items.Select(x => ProcessItem(x, pause));

    start = DateTime.Now;

    await Task.WhenAll(tasks);
    
    end = DateTime.Now;

    Console.WriteLine("using Task.WhenAll " + (end - start));
}

public static async Task ProcessItem(int item, bool pause)
{
    Console.WriteLine($"[{item}]: invoked at " + DateTime.Now.ToString("hh:mm:ss.fff tt"));

    if (pause) {
        await Task.Delay(1);
    }

    int x = 5;

    for (int i = 0; i < 1 * 1000000; i++) {
        x = await Calculate(i);
    }
   
}

public static async Task<int> Calculate(int item)
{
    return await Task.FromResult(item + 5);
}

In the Example method I simply call the ProcessItem method, at first with a normal foreach and then with Task.WhenAll.

ProcessItem takes some number and a flag indicating whether await.TaskDelay(1) should be invoked, more on that later. Besides that, all it does it simulate some longer running code + a call to a third awaitable method (Calculate).

Result

The result when running the code is

[0]: invoked at 01:19:17.417
[1]: invoked at 01:19:17.898
[2]: invoked at 01:19:18.330
[3]: invoked at 01:19:18.782
[4]: invoked at 01:19:19.118
[5]: invoked at 01:19:19.472
[6]: invoked at 01:19:19.716
[7]: invoked at 01:19:19.961
[8]: invoked at 01:19:20.179
[9]: invoked at 01:19:20.402
using normal foreach: 00:00:03.2314927

[0]: invoked at 01:19:20.639
[1]: invoked at 01:19:20.887
[2]: invoked at 01:19:21.178
[3]: invoked at 01:19:21.440
[4]: invoked at 01:19:21.670
[5]: invoked at 01:19:21.954
[6]: invoked at 01:19:22.390
[7]: invoked at 01:19:22.880
[8]: invoked at 01:19:23.218
[9]: invoked at 01:19:23.449
using Task.WhenAll 00:00:03.0749655

The normal loop and Task.WhenAll take about the same time for the execution, looks like both versions work sequentially because in both cases there's always some delay between the outputs.

Now let's make things weird. If I pass true instead of false to Example, the method now invokes await.TaskDelay(1), resulting in a different execution as you can see in the result.

[0]: invoked at 01:22:17.047
[1]: invoked at 01:22:17.521
[2]: invoked at 01:22:17.886
[3]: invoked at 01:22:18.337
[4]: invoked at 01:22:18.735
[5]: invoked at 01:22:19.024
[6]: invoked at 01:22:19.262
[7]: invoked at 01:22:19.500
[8]: invoked at 01:22:19.731
[9]: invoked at 01:22:19.992
using normal foreach: 00:00:03.2050316
[0]: invoked at 01:22:20.240
[1]: invoked at 01:22:20.241
[2]: invoked at 01:22:20.241
[3]: invoked at 01:22:20.241
[4]: invoked at 01:22:20.242
[5]: invoked at 01:22:20.242
[6]: invoked at 01:22:20.242
[7]: invoked at 01:22:20.243
[8]: invoked at 01:22:20.243
[9]: invoked at 01:22:20.244
using Task.WhenAll 00:00:01.4674985

As you can see, the normal loop works as usual but apparently Task.WhenAll now decides to call the ProcessItem method for all items at the same time - whereas before, one item after the other was being processed.

Questions

Why does executing await Task.Delay(1) make such a huge difference?

Why doesn't the first version (where await Task.Delay(1) wasn't being called) call the ProcessItem for all items at around the same time?

It seems like I'm missing something here. I've tested the code with .NET 4.5 and .NET 4.7.2 - same results.

Upvotes: 3

Views: 925

Answers (1)

Hans Kesting
Hans Kesting

Reputation: 39274

When you have await MyAsyncMethod(), that MyAsyncMethod returns a Task that may or may not be in an IsCompleted state. And the execution of the rest of the containing method depends on that state.

  • When the task is "completed", execution continues synchronously.
  • When the task is not yet completed, the containing async method returns an incomplete task. The rest of the containing method will be executed whenever this task completes.

In your Calculate method, you are returning Task.FromResult, which is a completed task. Task.Delay however is not completed until the timeout passes, so you immediately get an incomplete Task.

So with pause==false, your methods run in effect synchronously and all have completed when the foreach is finished, leaving nothing for Task.WhenAll to wait on.

With pause==true, the ProcessItem method returns an incomplete task as soon as the Delay is hit. So the several invocations of this method are started rapidly (you see the Console.WriteLine output close together in time) and only after the delays have expired, is the rest executed - in the Task.WhenAll.

Upvotes: 5

Related Questions