Parallel Programming Using TPL in .NET

Introduction

With today's computers, we have multiple cores that must be equipped to design and develop applications that can utilize these resources. We must develop programs that can run our functions in parallel to best utilize these hardware features. With Microsoft .NET, we have many different APIs that can be used to do parallel programming to speed up the execution of our programs. Today, we will look at one of the most common and powerful libraries used for this purpose, the Task Parallel Library or TPL.

What is the TPL?

We must have all heard about threading, in which we can create multiple threads to run multiple tasks. We can consider the TPL a wrapper or higher-level abstraction over threading. It provides us with many useful and easy-to-use features to run functions in parallel and improve the performance of our applications. We will learn by example in this article. We will be building a console application in .NET Core 3.1 and will cover the following topics.

  1. Creating and Running Tasks
  2. Waiting on Tasks to complete
  3. Getting Results from a Task
  4. Canceling a Task
  5. Handling Exceptions in a Task
  6. Access control to common code areas

So, let's begin.

Creating and Running Tasks

Let's look at the below code.

using System;
using System.Threading.Tasks;

namespace ParallelProgrammingProject
{
    class Program
    {
        static void Main(string[] args)
        {
            // First method to create and start a Task using TPL
            Task.Factory.StartNew(() =>
            {
                Console.WriteLine("This is the first method to create and start a task using TPL");
            });

            // Second method to create a Task using TPL
            var task = new Task(() =>
            {
                Console.WriteLine("This is the second method to create a task using TPL");
            });
            // Next, we have to start the Task
            task.Start();

            Console.Write("Program Complete...");
            Console.ReadKey();
        }
    }
}

Here, you see there are two methods to create a new task. In the first, we both create and start the task whereas in the second we first create the task. Then we run it. Once this application is run, we can see the following.

Two methods

If we run the application again, we get the following.

Run Application

You can see that the order in which we see the output changes. It is not in the order it is written in the code, as the three writes to the console are being executed on separate threads in parallel.

Waiting on Tasks to Complete

Sometimes, we would like to wait for the tasks to be completed before we proceed to the next step. This will be a blocking operation and the next line of code will only execute after the task has been completed, as shown below.

using System;
using System.Threading.Tasks;

namespace ParallelProgrammingProject
{
    class Program
    {
        static void Main(string[] args)
        {
            // First method to create and start a Task using TPL
            var task1 = Task.Factory.StartNew(() =>
            {
                Console.WriteLine("This is the first method to create and start a task using TPL");
            });

            task1.Wait();

            // Second method to create a Task using TPL
            var task2 = new Task(() =>
            {
                Console.WriteLine("This is the second method to create a task using TPL");
            });
            // Next, we have to start the Task
            task2.Start();

            task2.Wait();

            Console.Write("Program Complete...");
            Console.ReadKey();
        }
    }
}

When we run this application, we get the below screenshot.

Second program run

We can run this program multiple times and always get the same output. The reason for this is that after we start task one, we wait for it to complete and then we start task 2 and then we wait for it to finish. This sort of makes the application sequential and defeats the purpose of using tasks. However, I have just used this for demo purposes.

Also, we might need to use this when we want the result from one task and need to use this in the second task. Next, let us look at returning values from tasks.

Getting Results from a Task

Many times, we would need to get some results from a task. Let's look at how this is done.

using System;
using System.Threading.Tasks;

namespace ParallelProgrammingProject
{
    class Program
    {
        static void Main(string[] args)
        {
            // First method to create and start a Task using TPL
            var task1 = Task.Factory.StartNew<string>(() =>
            {
                Console.WriteLine("This is the first method to create and start a task using TPL");
                return "Hello";
            });

            var value1 = task1.Result;

            // Second method to create a Task using TPL
            var task2 = new Task<string>((str) =>
            {
                Console.WriteLine("This is the second method to create a task using TPL");
                return $"{str} World!";
            }, value1);

            // Next, we have to start the Task
            task2.Start();

            var value2 = task2.Result;

            Console.WriteLine($"{value2}");

            Console.Write("Program Complete...");
            Console.ReadKey();
        }

    }
}

In the above example, we first create a task which returns a string type. We then get the result of the task and store it in a variable. Please note that getting the result is a blocking operation and will pause the application until the result is received. Next, we pass this value as a parameter to the next task and wait for the result of the second task. Finally, we print the result of the second task on the screen.

Canceling a Task

Next, we will see how we can cancel a running task. For this, we will use a cancellation token source and a cancellation token, as shown below.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ParallelProgrammingProject
{
    class Program
    {
        static void Main(string[] args)
        {
            var cts = new CancellationTokenSource();

            // First method to create and start a Task using TPL
            var task1 = Task.Factory.StartNew(() =>
            {
                for (var i = 0; i < 10; i++)
                {
                    if (cts.Token.IsCancellationRequested)
                        break;
                    Console.WriteLine($"The Number is {i.ToString()}");
                    Thread.Sleep(2000);
                }
            }, cts.Token);

            Console.ReadKey();

            cts.Cancel();

            Console.WriteLine("Program Complete...");

        }

    }
}

Here, we see that we create a cancellation token source. Then we create a cancellation token from it and pass that to the task. Now, as the task is running and printing numbers on the screen, if we click any key, the cancel process is initiated and the loop breaks on the line where we are checking to see if the cancellation has been requested. Please note that we can add the cancellation at multiple points in the task. When the control gets to that point and sees that a cancellation has been requested, it will execute the action specified.

Handling Exceptions in a Task

In the previous example, we saw that when we cancel a task, we simply call the break statement to end the loop. However, a much better way to cancel a task is to throw an exception. Below is an example of throwing an exception from a task and how to handle it.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ParallelProgrammingProject
{
    class Program
    {
        static void Main(string[] args)
        {
            var task1 = new Task(() =>
            {
                for (var i = 0; i < 10; i++)
                {
                    if (i > 5)
                        throw new Exception();

                    Console.WriteLine($"The Number is {i.ToString()}");
                    Thread.Sleep(2000);
                }
            });

            try
            {
                task1.Start();
                task1.Wait();
            }
            catch (AggregateException ae)
            {
                ae.Handle(e =>
                {
                    Console.WriteLine("An exception has been thrown by the task");
                    return true;
                });
            }

            Console.WriteLine("Program Complete...");
        }
    }
}

Here, we see that if we added the try-catch inside the task, it would not be visible to the main program. Therefore, the solution is to add it around the Wait call. This will then propagate the exception thrown by the task to the catch statement. We can then use the aggregate exception type to check the exception and handle it.

Access Control to Common Code Areas

Finally, I would like to show how to access areas of code that could be called from multiple tasks. We need to ensure that if a piece of code is being called from multiple tasks then the results remain correct and the results are not messed up. Let's look at the below example.

using System;
using System.Threading.Tasks;

namespace ParallelProgrammingProject
{
    class Program
    {

        public static int total = 0;

        static void AddValues()
        {
            for (var i = 0; i < 1000000; i++)
            {
                total += i;
            }
        }

        static void RemoveValues()
        {
            for (var i = 0; i < 1000000; i++)
            {
                total -= i;
            }
        }

        static void Main(string[] args)
        {
            var task1 = new Task(AddValues);
            var task2 = new Task(RemoveValues);

            task1.Start();
            task2.Start();

            Task.WaitAll(new Task[] { task1, task2 });

            Console.WriteLine($"The value of total is {total}");

            Console.WriteLine("Program Complete...");
        }
    }
}

If we run the above code, we would expect to see the final total as zero. However, we see the below screen.

Incorrect value

The reason for this is that,

  1. Total += i
  2. Total -= i

These are not atomic. They consist of two statements where the value is first stored in a temporary variable and then assigned back to the variable total. Hence, when such a statement is called from multiple threads/tasks, we see that the results are messed up. To fix this, we need to add a locking mechanism to ensure that these lines are only called by one thread at a time. For this, we use the lock mechanism below.

using System;
using System.Threading.Tasks;

namespace ParallelProgrammingProject
{
    class Program
    {

        public static readonly Object codelock = new Object();
        public static int total = 0;

        static void AddValues()
        {
            for (var i = 0; i < 1000000; i++)
            {
                lock (codelock)
                    total += i;
            }
        }

        static void RemoveValues()
        {
            for (var i = 0; i < 1000000; i++)
            {
                lock (codelock)
                    total -= i;
            }
        }

        static void Main(string[] args)
        {
            var task1 = new Task(AddValues);
            var task2 = new Task(RemoveValues);

            task1.Start();
            task2.Start();

            Task.WaitAll(new Task[] { task1, task2 });

            Console.WriteLine($"The value of total is {total}");

            Console.WriteLine("Program Complete...");
        }
    }
}

Now, when we run the application, we always will receive the below output.

Output

Summary

In this article, I tried to discuss the Task Parallel Library (TPL) and how we can use it to do parallel programming. Running tasks in parallel is especially important these days when we have hardware that has multiple cores and we need to utilize these to make our application perform better. However, parallel programming comes with several tricky situations, like the one we saw where we need to apply a locking mechanism to ensure accurate results.