What do you expect from the following code?
Console.WriteLine($"Before Delay: {Environment.CurrentManagedThreadId}");
await Task.Delay(1000);
Console.WriteLine($"After Delay: {Environment.CurrentManagedThreadId}");
Yes, you're right, it is something like:
thead numbers may vary on your run
Before Delay: 1
After Delay: 4
Now let's do some refactoring and move the Task.Delay()
part to another method like CalcAsync()
:
Console.WriteLine($"Before Calc: {Environment.CurrentManagedThreadId}");
await CalcAsync();
Console.WriteLine($"After Calc: {Environment.CurrentManagedThreadId}");
async Task CalcAsync()
{
Console.WriteLine($"In Calc -> Before Delay: {Environment.CurrentManagedThreadId}");
await Task.Delay(1000);
Console.WriteLine($"In Calc -> After Delay: {Environment.CurrentManagedThreadId}");
}
This is the output:
Before Calc: 1
In Calc -> Before Delay: 1
In Calc -> After Delay: 4
After Calc: 4
But why!?
- Why are we still in
Thread 1
when we are in the body ofCalcAsync
method? - Why the thread is switched after
Task.Delay()
? - Any why are we still in
Thread 4
, although we returned back to the calling method?
Let's make it even more weird by replacting the Task.Delay()
with a loop:
Console.WriteLine($"Before Calc: {Environment.CurrentManagedThreadId}");
await CalcAsync();
Console.WriteLine($"After Calc: {Environment.CurrentManagedThreadId}");
async Task CalcAsync()
{
Console.WriteLine($"In Calc -> Before Loop: {Environment.CurrentManagedThreadId}");
var counter = 0;
for (var i = 0; i < 10000; i++) { counter++; }
Console.WriteLine($"In Calc -> After Loop: {Environment.CurrentManagedThreadId}");
}
Can you guess the output?
Before Calc: 1
In Calc -> Before Loop: 1
In Calc -> After Loop: 1
After Calc: 1
Although we are working with different tasks, all the work is being done using just one thread: Tread 1
!!! This is due to the magic of .NET, which allocates threads to tasks as few as possible. If the work is CPU-bound, no thread revoking will occur even if a task is created. If the work is I/O-bound, .NET is smart enough to revoke the thread and wait for the result. In your example, Task.Delay()
simulates an I/O-bound work, allowing .NET to revoke the thread. This approach can improve performance and reduce resource usage.
But you should be careful if you had expected the CalcAsync
method to run in another thread. For example, if you were in a UI application (WinForm or Blazor), the main thread will be busy by the loop, and you may have an unresponsive UI. In this case you should create your task explicitly like:
await Task.Run(CalcAsync);
This will get you an output like this:
Before Calc: 1
In Calc -> Before Loop: 4
In Calc -> After Loop: 4
After Calc: 4
Conclusion
As you can see, .NET is an excellent framework that manages threads and tasks efficiently. It is smart enough to decide when to continue using the current thread and when to revoke it. When you work with Tasks, you let .NET manage how to accomplish those tasks using and allocating threads.
Top comments (1)
Insightful article, especially the part that talks about how .Net allocate threads to I/O or CPU bound.