Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why the continuations of Task.WhenAll are executed synchronously?

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.

like image 885
Theodor Zoulias Avatar asked Mar 10 '20 03:03

Theodor Zoulias


2 Answers

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.

like image 114
Jeremy Lakeman Avatar answered Oct 18 '22 08:10

Jeremy Lakeman


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.

like image 34
Tanveer Badar Avatar answered Oct 18 '22 09:10

Tanveer Badar