Asynchronous vs Multi-threaded in C#

Asynchronous vs Multi-threaded in C#

Understanding Asynchronous and Multithreading Operations under the hood in .NET with C#

Introduction

Understanding the nuances between asynchronous and multithreading operations is crucial for building scalable and responsive applications. Both approaches aim to improve performance and responsiveness by executing tasks concurrently, but they operate differently under the hood, each with its advantages and considerations.

In this blog, we look into the fundamental differences between asynchronous and multithreading operations in .NET using C#. We will explore how threads behave when tasked with handling multiple operations simultaneously and how asynchronous programming aids in efficient resource utilization without blocking the main thread.

Multi-threading basics

Multithreading involves creating multiple threads to execute tasks concurrently. Each thread can run independently, and the operating system handles the scheduling and execution of these threads. It is well-suited for CPU-bound tasks that require a lot of processing power. Also effective for parallelizing heavy computational tasks to utilize multiple CPU cores effectively i.e. dividing the workload among multiple threads can lead to better performance, especially when you have multi-core CPUs.

Multi-threading in Single-core CPU

If we have a single-core processor, we can run one thread at a time. The reason is that a single-core processor can only execute one instruction at a time, and each thread represents a separate sequence of instructions that the CPU must process.

While we can create and manage multiple threads in our C# code, the processor will switch between these threads to give the illusion of parallelism. However, due to the single-core limitation, the CPU can only execute one thread at a time, and the execution of the threads will be interleaved, not truly parallel.

Challenges in Multi-threaded Implementations

It requires careful synchronization to avoid race conditions and deadlocks and is more complex and error-prone due to shared data access. It can also lead to higher memory overhead because each thread requires its stack and resources.

Async Operations basic

Asynchronous programming, on the other hand, allows tasks to be executed without blocking the main thread, enabling the application to remain responsive while performing I/O-bound operations such as file I/O, network calls, or database queries. It provides a way to achieve concurrency without creating additional threads, instead, the execution of code can be paused and resumed when necessary.

Requires fewer threads, resulting in lower memory overhead. Async operations allow the calling thread to be freed up while waiting for the I/O to complete, enabling the CPU to work on other tasks.

In terms of challenges, asynchronous implementation may not be as efficient for CPU-bound tasks, as it does not take full advantage of multiple CPU cores.

Comparison with Code

Inspired by the explanation on Code-Maze, let's try and understand how the thread behaves when they are asked to execute in multi-threaded vs async fashion. The idea is to execute three methods using both the mechanism and see how the CPU handles and assigns threads in each case

static void Main(string[] args)
        {
            Console.WriteLine("Select 1 for Multithreading and 2 for Async");
            int opType = Convert.ToInt16(Console.ReadLine());

            Console.WriteLine("Starting with Id: " + Thread.CurrentThread.ManagedThreadId);

            if (opType == 1)
                SimulateMultiThreading();
            if (opType == 2)
                SimulateAsyncOperation();

            Console.WriteLine("Completed with Id: " + Thread.CurrentThread.ManagedThreadId);
            Console.ReadLine();

        }

I have created a basic console app that simulates multi-threading and async operations based on the user input. To simulate the multi-threaded execution we have the following code

Thread t1 = new Thread(new ThreadStart(DownloadImage));
            Thread t2 = new Thread(new ThreadStart(CompressImage));
            Thread t3 = new Thread(new ThreadStart(SaveImage));

            t1.Start();
            t2.Start();

            Console.WriteLine("Some other task with Id: " + Thread.CurrentThread.ManagedThreadId);

            Thread.Sleep(3000);
            t3.Start();
            Thread.Sleep(3000);

            Console.WriteLine("yet another task with Id: " + Thread.CurrentThread.ManagedThreadId);

Here we are simulating three time-taking methods and creating each of them on a separate thread so that they can be executed in parallel. We have also added some delay just to show a long-running task. At each step, we display the current thread ID to understand which thread is executing which task.

private static void SaveImage()
        {
            Console.WriteLine("Saving with Id: " + Thread.CurrentThread.ManagedThreadId);
            Thread.Sleep(1000);
            Console.WriteLine("Saved with Id: " + Thread.CurrentThread.ManagedThreadId);
        }

        private static void CompressImage()
        {
            Console.WriteLine("Compressing with Id: " + Thread.CurrentThread.ManagedThreadId);
            Thread.Sleep(1000);
            Console.WriteLine("Compressed with Id: " + Thread.CurrentThread.ManagedThreadId);
        }

        private static void DownloadImage()
        {
            Console.WriteLine("Downloading with Id: " + Thread.CurrentThread.ManagedThreadId);
            Thread.Sleep(1000);
            Console.WriteLine("Downloaded with Id: " + Thread.CurrentThread.ManagedThreadId);
        }

On executing the code, we get the following output

Let's now understand the working and thread allotment here.

From the main method, we get to know that the main thread is the one with ID 1 and it is executing the method. Then we create three new threads for each of our three methods and threads with Id 9, 10 & 11 are created for it.

In the DownloadImage processing, we can see it's thread 9 which starts the process. Then we wait for the processing to complete and thread 9 continues to wait and then eventually completes the processing of the method. The same goes for the other two methods as well. The thread that starts the execution completes it no matter how much time it takes for the underlying method to complete.

Once all three processing is complete, we can see that the control is sent back to the main thread 1 and it completes the execution.

This seems pretty straightforward and expected. But now let's see how the same thing is achieved using asynchronous implementation.

static void SimulateAsyncOperation()
        {
            DownloadImageAsync();
            CompressImageAsync();
            Console.WriteLine("Some other task with Id: " + Thread.CurrentThread.ManagedThreadId);
            Thread.Sleep(3000);
            SaveImageAsync();
            Thread.Sleep(3000);
            Console.WriteLine("yet another task with Id: " + Thread.CurrentThread.ManagedThreadId);
        }
private async static Task SaveImageAsync()
        {
            Console.WriteLine("Saving with Id: " + Thread.CurrentThread.ManagedThreadId);
            await Task.Delay(1000); //Task.Delay(1000) represents a simulated asynchronous operation that waits for one second.
            Console.WriteLine("Saved with Id: " + Thread.CurrentThread.ManagedThreadId);
        }
        private async static Task CompressImageAsync()
        {
            Console.WriteLine("Compressing with Id: " + Thread.CurrentThread.ManagedThreadId);
            await Task.Delay(1000);
            Console.WriteLine("Compressed with Id: " + Thread.CurrentThread.ManagedThreadId);
        }
        private async static Task DownloadImageAsync()
        {
            Console.WriteLine("Downloading with Id: " + Thread.CurrentThread.ManagedThreadId);
            await Task.Delay(1000);
            Console.WriteLine("Downloaded with Id: " + Thread.CurrentThread.ManagedThreadId);
        }

Here in the simulated methods, instead of using Thread.Sleep we will be using Task.Delay as async implementations are mainly used for IO operations and that means they are probably executing somewhere else while the process waits for the result.

On running the app, we get the following output

The first thing to notice here is that it takes one less thread to perform the same operation. Let us try and understand how.

Again the execution starts with the main thread with ID 1. The same thread goes and starts the process of the DownloadImageAsync. Once it encounters the await keyword in the DownloadImageAsync it figures out that something time-consuming is going to happen here so no need to wait for it, lets go and continue our work and when this is complete someone will handle it.

Thread 1 goes back and starts working on the CompressImageAsync method. It again encounters await and returns to the calling method i.e. SimulateAsyncOperation. In the meantime, CompressImageAsync processing is complete so the CPU automatically creates a new thread, 9 in this case, and assigns it to complete the remaining operations in the method. Simultaneously, DownloadImageAsync is also complete so a new thread is assigned there as well named 5.

During all this time, 1 was occupied in other tasks else it could itself have gone back and completed the processing instead of 9 and 5. 1 starts the processing of SaveImageAsync but is completed by 9.

The advantage here is that no thread waits for some time-consuming operation to complete, instead, it goes back to the calling method and continues processing keeping track of the time-consuming operation. Once that operation is complete, further processing in that method is continued by any free thread.

Conclusion

Multithreading enables concurrent execution of tasks within the same process, while asynchronous programming facilitates non-blocking I/O operations and improves responsiveness by leveraging asynchronous handlers and the thread pool.

By choosing the right approach based on the nature of the tasks and performance requirements, we can design robust and efficient .NET applications that meet the demands of modern web development.

Eventually, the decision between multithreading and asynchronous operations depends on the nature of the tasks you need to perform. For CPU-bound tasks, where significant processing power is required, multithreading might be more appropriate. For I/O-bound tasks, such as accessing databases or making network calls, asynchronous operations are generally more suitable.

Source Code: https://github.com/rajat-srivas/random_dotnet_concepts

Did you find this article valuable?

Support Rajat Srivastava by becoming a sponsor. Any amount is appreciated!