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.
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
.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With