9

Does .NET resume an await continuation on a new different thread pool thread or reuse the thread from a previous resumption?

Let's image below C# code in a .NET Core console application:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace NetCoreResume
{
    class Program
    {
        static async Task AsyncThree()
        {
            await Task.Run(() =>
            {
                Console.WriteLine($"AsyncThree Task.Run thread id:{Thread.CurrentThread.ManagedThreadId.ToString()}");
            });

            Console.WriteLine($"AsyncThree continuation thread id:{Thread.CurrentThread.ManagedThreadId.ToString()}");
        }

        static async Task AsyncTwo()
        {
            await AsyncThree();

            Console.WriteLine($"AsyncTwo continuation thread id:{Thread.CurrentThread.ManagedThreadId.ToString()}");
        }

        static async Task AsyncOne()
        {
            await AsyncTwo();

            Console.WriteLine($"AsyncOne continuation thread id:{Thread.CurrentThread.ManagedThreadId.ToString()}");
        }

        static void Main(string[] args)
        {
            AsyncOne().Wait();

            Console.WriteLine("Press any key to end...");
            Console.ReadKey();
        }
    }
}

It will output:

AsyncThree Task.Run thread id:4
AsyncThree continuation thread id:4
AsyncTwo continuation thread id:4
AsyncOne continuation thread id:4
Press any key to end...

I have tried to add ConfigureAwait(false) after each await Task, but it will get the same result.

As we can see, it seems like all await continuations reused the thread created in Task.Run of AsyncThree() method. I want to ask if .NET will always resume the await continuation on previous resumption thread, or it will apply a new different thread from thread pool in some occasions?

I knew there is answer the continuation will resume on a thread pool thread in below discussion:

async/await. Where is continuation of awaitable part of method performed?

Let's exclude the SynchronizationContext case in above link, since we are now discussing a .NET console application. But I want to ask it seems like that thread pool thread in my example is always thread id 4, I don't know whether it is because thread id 4 is always free in the thread pool, so every continuation reuse it by coincidence, or .NET has mechanism will reuse the previous resumption thread as much as possible?

Is there any possibility each continuation will resume on a different thread pool thread like below?

AsyncThree Task.Run thread id:4
AsyncThree continuation thread id:5
AsyncTwo continuation thread id:6
AsyncOne continuation thread id:7
Press any key to end...
Scott.Hu
  • 369
  • 2
  • 14
  • I would expect the same thread to be used when returning back up the call stack, so I'm not surprised that the last 3 outputs report the same thread. – spender Jan 08 '20 at 16:32
  • @spender Sure, I'll expect that too... But I just want to know if the await continuation will pick up a thread from thread pool in random or .NET has a priority will resume the continuation from previous resumption thread somehow? – Scott.Hu Jan 08 '20 at 16:41
  • Are you asking because you're concerned about memory writes by the original thread being visible to the continuing thread? I've always assumed that `await` continuation introduces a happens-before barrier, because it would be extremely buggy if it didn't. But I can't point to any documentation that it does. – StackOverthrow Jan 08 '20 at 16:50
  • 3
    @user560822: It is not documented, but Microsoft people have assured me that the barriers are always present if `await` causes a thread switch. – Stephen Cleary Jan 11 '20 at 01:45

4 Answers4

12

Does .NET resume an await continuation on a new different thread pool thread or reuse the thread from a previous resumption?

Neither. By default, when awaiting Tasks, await will capture a "context" and use that to resume the asynchronous method. This "context" is SynchronizationContext.Current, unless it is null, in which case the context is TaskScheduler.Current. In your example code, the context is the thread pool context.

The other part of the puzzle is undocumented: await uses the TaskContinuationOptions.ExecuteSynchronously flag. This means that when the Task.Run task is completed (by thread 4), its continuations are run immediately and synchronously - if possible. In your example code, the continuation may run synchronously because there's enough stack on thread 4 and the continuation should be run on a thread pool thread and thread 4 is a thread pool thread.

Likewise, when AsyncThree completes, the continuation for AsyncTwo is run immediately and synchronously - again on thread 4 since it meets all the criteria.

This is an optimization that is especially helpful in scenarios like ASP.NET, where it's common to have a chain of async methods and have one task completing (e.g., a db read) that completes the entire chain and sends the response. In those cases you want to avoid unnecessary thread switches.

An interesting side effect of this is that you end up with an "inverted call stack" of sorts: the thread pool thread 4 ran your code and then completed AsyncThree and then AsyncTwo and then AsyncOne, and each of those completions are on the actual call stack. If you place a breakpoint on the WriteLine in AsyncOne (and look at external code), you can see where ThreadPoolWorkQueue.Dispatch (indirectly) called AsyncThree which (indirectly) called AsyncTwo which (indirectly) called AsyncOne.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • Thanks for your explanation. “In your example code, the continuation may run synchronously because there's enough stack on thread 4 and the continuation should be run on a thread pool thread and thread 4 is a thread pool thread.”. So it means there is still possibility that the continuation will resume on a different/new thread pool thread under some extreme conditions? – Scott.Hu Jan 11 '20 at 06:41
  • @Scott.Hu: Yes, that is [possible](https://devblogs.microsoft.com/pfxteam/when-executesynchronously-doesnt-execute-synchronously/). – Stephen Cleary Jan 11 '20 at 23:24
  • @StephenCleary plz check this [link](https://referencesource.microsoft.com/#mscorlib/system/threading/Tasks/Task.cs,f4ecbcb6398671fb). it first checks whether there’s a SynchronizationContext set, and if there isn’t, whether there’s a non-default TaskScheduler in play. If it finds one, when the callback is ready to be invoked, it’ll use the captured scheduler; otherwise, it’ll generally just execute the callback on as part of the operation completing the awaited task. – pinopino Jan 30 '21 at 08:45
  • Is this still true in .NET Core 7? I made a simple .NET 7 async console app fiddle here: https://dotnetfiddle.net/xWDAco. When I call `Task.WhenAll(tasks)` with an array of tasks, and each task includes an `await Task.Delay(TimeSpan.FromMilliseconds(10))`, the `Thread.CurrentThread.ManagedThreadId` changes after the await. – dbc Aug 10 '23 at 20:19
  • 1
    Yes, this is still true in .NET 7. It will probably never change. – Stephen Cleary Aug 10 '23 at 21:47
1

As you can see Why is the initial thread not used on the code after the awaited method? it is quite possible to resume on another thread, based on what is available at the moment.

In asynchronous programming there is not definite usage of specific threads when used with async await. You only know that an available thread will be picked from the thread pool.

In your case, since the execution is pretty much sequential, the thread is freed and you get the number 4.

Based on the thread pool documentation https://learn.microsoft.com/en-us/dotnet/standard/threading/the-managed-thread-pool the thread pool is unique per process, so I'd expect the usage of the first available thread to be used. So is you have no other concurrent operations, the thread 4 will be reused each time. There are no guarantees though.

Athanasios Kataras
  • 25,191
  • 4
  • 32
  • 61
  • So that means the await continuations will probably resume on different thread pool threads if thread pool is quite busy? – Scott.Hu Jan 08 '20 at 16:36
  • 1
    I tried the code in the first link, the result is below:From main before async call , Thread:1 From TestAsyncSimple before delay,Thread:1 From TestAsyncSimple inside Task,Thread:4 From TestAsyncSimple after delay,Thread:4 From main after async call ,Thread:4 ResultComputed:tadaa,Thread:4 . We can see all the continuations still resume on thread 4. But it does make sense if thread pool always reused the free thread from previous resumption. – Scott.Hu Jan 08 '20 at 16:59
1

Does .NET resume an await continuation on a new different thread pool thread or reuse the thread from a previous resumption?

Most of the time it will use the same thread, but this is not guaranteed. This answer to a question where the OP wants to force it to the same thread gives some details.

Where the continuation is run is up to the TaskScheduler. I haven't looked, but I imagine it will run the continuation on the same thread just to avoid unnecessary overhead when working with the thread pool.

Is there any possibility each continuation will resume on a different thread pool thread like below?

Yes, there's a possibility, but probable that you won't see this, again because of the scheduler. You'd probably have to write your own to force a difference, and personally I don't know why you'd do that. (Not saying that was your intent).

Kit
  • 20,354
  • 4
  • 60
  • 103
  • Yes, I just want to know if .NET optimize all continuations reused the same thread, I never intend to force all continuations must work in different threads... – Scott.Hu Jan 08 '20 at 18:36
  • Cool. In short, continuations are run in the same thread because that's what the scheduler does, but it doesn't *have* to. – Kit Jan 08 '20 at 20:34
0

There is no real async call in your sample code ie. An i/o call so most likely the continuation is inlined on the original thread as an optimization. In your async methods if you add await Task.Delay you may observe continuation may run on a different thread. Bottomline never make any assumptions on which thread the continuations run, assume it gets run on another thread.

Dogu Arslan
  • 3,292
  • 24
  • 43