1

Can anyone explain (or have a resource that explains) exactly when ThreadPool threads are released back to the ThreadPool? Here is a small example program (Dotnet fiddle: https://dotnetfiddle.net/XRso3q)

    public static async Task Main()
    {
        Console.WriteLine("Start");
        var t1 = ShortWork("SW1");
        var t2 = ShortWork("SW2");
        await Task.Delay(50);
        var t3 = LongWork("LW1");
        Console.WriteLine($"After starting LongWork Thread={Thread.CurrentThread.ManagedThreadId}");
        await Task.WhenAll(t1, t2);
        await t3;
        Console.WriteLine("Done");
    }
    
    public static async Task ShortWork(string name)
    {
        Console.WriteLine($"SHORT Start {name} Thread={Thread.CurrentThread.ManagedThreadId}");
        await Task.Delay(500);
        Console.WriteLine($"SHORT End {name} Thread={Thread.CurrentThread.ManagedThreadId}");
    }
    
    public static async Task LongWork(string name)
    {
        Console.WriteLine($"LONG Start {name} Thread={Thread.CurrentThread.ManagedThreadId}");
        await Task.Delay(2500);
        Console.WriteLine($"LONG End {name} Thread={Thread.CurrentThread.ManagedThreadId}");
    }

Outputs:

Start
SHORT Start SW1 Thread=1
SHORT Start SW2 Thread=1
LONG Start LW1 Thread=5
After starting LongWork Thread=5
SHORT End SW1 Thread=7
SHORT End SW2 Thread=5
LONG End LW1 Thread=5
Done

Long work starts on thread 5, but at some point thread 5 is released back to the threadpool as thread 5 is able to pick up Short SW1 ending. When exactly does 5 get released back to the threadpool after await Task.Delay(2500) in LongWork? Does the await call release it back to the threadpool? I dont think this is the case as if I log the thread id right after the call to LongWork, that is still running on thread 5. await Task.WhenAll is called on thread 5 - which then releases control back up to whatever called 'main' - is this where it gets released as there is no 'caller' to go back to?

My understanding of what happens:

  • Starts on thread 1, thread 1 executes ShortWork SW1 and SW2.
  • Task.Delay(50) is awaited and thread 1 gets released (as there is no more work to do?)
  • Thread 5 is chosen to pick up the continuation after the 50ms delay
  • Thread 5 kicks off LongWork, and it it gets to the awaited 2500ms delay. Control gets released back up to main, still on thread 5. t1 and t2 are awaited - control gets released back up to whatever called main (and so thread 5's work is done - it gets released to the threadpool)
  • At this point no threads are 'doing' anything
  • When the ShortWork delay is done, thread 5 and 7 are selected from the pool for the continuation of each call. Once done with the continuation, these are released to the pool (?)
  • Another thread picks up the continuation between Task.WhenAll and await t3, which then immediately gets released because it is just awaiting t3
  • A ThreadPool thread is selected to do the continuation of the LongWork call
  • Finally, a ThreadPool thread picks up the last work to write done after t3 is done.

Also as a bonus, why does 5 pick up end of SW1, and 7 pick up end of LW1? These are the threads that were just used. Are they somehow kept as 'hot threads and prioritised for continuations that come up?

Eza
  • 43
  • 6
  • When execution of whatever the thread is doing is complete, the thread goes back into the pool. Remember that when an async method awaits on an uncompleted Task, the caller gives up control, the current thread of execution effectively returns. Exactly when it goes back to the pool is probably different in the Framework and may differ between execution environments – Flydog57 Dec 24 '22 at 03:01
  • Task.Delay() does *not* keep a thread busy. So yes, it can do other work. The await ensures that execution resumes after the delay, on any pool thread that happens to be available. – Hans Passant Dec 24 '22 at 03:02

2 Answers2

6

The way await works is that it first checks its awaitable argument; if it has already completed, then the async method continues synchronously. If it is not complete, then the method returns to its caller.

A second key to understanding is that all async methods begin executing synchronously, on a normal call stack just like any other method.

The third useful piece of information here is that a Console app needs a foreground thread to keep running or it will exit. So when you have an async Main, behind the scenes the runtime blocks the main thread on the returned task. So, in your example, when the first await is hit in Main, it returns a task, and the main thread 1 spends the rest of the time blocked on that task.

In this code, all continuations are run by thread pool threads. It's not specified or guaranteed which thread(s) will run which continuations.

The current implementation uses synchronous continuations, so in your example the thread id for LONG Start LW1 and After starting LongWork will always be the same. You can even place a breakpoint on After starting LongWork and see how a LongWork continuation is actually in the call stack of your Main continuation.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • Thanks a lot Stephen. So really I should not be thinking of threads being 'released to the thread pool', but rather 'are they or are they not currently doing any work?'. A pool thread will simply run synchronously on the work it has been given until it hits an await, upon which it will sign up a continuation and yield to the caller. Once it has done all it can do (i.e. Done everything before another 'await'), it will no longer be doing any work, and will be considered 'available' by the thread pool. – Eza Dec 24 '22 at 05:56
  • 1
    I think it's fine to think of them being released to the thread pool. This happens whenever they run out of work to do. – Stephen Cleary Dec 24 '22 at 13:01
  • Thanks. Just to confirm my understanding, this is what is happening in my example: Thread 1 (Th1) enters Main and starts SW1/2. SW awaits, so continuation of the 2nd part of the method is scheduled, and control is released back to Main, which awaits the 50ms delay. A continuation of Main up to and including the next await is scheduled. Control goes to caller of Main, and Th1 gets blocked there E.g. Main().Wait(). The 50ms delay finishes and Th5 picks up the continuation, starting LW1. It awaits WhenAll, so it schedules the continuation. Th5 work is done - it is released to the pool. Repeat. – Eza Dec 24 '22 at 14:27
  • Similarly when the ShortWork delay completes, a thread comes and does the continuation, and marks the task as complete. The thread then returns to the pool. – Eza Dec 24 '22 at 14:28
  • That is a correct way of thinking about it. Technically, there is an implementation detail where any task completion runs its continuations synchronously if possible on the thread completing the task, so the thread completing `ShortWork` will continue executing any code `await`ing that task if possible. But as I noted above, that's not behavior you can depend on. – Stephen Cleary Dec 24 '22 at 16:01
  • Thanks, your answer to [this question](https://stackoverflow.com/a/59691044/11966116) helped clarify how the synchronous continuation works. Additionally I should note that the continuation is scheduled at the time the awaited task completes, not at the time the await is called (when the asynchronous wait starts), as explained [here](https://stackoverflow.com/a/22350157/11966116) - `Later, when the await task completes, the remainder of the method is scheduled to run in that context.` – Eza Dec 24 '22 at 20:10
0

What actually happens is that when a pool thread starts, it takes a task from the pool's work queue, waiting if necessary. It then executes the task, and then takes a new one, again waiting if necessary. This is repeated until the thread determines that it has to die and exits.

While the thread is taking a new task, or waiting for one, you could say that it has been "released to the thread pool", but that's not really useful or helpful. Nothing from the pool actually does things to the threads, after starting them up. The threads control themselves.

When you write an async function, the compiler transforms it in a way that divides it into many tasks, each of which will be executed by a function call. Where you write Task.Delay(), what actually happens is that your function schedules the task that represents the remainder of its execution, and then returns all the way out to the thread's main procedure, allowing it to get a new task from the thread pool's work queue.

Matt Timmermans
  • 53,709
  • 3
  • 46
  • 87
  • "the thread determines that it has to die" - the thread does no such thing. That's a misleading statement. – Enigmativity Dec 24 '22 at 05:06
  • In what way do "The threads control themselves"? – Enigmativity Dec 24 '22 at 05:08
  • @Enigmativity in C# the core of the thread pool seems to be implemented in the VM, so I can't direct you to that source. In the java version, this is where the thread decides to die: https://github.com/openjdk-mirror/jdk7u-jdk/blob/master/src/share/classes/java/util/concurrent/ThreadPoolExecutor.java#L1103 – Matt Timmermans Dec 24 '22 at 13:35
  • That code doesn't show anything to do with the thread "deciding" to die. In any case, this question is about C#, not Java. – Enigmativity Dec 25 '22 at 01:06