DEV Community

Cover image for Tasks Are NOT Threads
Mehran Davoudi
Mehran Davoudi

Posted on

Tasks Are NOT Threads

What do you expect from the following code?

Console.WriteLine($"Before Delay: {Environment.CurrentManagedThreadId}");
await Task.Delay(1000);
Console.WriteLine($"After Delay: {Environment.CurrentManagedThreadId}");
Enter fullscreen mode Exit fullscreen mode

Yes, you're right, it is something like:

thead numbers may vary on your run

Before Delay: 1
After Delay: 4
Enter fullscreen mode Exit fullscreen mode

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}");
}
Enter fullscreen mode Exit fullscreen mode

This is the output:

Before Calc: 1
In Calc -> Before Delay: 1
In Calc -> After Delay: 4
After Calc: 4
Enter fullscreen mode Exit fullscreen mode

But why!?

  • Why are we still in Thread 1 when we are in the body of CalcAsync 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}");
}
Enter fullscreen mode Exit fullscreen mode

Can you guess the output?

Before Calc: 1
In Calc -> Before Loop: 1
In Calc -> After Loop: 1
After Calc: 1
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

This will get you an output like this:

Before Calc: 1
In Calc -> Before Loop: 4
In Calc -> After Loop: 4
After Calc: 4
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
ipazooki profile image
Mo

Insightful article, especially the part that talks about how .Net allocate threads to I/O or CPU bound.