I often read that Task
is used for multithreading in C# / .NET, but that is not the case. And it is crucial to understand why this isn't the case. We will also see which problem exactly Task
is solving in the first place.
What is a Task
?
Imagine you are making a pizza. You have all of the ingredients ready, but you need to let the dough rise for a while before you can bake it. While you are waiting for the dough to rise, you can go do other things, like setting the table or watching TV.
The task of letting the dough rise is something that you don't need to constantly check on, so you can just set it aside and come back to it later. This is similar to how a Task
works in C#. It can run in the background while the rest of your program does other things. When the task is finished, it will let you know, and you can continue your work. Very important here is that you were alone all the time. You could do asynchronous work without anyone else. Tasks work similar. It can happen that the work is done one a second thread but it doesn't have to be like that! And frankly, you shouldn't care so much about that in the first place. That is the whole reason behind Task
: An abstraction behind that exact concept.
More technically: In C#, a task represents an asynchronous operation. It allows you to write asynchronous code in a way that is similar to synchronous code, making it easier to read and maintain.
Tasks are not related to multithreading in any way. A task can be executed on a separate thread, but it can also be executed on the same thread as the calling code. The choice of thread is determined by the task scheduler, which is responsible for managing the execution of tasks. Think of technologies like Blazor Client, which uses WebAssembly or even javascript, which has Promise
s (that are very similar to Task
s). Those two technologies (as of now) do only work on one single thread and still they can use Task
s and use asynchronous programming.
We might also see a common pattern here. All the stuff we are awaiting do their stuff on their own and we don't need to do any work. That is the prime example also in .NET/C#. We can distinguish between I/O bound tasks and CPU bound tasks. I/O bound tasks are tasks that spend a lot of time waiting for input or output, like reading from a file or waiting for a website to respond to a request. CPU bound tasks are tasks that spend a lot of time using the processor, like crunching numbers or running a complex calculation.
Imagine you have a bucket of water and a bucket of sand, and you have to pour all of the water into the sand and mix it together. Pouring the water is like an I/O bound task, because you have to wait for the water to come out of the bucket and into the sand. Mixing the water and sand together is like a CPU bound task, because it takes a lot of effort and energy (just like a CPU) to do it.
Why Use Tasks?
PERFORMANCE!!!!!!!!!1111 No, I am just kidding. Task
s are not necessarily used for performance reasons, even though they can be used for that. No the main reason is scalability and I already discussed this a bit here: "ASP.NET Core - Why async await is useful".
Have a look at the following asynchronous controller code:
[HttpGet]
public async Task<IActionResult> GetByIdAsync(int id)
{
var entity = await repository.GetByIdAsync(id);
return Ok(entity);
}
And here the "sync" version of it:
[HttpGet]
public IActionResult GetById(int id)
{
var entity = repository.GetById(id);
return Ok(entity);
}
The sync version is making the pizza but you wait until the dough is ready before doing anything else. The asynchronous version is doing something else directly while you await the dough-readiness. We can utilize the waiting time and accept a second pizza we can do! The same applies to lots of async
and await
calls in the wild out there! It is about utilizing all the resources as best as we can! The requests themselves will not be handled faster when we do them asynchronously ... at least not from the point of view of the user who is requesting something.
And yes this would be one of the prime use cases of a Task
.
TaskScheduler
A TaskScheduler
has nothing to do with multithreading directly. It simply provides a way to specify how and when tasks should be executed. Tasks themselves can be executed on multiple threads, but the TaskScheduler is not responsible for creating or managing those threads. Instead, it simply determines which thread a task should be executed on, based on the scheduling rules defined by the TaskScheduler
class.
There are several built-in TaskScheduler classes in the .NET, such as the ThreadPoolTaskScheduler
, which uses the thread pool to schedule tasks, and the SynchronizationContextTaskScheduler
, which schedules tasks on the thread that the SynchronizationContext
was captured from.
SynchronizationContext
Now we go a bit more in detail and talk about the SynchronizationContext
. I'll explain in simple terms what this is and what it's used for.
Imagine you have a group of friends who are all trying to work on a puzzle together. You have a big table where you can all work, but you also have a bunch of smaller tables where you can work independently.
When you are working on the puzzle, you might find a piece that fits in a certain spot. You would want to go to the big table and put the piece in the right place. However, you don't want everyone to try to put their pieces in at the same time, because it would be confusing and might make the puzzle harder to complete.
Instead, you might decide that only one person can work on the big puzzle at a time. When someone wants to add a piece to the puzzle, they must first ask for permission. If someone else is already working on the puzzle, they have to wait their turn. When it is their turn, they can go to the big table and add their piece.
The SynchronizationContext
works similarly. It helps to coordinate work that needs to be done on specific tasks. This can be useful when you are working with multiple threads in your program, and you want to make sure that certain things happen in a specific order.
For example, you might need a program to update the user interface with new information. However, the UI can only be updated from the main thread. You might get an error if you try to update the UI from a different thread.
To fix this, you can use the SynchronizationContext
to "send" the work back to the main thread. This way, you can ensure that the UI is only updated from the main thread, which can help prevent errors.
Another and much simpler analogy (even though more abstract): The SynchronizationContext
captures the current environment. If we come back we can continue in our environment and not in a completely new one.
The usage you will often see is one of the following 3:
await myTask;
await myTask.ConfigureAwait(true);
await myTask.ConfigureAwait(false);
Now versions 1 and 2 are the same. If you don't specify ConfigureAwait
it is automatically set to true
. In my opinion that was a huge mistake from Microsoft to set the default in that way. Anyway, if you say yes, as said above, your "old environment" gets recreated. If you explicitly say ConfigureAwait(false)
then there is no old environment.
That yields some implications: Returning to the old environment costs! Well, you have to save the old one and have to return to it, but it brings the benefit that you can use stuff that was already there. That is important when you work with the UI, especially on Windows. Windows only allows the main thread to update the UI. So you might need ConfigureAwait
(or elide it completely if you want) so that you are back on the main thread and are allowed to update your UI.
Frameworks like ASP.NET Core
don't have any SynchronizationContext, so omitting the keyword makes almost no difference. Well, almost: For example Blazor Server uses a SynchronzationContext
to emulate the same behavior as Blazor Client: Only having a single thread! Where it makes a big difference is if you are maintaining a library, here you almost always want to add ConfigureAwait(false)
.
Of course, there are also rare cases where you can set your own SynchronzationContext
, but that goes beyond what I wanted to showcase in this blog post.
Conclusion
In conclusion, tasks are an important concept in C# that allows you to write asynchronous code in a simple, synchronous-like syntax. While tasks can be executed on separate threads, they are primarily used for scalability rather than performance.