22

I just made a curious observation regarding the Task.WhenAll method, when running on .NET Core 3.0. I passed a simple Task.Delay task as a single argument to Task.WhenAll, and I expected that the wrapped task will behave identically to the original task. But this is not the case. The continuations of the original task are executed asynchronously (which is desirable), and the continuations of multiple Task.WhenAll(task) wrappers are executed synchronously the one after the other (which is undesirable).

Here is a demo of this behavior. Four worker tasks are awaiting the same Task.Delay task to complete, and then continue with a heavy computation (simulated by a Thread.Sleep).

var task = Task.Delay(500);
var workers = Enumerable.Range(1, 4).Select(async x =>
{
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}" +
        $" [{Thread.CurrentThread.ManagedThreadId}] Worker{x} before await");

    await task;
    //await Task.WhenAll(task);

    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}" +
        $" [{Thread.CurrentThread.ManagedThreadId}] Worker{x} after await");

    Thread.Sleep(1000); // Simulate some heavy CPU-bound computation
}).ToArray();
Task.WaitAll(workers);

Here is the output. The four continuations are running as expected in different threads (in parallel).

05:23:25.511 [1] Worker1 before await
05:23:25.542 [1] Worker2 before await
05:23:25.543 [1] Worker3 before await
05:23:25.543 [1] Worker4 before await
05:23:25.610 [4] Worker1 after await
05:23:25.610 [7] Worker2 after await
05:23:25.610 [6] Worker3 after await
05:23:25.610 [5] Worker4 after await

Now if I comment the line await task and uncomment the following line await Task.WhenAll(task), the output is quite different. All continuations are running in the same thread, so the computations are not parallelized. Each computation is starting after the completion of the previous one:

05:23:46.550 [1] Worker1 before await
05:23:46.575 [1] Worker2 before await
05:23:46.576 [1] Worker3 before await
05:23:46.576 [1] Worker4 before await
05:23:46.645 [4] Worker1 after await
05:23:47.648 [4] Worker2 after await
05:23:48.650 [4] Worker3 after await
05:23:49.651 [4] Worker4 after await

Surprisingly this happens only when each worker awaits a different wrapper. If I define the wrapper upfront:

var task = Task.WhenAll(Task.Delay(500));

...and then await the same task inside all workers, the behavior is identical to the first case (asynchronous continuations).

My question is: why is this happening? What causes the continuations of different wrappers of the same task to execute in the same thread, synchronously?

Note: wrapping a task with Task.WhenAny instead of Task.WhenAll results in the same strange behavior.

Another observation: I expected that wrapping the wrapper inside a Task.Run would make the continuations asynchronous. But it's not happening. The continuations of the line below are still executed in the same thread (synchronously).

await Task.Run(async () => await Task.WhenAll(task));

Clarification: The above differences were observed in a Console application running on the .NET Core 3.0 platform. On the .NET Framework 4.8, there is no difference between awaiting the original task or the task-wrapper. In both cases, the continuations are executed synchronously, in the same thread.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • just curious, what will happend if `await Task.WhenAll(new[] { task });` ? – vasily.sib Mar 10 '20 at 04:12
  • 1
    I think its because of the short circuiting inside `Task.WhenAll` – TheGeneral Mar 10 '20 at 04:13
  • @vasily.sib the same thing happens. The `Task.WhenAll` accepts as argument an array `params Task[] tasks`, so both calls are identical. – Theodor Zoulias Mar 10 '20 at 04:17
  • @MichaelRandall could you elaborate on that? – Theodor Zoulias Mar 10 '20 at 04:19
  • @TheodorZoulias [here](https://github.com/microsoft/referencesource/blob/master/mscorlib/system/threading/Tasks/Task.cs#L6362) – vasily.sib Mar 10 '20 at 04:27
  • @vasily.sib thank you for the link. The line you refer is: `if (task.IsCompleted) this.Invoke(task); // short-circuit the completion action, if possible`. This line handles the case of already completed tasks. The `Task.Delay(100)` is not completed yet at the time that it's awaited. Otherwise the continuations would run synchronously in all cases. I just tested it by replacing `task = Task.Delay(100)` with `task = Task.CompletedTask`. All continuations run in the main thread (the thread with id = 1) in this case. – Theodor Zoulias Mar 10 '20 at 04:36
  • 3
    LinqPad gives the same expected second output for both variants... Which environment you use to get parallel runs (console vs. WinForms vs... , .NET vs. Core,..., framework version)? – Alexei Levenkov Mar 10 '20 at 06:06
  • @AlexeiLevenkov you are right, the output is the same on .NET Framework 4.8 (the continuations are executed synchronously in both cases). My observations with different outcomes are on the .NET Core 3.0 platform. It is a Console application written in C# 8. – Theodor Zoulias Mar 10 '20 at 06:30
  • 1
    I was able to duplicate this behavior on .NET Core 3.0 and 3.1, but only after changing the initial `Task.Delay` from `100` to `1000` so that it is not completed when `await`ed. – Stephen Cleary Mar 11 '20 at 17:02
  • Could it be related to the following [known issue?](https://support.microsoft.com/en-us/help/3118695/runcontinuationsasynchronously-does-not-run-continuations-asynchronous) – BlueStrat Mar 11 '20 at 23:14
  • 2
    @BlueStrat nice find! It could certainly be related somehow. Interestingly I failed to reproduce the erroneous behavior of Microsoft's [code](https://support.microsoft.com/en-us/help/3118695/runcontinuationsasynchronously-does-not-run-continuations-asynchronous) on the .NET Frameworks 4.6, 4.6.1, 4.7.1, 4.7.2 and 4.8. I get different thread ids every time, which is the correct behavior. [Here](https://dotnetfiddle.net/Az91rB) is a fiddle running on 4.7.2. – Theodor Zoulias Mar 11 '20 at 23:43

3 Answers3

4

So you have multiple async methods awaiting the same task variable;

    await task;
    // CPU heavy operation

Yes, these continuations will be called in series when task completes. In your example, each continuation then hogs the thread for the next second.

If you want each continuation to run asynchronously you may need something like;

    await task;
    await Task.Yield().ConfigureAwait(false);
    // CPU heavy operation

So that your tasks return from the initial continuation, and allow the CPU load to run outside of the SynchronizationContext.

Jeremy Lakeman
  • 9,515
  • 25
  • 29
  • Thanks Jeremy for the answer. Yes, `Task.Yield` is a good solution to my problem. My question though is more about why is this happening, and less about how to force the desired behavior. – Theodor Zoulias Mar 10 '20 at 05:20
  • If you really want to know, the source code is here; https://github.com/microsoft/referencesource/blob/master/mscorlib/system/threading/Tasks/Task.cs#L3656 – Jeremy Lakeman Mar 10 '20 at 06:37
  • I wish that it was that simple, to obtain the answer of my question by studying the source code of the related classes. It would take me ages to understand the code and figure out what's going on! – Theodor Zoulias Mar 10 '20 at 06:53
  • The key is avoiding the `SynchronizationContext`, calling `ConfigureAwait(false)` once on the original task may be sufficient. – Jeremy Lakeman Mar 11 '20 at 00:21
  • This is a console application, and the [`SynchronizationContext.Current`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.synchronizationcontext.current) is null. But I just checked it to be sure. I added `ConfigureAwait(false)` in the `await` line and it made no difference. The observations are the same as previously. – Theodor Zoulias Mar 11 '20 at 00:31
  • Ah, didn't spot the .net-core tag earlier. .net core doesn't have a `SynchronizationContext`. Tasks will be executed immediately if they can, or be scheduled to run in a thread pool when they resume. – Jeremy Lakeman Mar 11 '20 at 14:37
1

When a task is created using Task.Delay(), its creation options is set to None rather than RunContinuationsAsychronously.

This might be breaking change between .net framework and .net core. Regardless of that, it does appear to explain the behavior you are observing. You can also verify this from digging into the source code that Task.Delay() is newing up a DelayPromise which calls the default Task constructor leaving no creation options specified.

Tanveer Badar
  • 5,438
  • 2
  • 27
  • 32
  • Thanks Tanveer for the answer. So you speculate that on .NET Core the [`RunContinuationsAsychronously`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcreationoptions#fields) has become the default instead of `None`, when constructing a new `Task` object? This would explain some of my observations but not all. Specifically it wouldn't explain the difference between awaiting the same `Task.WhenAll` wrapper, and awaiting different wrappers. – Theodor Zoulias Mar 11 '20 at 19:29
0

In your code, the following code is out of the recurring body.

var task = Task.Delay(100);

so each time you run the following it will await the task and run it in a separate thread

await task;

but if you run the following, it will checks the state of task, so it will run it in one Thread

await Task.WhenAll(task);

but if you move task creation beside WhenAll it will run each task in separate Thread.

var task = Task.Delay(100);
await Task.WhenAll(task);
  • Thanks Seyedraouf for the answer. Your explanation doesn't sound too satisfactory to me though. The task returned by `Task.WhenAll` is just a regular `Task`, like the original `task`. Both tasks are completing at some point, the original as a result of a timer event, and the composite as a result of the completion of the original task. Why should their continuations display different behavior? In what aspect is the one task different from the other? – Theodor Zoulias Mar 10 '20 at 11:11