(Note: I've completely rewritten this answer as I realized after a good night's sleep that it was incorrect.)
There's a lot of factors at work here.
- Thread pool introduces lots of randomness / possible variations.
- The two
Task.Run are executed on the thread pool, so the await in each of them is (most likely, but not guaranteed) executed on a different thread. This means that they are effectively executed in a random order.
- Continuations can, but don't have to execute synchronously. See this answer by Stephen Cleary (who has written multiple great blog posts on async/await and the task api) for more info.
- The eventhandler
process.Exited is executed on a thread pool thread as well.
- Using the notepad process exited handler as a trigger for your task completion introduces even more randomness due to the inconsistent timing with which the window is closed.
To have a better understanding of what's going on I suggest adding additional Print() calls: one at the very start (which will always be the main thread [1]) and one each at the beginning of both tasks.
Print("main thread");
Task t = Start();
Task t1 = Task.Run(async () =>
{
Print("task1 before await");
await t;
Thread.Sleep(TimeSpan.FromSeconds(2));
Print($"task1");
});
Task t2 = Task.Run(async () =>
{
Print("task2 before await");
await t;
Thread.Sleep(TimeSpan.FromSeconds(4));
Print($"task2");
});
With this change, you'll see that there are actually more than the 2 variations you found.
Sidenote:
If you want to see what the execution looks like when the continuation of the eventhandler process.Exited is run asynchronously, you can provide TaskCreationOptions when creating the TaskCompletionSource:
new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
The 2 variations
The biggest difference between the variations is the thread on which the longer task (t2) runs, because the completion of this task will trigger the continuation of Task.WhenAll() (because t1 is almost guaranteed to be already completed).
Second variation
One possible execution flow that causes your 2nd variation is:
[1] - main thread
[4] - task1 before await
[9] - task2 before await
[4] - one
[4] - task1
[4] - two
[9] - task2
[9] - end
As you can see task1 is executed on the same thread as the event handler process.Exited. When reaching taskCompletionSource.SetResult() the continuation of task1 is run synchronously, effectively happening between one and two are printed. Meanwhile task2 was started on a different thread, which has to sleep 2 seconds longer, so its continuation only happens after task1 and its continuation have already completed. The continuation for task2 is also executed synchronously ([9] - task2).
Note that it is not guaranteed that the eventhandler is executed on the same thread as task1 and that it is even possible for the continuation of task1 to be executed on the thread that is awaiting t in task2:
[1] - main thread
[1] - entering Start()
[4] - task1 before await
[10] - task2 before await
[10] - one <- eventhandler run on task2 thread
[10] - task1 <- task1 continuation run on task2 thread
[10] - two <- eventhandler run on task2 thread
[4] - task2 <- task2 continuation run on task1 thread
[4] - end
First variation
Now, for an example that causes your first variation:
[1] - main thread
[1] - entering Start()
[9] - task2 before await
[4] - task1 before await
[9] - one
[4] - task1
[9] - task2
[9] - end
As you can see the thread pool thread for task2 is started before the one for task1. The continuation of the eventhandler is executed on that thread as well. Upon reaching taskCompletionSource.SetResult() the continuation for task2 is executed synchronously on that same thread. This continuation is the longer of the two tasks, so task1 running on a different thread pool thread finishes first. The only task Task.AwaitAll is waiting on is now task2 which is executed by the thread that is running the continuation of Start(). Upon completion of task2 the continuation of Task.WhenAll is executed synchronously, thus happening before Print("two").
More info on TaskCompletionSource:
If you really want to go into detail on TaskCompletionSource you might also be interested in this article
https://devblogs.microsoft.com/premier-developer/the-danger-of-taskcompletionsourcet-class/
Info on Task.WhenAll:
Task.WhenAll seems to add the Task as a continuation to all awaited tasks (See Source.Dot.Net. I only checked the fast path for arrays in Task.WhenAll, if you really want to make sure that it always works like this you'll have to go through the other code paths as well.
Thread.Sleep()withawait Task.Delay(). Reevaluateawait Task.Run(() => SomeMethodThatReturnsAValueOrNot());instead ofawait Task.Delay()