Asynchronous Programming

Asynchronous Programming

Introduction

In this article, I will discuss Asynchronous Programming in C#, including all related definitions and how to use it to move your application to the next level.

Before we dive into Asynchronous Programming in C#, it is important to understand some key related concepts that will help us grasp what Asynchronous Programming is.

Definitions

Sequential programming:?Sequential programming is a model where instructions are executed one at a time without any concurrency. It involves following a step-by-step sequence of instructions, making it easy to understand. However, it can be slow due to its linear execution nature.

Concurrency:?Concurrency involves executing multiple tasks simultaneously, which contrasts with the linear execution of sequential programming. It encompasses various forms, with threads being a fundamental concept. Threads represent independent sequences of instructions that can execute concurrently with our code.

Multithreading:?Multithreading involves utilizing multiple threads concurrently. It's essential to note that being multithreaded doesn't necessarily imply parallelism. Even on a single-core processor, multithreading is possible, as the operating system can manage multiple threads and execute them sequentially without true parallelism.

Parallelism:?It is running several threads simultaneously. This requires a multicore processor. Since parallelism uses multiple threads, parallelism uses multithreading. However, as we said, we can have multithreading without parallelism. In this case, typically what we have is called multitasking.

Multitasking:?With multitasking, we can have several tasks running in such a way that we execute their different threads sequentially, typically with some type of Task Execution System. This is handled at the operating system level. For example, if we have a program A with threads one and two and a program B with threads three and four, and we try to execute both programs at the same time, it could be that the system executes the threads in the order one three two, and four.

It looks like there was parallelism, but there really wasn’t as the threads did not run simultaneously, but in sequence. The computer is so fast that the human eyes could not see that the task was executed in sequence.

With that being said let's discuss the key differences between parallel programming and asynchronous programming...


Parallel programming refers to the use of multiple threads simultaneously to solve a set of tasks. For this, we need processors with adequate abilities to perform several tasks simultaneously. In general, we use parallel programming to gain speed.

Asynchronous programming refers to the efficient use of threads where we do not block a thread unnecessarily. But while we wait for the result of an operation, the thread gets to perform other tasks in the meantime. This increases vertical scalability and allows us to prevent the user interface from freezing during long tasks.

The previous image illustrates that async programming can run on a single core, while parallel programming requires multiple cores.

So keep in mind that Asynchronous programming does not improve the speed of the processes since there is no way that from our system however, it aims to prevent blocking a thread during tasks like waiting for responses, whether from external systems like web services or the computer's file management system.

With Asynchronous Programming, we can serve more HTTP requests on our web server and each request is handled by a thread and multiple threads can handle multiple HTTP Requests by avoiding thread blocking.

Now what is the thread in the first place ?

Well, a thread is a sequence of instructions that can be executed independently of other code.


Async and Await in C#

When using the async keyword, the method must return either a Task or Task<T>. A Task represents an asynchronous operation that doesn't return anything. On the other hand, Task<T> acts like a promise that the method will return a value of the data type T in the future.

So what is the Task?

In C#, Task is a class used for asynchronous programming, allowing operations like I/O or network requests to occur without blocking the main thread.

Await means the thread is free to go to do another thing and then he will come back when this operation is done (We will observe how this works in the code examples).

It is important to realize that await does not mean that the thread will have to be blocked waiting for the operation.

Code Examples

Using Async - Await

The first example will cover the use of async and await, including the underlying behavior when using or not using them.

Open Visual Studio. Then, create a new empty console application...

Console.WriteLine("MAIN STARTED");
SayHello();
Console.WriteLine("MAIN ENDED");
Console.ReadKey();


async void SayHello()
{
        Console.WriteLine("HELLO STARTED");
	await WaitingMethod();
	Console.WriteLine("HELLO ENDED");
}

async Task WaitingMethod()
{
	await Task.Delay(TimeSpan.FromSeconds(5));
	Console.WriteLine("WAITING ENDED");	
}        

In the previous example, I created an asynchronous method named "SayHello". Inside this method, I called another method called "WaitingMethod". The purpose of "WaitingMethod" is to cause a delay in execution for 5 seconds. Additionally, I added necessary logs to keep track of the flow of execution.

"Let's review the output and provide an explanation for it."

MAIN STARTED
HELLO STARTED
MAIN ENDED
WAITING ENDED
HELLO ENDED        

You might be wondering why the "MAIN ENDED" statement appeared before the "HELLO ENDED" statement, even though "SayHello" was called first !!

When we call the "WaitingMethod" inside the "SayHello" method, it causes a delay of 5 seconds (similar to waiting for a response from an API). This delay does not block the main thread from continuing its operations. Instead, the main thread is free to perform other tasks while the instructions in the main method continue to execute.

That's why we see the "MAIN ENDED" statement appear before the "HELLO ENDED" statement

Remember that the await keyword does not block the thread while the execution process continues. The thread remains free to execute other tasks.

You might also be wondering about the role of the "await" keyword before calling the "WaitingMethod" or delaying execution for 5 seconds !!

Well, to understand what is the specific role Let's remove the "await" keyword here: await WaitingMethod();, and observe the outcome:

MAIN STARTED
HELLO STARTED
HELLO ENDED
MAIN ENDED
WAITING ENDED        

As you may have noticed, the "SayHello" method does not wait for the "WaitingMethod" to finish executing before continuing. This is where the "await" keyword becomes useful. By marking a method with "await", the execution process is paused until the method finishes executing and returns a result.

Keep in mind that the await keyword does not block the thread while the execution process continues. The thread remains free to execute other tasks.

Execute Multiple Tasks

In the upcoming example, we will discuss techniques for executing multiple Tasks more efficiently to improve performance.

In the same solution, we will create another console application named "MultipleTasks" which will have the following:

We'll add CreditCard class

public class CreditCard
{
   public string Name { get; set; }
   public string Number { get; set; }

   public static List<CreditCard> GenerateCreditCards(int number)
   {
	List<CreditCard> creditCards = new List<CreditCard>();
	for(int i = 0;i < number;i++)
	{
		var creditCard = new CreditCard
		{
			Name = $"CreditCard-{i + 1}",
			Number = $"0000-{i + 1}"
		};
		creditCards.Add(creditCard);
	}
	return creditCards;
   }
}        

CreditCard class will have two properties: "Name" and "Number". It will also have a static method that expects a number to generate credit cards, which will be returned as a list.

Now, let's take a look at the code in the "Program.cs" file. We'll break it down into smaller units:


  • ProcessCard: It is an asynchronous method that expects a card as input and returns a task of a string containing the card information. A log statement has also been added for testing purposes.
  • ProcessCreditCards: This method takes a list of credit cards as input, and we have added a stopwatch to measure its execution time. We then loop through each card and process its information.

"Let's run the application and discuss the results."

Console.WriteLine("MAIN THREAD STARTED");

List<CreditCard> creditCard = CreditCard.GenerateCreditCards(10);

ProcessCreditCards(creditCard);

Console.WriteLine("MAIN THREAD ENDED");
Console.ReadLine();


async Task<string> ProcessCard (CreditCard card)
{
	await Task.Delay(TimeSpan.FromSeconds(1));

	string message = $"Name:{card.Name}\nNumber:{card.Number}";
	Console.WriteLine($"Credit Card Number: {card.Number} Processed");
	return message;
}

async void ProcessCreditCards (List<CreditCard> cards)
{
	var stopwatch = new Stopwatch();
	stopwatch.Start();

	var tasks = new List<Task<string>>();

	foreach (var card in cards)
	{
		var response = ProcessCard(card);
		tasks.Add(response);
	}

	await Task.WhenAll(tasks);

	stopwatch.Stop();

Console.WriteLine($"Processing of {cards.Count} Credit Cards Done in {stopwatch.ElapsedMilliseconds / 1000.0} Seconds");
}        
MAIN THREAD STARTED
MAIN THREAD ENDED
Credit Card Number: 0000-1 Processed
Credit Card Number: 0000-2 Processed
Credit Card Number: 0000-4 Processed
Credit Card Number: 0000-3 Processed
Credit Card Number: 0000-6 Processed
Credit Card Number: 0000-7 Processed
Credit Card Number: 0000-8 Processed
Credit Card Number: 0000-9 Processed
Credit Card Number: 0000-10 Processed
Credit Card Number: 0000-5 Processed
Processing of 10 Credit Cards Done in 1.005 Seconds        

Conclusions:

  • The "MAIN THREAD ENDED" statement appeared before method logs because the thread is not blocked, as previously explained.
  • Credit card transactions are not processed in a specific order due to the concurrent (at the same time) execution of multiple tasks.

But you might be wondering how we delay the execution for each processing operation for 1 second in the ProcessCard method, while the final execution time is just slightly over one second. How is this even possible?

The "Task.WhenAll()" method is magical because it allows us to execute all of our tasks concurrently, which means that we don't have to wait for each task to execute individually. By marking it with the "await" keyword, the execution process will pause until all of the tasks have returned a response.


"To observe the impact of not using 'Task.WhenAll()', we can introduce a small modification."

async void ProcessCreditCards (List<CreditCard> cards)
{
	var stopwatch = new Stopwatch();
	stopwatch.Start();

	foreach (var card in cards)
	{
	    var response = await ProcessCard(card);
	}

	stopwatch.Stop();

	Console.WriteLine($"Processing of {cards.Count} Credit Cards Done in {stopwatch.ElapsedMilliseconds / 1000.0} Seconds");
}        

What we did here is remove Task.WhenAll() from ProcessCreditCards method. Let's see the result and discuss it.

MAIN THREAD STARTED
MAIN THREAD ENDED
Credit Card Number: 0000-1 Processed
Credit Card Number: 0000-2 Processed
Credit Card Number: 0000-3 Processed
Credit Card Number: 0000-4 Processed
Credit Card Number: 0000-5 Processed
Credit Card Number: 0000-6 Processed
Credit Card Number: 0000-7 Processed
Credit Card Number: 0000-8 Processed
Credit Card Number: 0000-9 Processed
Credit Card Number: 0000-10 Processed
Processing of 10 Credit Cards Done in 10.008 Seconds        

As you may have noticed, our tasks are not being executed concurrently. This is because we are waiting for each processing operation to be completed before moving on to the next one. As a result, the total execution time is equal to the sum of all the processing operation times.

After implementing Task.WhenAll() with multiple tasks, we can observe a significant increase in performance.


What if we slightly increase the number of credit cards to 20000? How would that affect the execution time?

We added an additional stopwatch to the main method and removed all log statements from the ProcessCard method.

"The main method should now look like this."

var stopwatch = new Stopwatch();
stopwatch.Start();
Console.WriteLine("MAIN THREAD STARTED");

List<CreditCard> creditCard = CreditCard.GenerateCreditCards(20000);

ProcessCreditCards(creditCard);

Console.WriteLine("MAIN THREAD ENDED");
stopwatch.Stop();
Console.WriteLine($"Main Thread Execution Time {stopwatch.ElapsedMilliseconds / 1000.0} Seconds");

Console.ReadLine();        

Now, let's see the output result

MAIN THREAD STARTED
MAIN THREAD ENDED
Main Thread Execution Time 1.581 Seconds
Processing of 20000 Credit Cards Done in 2.558 Seconds        

As you may have noticed from the output, the main is frozen for around 1.5 seconds. This occurs because the "ProcessCreditCards" method contains a "foreach" statement that loops for 20000 iterations. This process takes some time until the "await Task.WhenAll()" command is executed, after which the thread becomes active and begins processing.

To resolve this problem, we can utilize the Task.Run() asynchronous method in C# using a delegate. This will move the foreach loop to a separate thread, allowing it to execute concurrently with other tasks.

By using the await keyword, we can free up the main thread to continue executing other tasks.

async void ProcessCreditCards (List<CreditCard> cards)
{
	var stopwatch = new Stopwatch();
	stopwatch.Start();

	var tasks = new List<Task<string>>();

	await Task.Run(() =>
	{
		foreach(var card in cards)
		{
			var response = ProcessCard(card);
			tasks.Add(response);
		}
	});


	await Task.WhenAll(tasks);

	stopwatch.Stop();

	Console.WriteLine($"Processing of {cards.Count} Credit Cards Done in {stopwatch.ElapsedMilliseconds / 1000.0} Seconds");
}        

"Now the output will be:"

MAIN THREAD STARTED
MAIN THREAD ENDED
Main Thread Execution Time 0.03 Seconds
Processing of 20000 Credit Cards Done in 2.589 Seconds        

Limit the number of concurrent tasks

Previously, we attempted to generate 20000 cards. This is a significant number, especially when it comes to the number of HTTP requests sent to a server. The server may not be able to handle all of these requests, which could lead to it being blocked or going down.

If you're unfamiliar with the SemaphoreSlim class, it is useful for synchronizing actions within a single application. Its primary function is to restrict the number of threads that can access a shared resource or pool of resources simultaneously, which can help prevent issues with resource contention.

Instead of processing 20000 tasks simultaneously, we can process them in batches using the C# class SemaphoreSlim. with SemaphoreSlim, we can limit the number of concurrent tasks that will be executed with the Task.WhenAll() method.

If you're unfamiliar with the SemaphoreSlim class, it is useful for synchronizing actions within a single application.

Its primary function is to restrict the number of threads that can access a shared resource or pool of resources simultaneously, which can help prevent issues with resource contention.

The SemaphoreSlim class comprises two primary methods that we should comprehend to understand how they function.

The Release() and WaitAsync() methods have prototypes and overloads that you can find below.

Firstly we need to understand that when we instantiate a SemaphoreSlim, we can set the maximum number of threads that are allowed to access the critical section concurrently.

SemaphoreSlim semaphore = new SemaphoreSlim(3);        

Here Our resource can only be processed by a maximum of three threads at a time.

To enter the SemaphoreSlim, a thread must call one of the Wait or WaitAsync methods.

If a thread calls the WaitAsync() method on a SemaphoreSlim instance, it will attempt to acquire a slot in the semaphore. If the semaphore has slots available (which means the maximum count has not been reached), the method will return a task that represents an asynchronous wait operation. The task will be completed when the semaphore slot is acquired, allowing the thread to continue executing.

If all slots in the semaphore are currently taken, the calling thread will be put in a wait state until a slot becomes available or until a timeout occurs, depending on the overload used. This is a non-blocking operation; it won't cause the calling thread to block while waiting.

To release the SemaphoreSlim, the thread has to call one of the Release() methods.

The count of threads that can enter the critical section concurrently is decremented each time a thread enters the SemaphoreSlim and incremented each time a thread releases the SemaphoreSlim.

Now Let's proceed with implementing that in our application...

We are going to process 15 cards with an initial capacity of the SemaphoreSlim of 3. This means that we will have 5 batches, and each batch will include the processing of cards.

SemaphoreSlim semaphore = new SemaphoreSlim(3);

var stopwatch = new Stopwatch();
stopwatch.Start();
Console.WriteLine("MAIN THREAD STARTED");

List<CreditCard> creditCard = CreditCard.GenerateCreditCards(15);

ProcessCreditCards(creditCard);

Console.WriteLine("MAIN THREAD ENDED");
stopwatch.Stop();
Console.WriteLine($"Main Thread Execution Time {stopwatch.ElapsedMilliseconds / 1000.0} Seconds");

Console.ReadLine();        

We have defined an instance of SemaphoreSlim type with an initial capacity of 3.

In the ProcessCreditCards method, we will have a significant change. We are using an async lambda expression for all the cards inside our list of cards because we are using the WaitAsync() method.

async void ProcessCreditCards(List<CreditCard> cards)
{
	var stopwatch = new Stopwatch();
	stopwatch.Start();

	var tasks = new List<Task<string>>();

	tasks = cards.Select(async card =>
	{
		await semaphore.WaitAsync();

		try
		{
			return await ProcessCard(card);
		}
		finally
		{
			semaphore.Release();
		}
	}).ToList();


	await Task.WhenAll(tasks);

	stopwatch.Stop();

	Console.WriteLine($"Processing of {cards.Count} Credit Cards Done in {stopwatch.ElapsedMilliseconds / 1000.0} Seconds");
}        

In the ProcessCreditCards method, we will have a significant change. We are using an async lambda expression for all the cards inside our list of cards because we are using the WaitAsync() method.

await semaphore.WaitAsync();        

This previous line tells us to wait for one of the semaphore tasks to be released before another task can acquire SemaphoreSlim if there are more than four tasks running.


{
      return await ProcessCard(card);
}
finally
{
      semaphore.Release();
}        

By adding a try-finally block, we can ensure that the card is processed only if the thread has acquired the SemaphoreSlim. When the thread proceeds, the finally block is executed to release the SemaphoreSlim, allowing other threads to enter the critical section. The finally block will also be executed if any exception occurs.

After calling Task.WhenAll(), the tasks will be executed in batches of three instead of all at once.

To run our program and add the log statement inside the ProcessCard method...

async Task<string> ProcessCard(CreditCard card)
{
	await Task.Delay(TimeSpan.FromSeconds(1));

	string message = $"Name:{card.Name}\nNumber:{card.Number}";
	Console.WriteLine($"Credit Card Number: {card.Number} Processed");
	return message;
}        
MAIN THREAD STARTED
MAIN THREAD ENDED
Main Thread Execution Time 0.02 Seconds
Credit Card Number: 0000-1 Processed
Credit Card Number: 0000-2 Processed
Credit Card Number: 0000-3 Processed
Credit Card Number: 0000-4 Processed
Credit Card Number: 0000-5 Processed
Credit Card Number: 0000-6 Processed
Credit Card Number: 0000-7 Processed
Credit Card Number: 0000-8 Processed
Credit Card Number: 0000-9 Processed
Credit Card Number: 0000-10 Processed
Credit Card Number: 0000-12 Processed
Credit Card Number: 0000-11 Processed
Credit Card Number: 0000-13 Processed
Credit Card Number: 0000-15 Processed
Credit Card Number: 0000-14 Processed
Processing of 15 Credit Cards Done in 5.013 Seconds        

You will notice that the processing is done in batches, meaning that every three cards are processed simultaneously. The final processing time is almost 5 seconds, which means every batch takes almost 1 second.


Handle Response when Executing Multiple Tasks

I have finished the article, and I want to emphasize that you can manage all the tasks once they are completed by using the Task.WhenAll() method.

If you hover over the 'await' keyword before Task.WhenAll(tasks), you will notice that it returns an array of strings. This is because the ProcessCard method also returns a Task of string.

Now you can save the return data in an array of strings, (which can be considered as JSON data in real-world examples). You can then loop through this string and execute your logic.

string[] strings = await Task.WhenAll(tasks);

foreach (string s in strings)
{
      Console.WriteLine(s);
}        

Thank you for reading my article. Stay tuned for part 2 where we will continue our journey in Asynchronous Programming.

I hope this was helpful!

The example code is available on GitHub.

Mohamed Sameh

Full Stack .NET Developer | C# | Asp.net Core | Angular | SQL | Software Developer | Web Development | Backend Developer

7 个月
回复
Volodymyr Haievyi

C# developer, Azure cloud engineer | 19+ years in .NET | 10+ years in Full Stack

9 个月

Yeah ?? Async/await is a powerful tool. Here, I experimented to use it for long-running processes, I mean really long, like years https://gaevoy.com/2019/01/30/process-manager-as-async-function.html

回复
Reham Ahmed

--Hr Specialist in 4explan Company

9 个月

Bravo ????

Mostafa Elnady

Senior Software Engineer at Link Development | Full Stack .Net Developer | Angular | SQL Server | Microservices | ITI | React | c# | kafka | elastic search | freelancer

9 个月

The DotNet shark ????

Mohammed Alaa

.NET Developer | CS & ITI Graduate

9 个月

Thanks for sharing.

要查看或添加评论,请登录

Mohamed Sameh的更多文章

  • Memory Management in .NET

    Memory Management in .NET

    In this article, I am going to talk about a common topic that many developers might not have a clear idea about. We…

  • API Gateway with .NET Core

    API Gateway with .NET Core

    Direct client-to-microservice communication Before delving into the intricacies of an API gateway, let's first explore…

    12 条评论
  • gRPC with .NET Core

    gRPC with .NET Core

    HTTP/2 Protocol Before discussing gRPC, it's a good idea to introduce HTTP/2 and its features, since gRPC relies on it.…

    7 条评论

社区洞察

其他会员也浏览了