Modern systems are becoming more powerful with multiple processors. Similarly, modern server application development has also improved to leverage the host CPU power for processing multiple requests in parallel to achieve more scalability.
Earlier we had many limitations on asynchronous calls and not utilizing the complete CPU cores. The Task Parallel Library (TPL) that was introduced in the .NET Framework 4 made rapid changes to our asynchronous development and utilization of infrastructure.
Still, most of the developers are not very familiar with understanding these keywords and make regular mistakes during usage. Here, we will concentrate more on understanding how these keywords work with asynchronous calls. At the end we will see some of the recommendations needed for regular usage.
Every .NET/.NET Core application is associated with a thread pool and is a collection of threads that effectively executes asynchronous calls of an application. These threads on the thread pool are known as Worker threads.
Threadpool will work as a software-level thread management system for the .NET runtime. It is a queuing mechanism for all application requests.
When the application is initiated, the .NET runtime will spawn the worker threads and keep them in the thread pool queue as available threads. Once any request comes, the runtime will pick one worker thread from this queue and assign this incoming request to it and it will be retained to the queue once this request process is completed. The number of threads the runtime will spawn depends on how many logical processors are available on the host system by default. If needed we can increase the minimum worker threads to spawn by using the below statement at the startup of the application.
ThreadPool.SetMinThreads(100, 100);
The first parameter is the number of worker threads that need to be created and the second parameter defines the completion port thread count. Please find my other article on completion ports in detail here.
Let us understand Async, Await and Task keywords in detail here with the following example code block.
public async Task<decimal> TotalGiftBudget()
{
decimal totalBudgetAmount = 0;
decimal percentageAmount = GetGiftPercentage();
List<Employee> lstEmployees = new List<Employee>();
//Fetch all active employees from Hyderabad Branch
List<Employee> lstHyd = await GetHyderabadEmployeesListAsync();
lstEmployees.AddRange(lstHyd);
//Fetch all active employees from Banglore Branch
List<Employee> lstBglre = await GetBangloreEmployeesListAsync();
lstEmployees.AddRange(lstBglre);
foreach(var emp in lstEmployees)
{
totalBudgetAmount += (emp.Salary * percentageAmount) / 100.0m;
}
return totalBudgetAmount;
}
Let say a company decided to pay a Christmas gift to all their employees with a certain percentage of their monthly salary. Before announcing to the employees they want to check the budget for this bonus amount. This asynchronous method will give this information by fetching all the employees from their Hyderabad and Bangalore branches asynchronously and calculating the expected gift amount based on each employee's salary.
When this method is called, the following actions will happen at the runtime.
When runtime finds the async keyword, it will allocate some memory on the RAM to store the state information of the method.
Next, Runtime will divide this method into three parts as follows
Once it executes or processes part-1 and founds asynchronous statement to get employees from the Hyderabad branch, then immediately creates an I/O thread and returns the Task object which points to its IOCP and the current worker thread will be retained to the available worker threads queue i.e., thread pool so that runtime will utilize this worker thread to serve other requests.
Once the Hyderabad list is ready (for example, it might get from an external API) our completion port will keep this in its queue. The await keyword is used so that the runtime will keep getting the status of the I/O process using the GetQueuedCompletionStatus function and once it is ready, a new available worker thread will be allocated to continue the rest of the actions. This new worker thread will fetch the state machine information from the RAM and continue the remaining process of the method.
In our case, once the list of Hyderabad is available and added to the listEmployee list and again runtime found another asynchronous statement and repeat the same as above. Once we get the Bangalore list too, there is no other asynchronous statement and this time runtime will complete the next actions to calculate the total gift amount and returns successfully.
Here the point of interest is until we get the list of employees from Hyderabad and Bangalore the worker thread is retained and utilized to serve other requests. In the case of a synchronous call, the worker thread will be blocked until it returns the budget amount. Here is the advantage of these asynchronous calls using async/await keywords to improve the performance of the application and to make it more scalable.
Recommendations
If possible avoid the Async keyword and use only Task. When we use Async, some memory will be allocated to the Ram and it will impact the performance. For example, the below code doesn’t need any async and await.
publc Task<int> GetSum(int a, int b)
{
int c = a + b;
return c;
}
In case, if we have a method non-async and we need to call an async method. The following statements are very BAD and they will block the thread and it will lead to perf issues.
Task.Result; //bad
Task.Wait(); //bad
Task.GetAwaiter().GetResult(); //bad
Instead, we can write like below
Public string Getsomething()
{
var task1 = Do1Async();
var task2 = Do2Async();
Task.WaitAll(new[] {task1, task2});
//or
Task.WhenAll(new[] {task1, task2});
}
Public async Task<string> do1Async()
{}
Public async Task<string> do2Async()
{}
Hope this article is helpful to you in understanding the best usage of Async, Await and Task keywords in an asynchronous world.
Happy Coding :)