2

I got curious about what would happen if you await a TaskCompletionSource from two threads.

using System.Diagnostics;

var t = Start();
var t1 = Task.Run(async () => {
    await t;
    Thread.Sleep(TimeSpan.FromSeconds(2));
    Print($"task1");
});
var t2 = Task.Run(async () => {
    await t;
    Thread.Sleep(TimeSpan.FromSeconds(4));
    Print($"task2");
});
await Task.WhenAll(t1, t2);
Print($"end");
Console.ReadLine();

static Task Start()
{
    var taskCompletionSource = new TaskCompletionSource();

    Process process = new()
    {
        StartInfo = new ProcessStartInfo(@"notepad"),
        EnableRaisingEvents = true
    };
    process.Exited += (object? sender, EventArgs args) =>
    {
        Print("one");
        taskCompletionSource.SetResult();
        Print("two"); 
    };
    process.Start();

    return taskCompletionSource.Task;
}

static void Print(string msg) => Console.WriteLine($"[{Environment.CurrentManagedThreadId}] - {msg}");

Outputs:

[6] - one
[4] - task1
[6] - task2
[6] - end

But sometimes the output is:

[4] - one
[4] - task1
[4] - two
[6] - task2
[6] - end

So, it seems that when SetResult sees that we are awaiting our TaskCompletionSource in two places at once, it sometimes behaves differently. In the second output, it runs the first continuation on the same thread that called SetResult (thread [4]), while the second continuation is scheduled on a thread from the thread pool (thread [6]), which seems to happen randomly. But other times, for some reason (as in the first output), it seems to wait for both continuations to complete before executing the code that follows the SetResult call. I'm wondering why.

How does this logic work? Why are both outcomes possible?

8
  • Replace Thread.Sleep() with await Task.Delay(). Reevaluate Commented Jul 30 at 12:03
  • we'll just release the stream back to the pool. But what if we have CPU work, then there are still two possible outputs? In the first cycle, instead of the delay, we will insert int r = 0; for (var i = 0; i < 1000_00000; i++) { r++; } and in the second int r = 0; for (var i = 0; i < 1000_000000; i++) { r++; } Commented Jul 30 at 12:11
  • await Task.Run(() => SomeMethodThatReturnsAValueOrNot()); instead of await Task.Delay() Commented Jul 30 at 12:32
  • I understand that I can just start another thread, in fact this is the behavior of TaskCreationOptions.RunContinuationsAsynchronously, so it's unnecessary to create a thread myself, I'm wondering how it works in c# if a new thread is not started. Commented Jul 30 at 13:15
  • If you don't block an async Task, you just add one ThreadPool Thread (not a Thread), the continuation is actually asynchronous, and you have a consistent behavior Commented Jul 30 at 14:35

1 Answer 1

1

(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.

  1. Thread pool introduces lots of randomness / possible variations.
  2. 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.
  3. 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.
  4. The eventhandler process.Exited is executed on a thread pool thread as well.
  5. 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.

Sign up to request clarification or add additional context in comments.

1 Comment

So, the key point is: if the conditions for synchronous continuation are met when calling SetResult, and the TaskCompletionSource has two or more awaiters, one randomly selected awaiter will continue synchronously. The rest will be scheduled asynchronously.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.