DEV Community

Keyur Ramoliya
Keyur Ramoliya

Posted on

ThreadPool in C#: Practical Examples

In the previous article, we learned what is ThreadPool in C# and how it works behind the scenes. We also saw some of the benefits and drawbacks of using ThreadPool for concurrent programming. In this article, we will explore some practical examples of using ThreadPool in C# to perform parallel tasks. We will also discuss some of the challenges and best practices of using ThreadPool in C#.

Using ThreadPool in C#: Practical Examples

ThreadPool is a useful class for executing multiple tasks in parallel without creating and managing individual threads. ThreadPool provides a method called QueueUserWorkItem that allows us to queue a work item (a delegate or a lambda expression) to be executed by a thread from the pool. The work item can accept an optional state object as a parameter. For example, the following code queues two work items to the ThreadPool:

using System;
using System.Threading;

namespace ThreadPoolDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            // Queue two work items to the ThreadPool
            ThreadPool.QueueUserWorkItem(WorkItem1, "Hello");
            ThreadPool.QueueUserWorkItem(WorkItem2, 42);

            // Wait for the work items to complete
            Thread.Sleep(1000);

            Console.WriteLine("Main thread exits");
        }

        // A work item that prints a message
        static void WorkItem1(object state)
        {
            Console.WriteLine("WorkItem1: {0}", state);
        }

        // A work item that calculates and prints the square of a number
        static void WorkItem2(object state)
        {
            int num = (int)state;
            int square = num * num;
            Console.WriteLine("WorkItem2: {0} * {0} = {1}", num, square);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The output of the program may vary depending on the order of execution of the work items, but it could look something like this:

WorkItem2: 42 * 42 = 1764
WorkItem1: Hello
Main thread exits
Enter fullscreen mode Exit fullscreen mode

Notice that the main thread will not wait for the work items to finish before exiting. This means that the work items may not complete if the main thread terminates the application. To avoid this, we can use a synchronization mechanism such as a ManualResetEvent or a CountdownEvent to signal the completion of the work items and wait for them in the main thread. For example, the following code uses a CountdownEvent to wait for two work items to finish:

using System;
using System.Threading;

namespace ThreadPoolDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            // Create a CountdownEvent with an initial count of 2
            CountdownEvent countdown = new CountdownEvent(2);

            // Queue two work items to the ThreadPool and pass the countdown as the state object
            ThreadPool.QueueUserWorkItem(WorkItem1, countdown);
            ThreadPool.QueueUserWorkItem(WorkItem2, countdown);

            // Wait for the countdown to reach zero
            countdown.Wait();

            Console.WriteLine("Main thread exits");
        }

        // A work item that prints a message and signals the countdown
        static void WorkItem1(object state)
        {
            Console.WriteLine("WorkItem1");
            CountdownEvent countdown = (CountdownEvent)state;
            countdown.Signal();
        }

        // A work item that prints a message and signals the countdown
        static void WorkItem2(object state)
        {
            Console.WriteLine("WorkItem2");
            CountdownEvent countdown = (CountdownEvent)state;
            countdown.Signal();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The output of the program may look something like this:

WorkItem1
WorkItem2
Main thread exits
Enter fullscreen mode Exit fullscreen mode

In the above code, the main thread waits for the work items to finish before exiting. This ensures that the work items complete their execution.

Sometimes, we may want to queue a work item that returns a result. For this, we can use the Task class, which represents an asynchronous operation that can return a value. The Task class provides a static method called Run that allows us to queue a work item (a delegate or a lambda expression) to the ThreadPool and return a Task object that represents the operation. The Task object has a property called Result that returns the value of the work item when it completes. For example, the following code queues two work items that return the sum and the product of two numbers:

using System;
using System.Threading.Tasks;

namespace ThreadPoolDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            // Queue two work items to the ThreadPool using Task.Run and store the Task objects
            Task<int> task1 = Task.Run(() => Sum(10, 20));
            Task<int> task2 = Task.Run(() => Product(10, 20));

            // Wait for the tasks to complete and get the results
            int result1 = task1.Result;
            int result2 = task2.Result;

            Console.WriteLine("Sum: {0}", result1);
            Console.WriteLine("Product: {0}", result2);

            Console.WriteLine("Main thread exits");
        }

        // A work item that returns the sum of two numbers
        static int Sum(int a, int b)
        {
            return a + b;
        }

        // A work item that returns the product of two numbers
        static int Product(int a, int b)
        {
            return a * b;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The output of the program may look something like this:

Sum: 30
Product: 200
Main thread exits
Enter fullscreen mode Exit fullscreen mode

Now the main thread waits for the tasks to complete and gets the results using the Result property. This property blocks the calling thread until the task finishes. Alternatively, we can use the Wait method to wait for one or more tasks to complete, or the ContinueWith method to execute a continuation task when a task finishes.

Thread Safety and ThreadPool

When using ThreadPool, it is important to ensure thread safety, which means that the data and resources shared by multiple threads are accessed in a consistent and correct manner. Thread safety is essential for avoiding race conditions, deadlocks, and other concurrency issues that can lead to unpredictable and erroneous behavior of the application.

One of the common thread safety challenges when using ThreadPool is to synchronize the access to shared data. For example, consider the following code that uses a shared variable counter to count the number of work items completed by the ThreadPool:

using System;
using System.Threading;

namespace ThreadPoolDemo
{
    class Program
    {
        // A shared variable to count the number of work items completed
        static int counter = 0;

        static void Main(string[] args)
        {
            // Queue 10 work items to the ThreadPool
            for (int i = 0; i < 10; i++)
            {
                ThreadPool.QueueUserWorkItem(WorkItem);
            }

            // Wait for the work items to complete
            Thread.Sleep(1000);

            Console.WriteLine("Counter: {0}", counter);

            Console.WriteLine("Main thread exits");
        }

        // A work item that increments the counter
        static void WorkItem(object state)
        {
            // Increment the counter
            counter++;

            Console.WriteLine("WorkItem: {0}", counter);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The output of the program may vary depending on the order of execution of the work items, but it could look something like this:

WorkItem: 1
WorkItem: 6
WorkItem: 7
WorkItem: 8
WorkItem: 9
WorkItem: 10
WorkItem: 3
WorkItem: 4
WorkItem: 5
WorkItem: 2
Counter: 10
Main thread exits
Enter fullscreen mode Exit fullscreen mode

This output seems correct, but it is not guaranteed. The problem is that the counter++ operation is not atomic, which means that it involves multiple steps: reading the value of counter, adding one to it, and storing the new value back to counter. If two or more threads execute this operation concurrently, they may read the same value of counter, increment it, and overwrite each other's results. This can lead to incorrect and inconsistent values of counter.

To avoid this, we need to use a synchronization mechanism such as a lock statement or an Interlocked method to ensure that only one thread can access the shared variable at a time. For example, the following code uses the Interlocked.Increment method to atomically increment the counter:

using System;
using System.Threading;

namespace ThreadPoolDemo
{
    class Program
    {
        // A shared variable to count the number of work items completed
        static int counter = 0;

        static void Main(string[] args)
        {
            // Queue 10 work items to the ThreadPool
            for (int i = 0; i < 10; i++)
            {
                ThreadPool.QueueUserWorkItem(WorkItem);
            }

            // Wait for the work items to complete
            Thread.Sleep(1000);

            Console.WriteLine("Counter: {0}", counter);

            Console.WriteLine("Main thread exits");
        }

        // A work item that increments the counter
        static void WorkItem(object state)
        {
            // Increment the counter atomically using Interlocked.Increment
            int value = Interlocked.Increment(ref counter);

            Console.WriteLine("WorkItem: {0}", value);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The output of the program may look something like this:

WorkItem: 1
WorkItem: 6
WorkItem: 7
WorkItem: 8
WorkItem: 9
WorkItem: 10
WorkItem: 5
WorkItem: 4
WorkItem: 3
WorkItem: 2
Counter: 10
Main thread exits
Enter fullscreen mode Exit fullscreen mode

Monitoring and Managing ThreadPool

Another challenge when using ThreadPool is to monitor and manage its performance and behavior. ThreadPool has a limited number of threads that it can use to execute work items. If the number of work items exceeds the number of available threads, the work items are queued and wait for a thread to become free. This can affect the responsiveness and throughput of the application.

To monitor and manage ThreadPool, we can use some of the properties and methods provided by the ThreadPool class. For example, we can use the following properties to get or set the minimum and maximum number of threads in the ThreadPool:

  • GetMinThreads and SetMinThreads: These methods get or set the minimum number of threads that the ThreadPool maintains in the pool. The minimum number of threads determines the lower bound of threads that are available to execute work items. Increasing the minimum number of threads can reduce the latency of work items, but it can also increase the resource consumption and contention of the application.
  • GetMaxThreads and SetMaxThreads: These methods get or set the maximum number of threads that the ThreadPool can create in the pool. The maximum number of threads determines the upper bound of threads that are available to execute work items. Decreasing the maximum number of threads can reduce the resource consumption and contention of the application, but it can also increase the latency and queue length of work items.

The following code shows how to use these methods to get and set the minimum and maximum number of threads in the ThreadPool:

using System;
using System.Threading;

namespace ThreadPoolDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            // Get the minimum and maximum number of threads in the ThreadPool
            int minWorkerThreads, minCompletionPortThreads;
            int maxWorkerThreads, maxCompletionPortThreads;
            ThreadPool.GetMinThreads(out minWorkerThreads, out minCompletionPortThreads);
            ThreadPool.GetMaxThreads(out maxWorkerThreads, out maxCompletionPortThreads);

            Console.WriteLine("Minimum worker threads: {0}", minWorkerThreads);
            Console.WriteLine("Minimum completion port threads: {0}", minCompletionPortThreads);
            Console.WriteLine("Maximum worker threads: {0}", maxWorkerThreads);
            Console.WriteLine("Maximum completion port threads: {0}", maxCompletionPortThreads);

            // Set the minimum and maximum number of threads in the ThreadPool
            ThreadPool.SetMinThreads(10, 10);
            ThreadPool.SetMaxThreads(20, 20);

            Console.WriteLine("Main thread exits");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The output of the program may look something like this:

Minimum worker threads: 6
Minimum completion port threads: 1
Maximum worker threads: 32767
Maximum completion port threads: 1000
Main thread exits
Enter fullscreen mode Exit fullscreen mode

Notice that the ThreadPool has two types of threads: worker threads and completion port threads. Worker threads are used to execute normal work items, while completion port threads are used to handle asynchronous I/O operations. The minimum and maximum number of threads can be set separately for each type of thread.

Another method that can be used to monitor and manage ThreadPool is GetAvailableThreads, which returns the number of threads in the ThreadPool that are available to execute work items. This method can be useful to determine the degree of concurrency and utilization of the ThreadPool. For example, the following code uses the GetAvailableThreads method to print the number of available threads before and after queuing 10 work items to the ThreadPool:

using System;
using System.Threading;

namespace ThreadPoolDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            // Get the number of available threads in the ThreadPool
            int availableWorkerThreads, availableCompletionPortThreads;
            ThreadPool.GetAvailableThreads(out availableWorkerThreads, out availableCompletionPortThreads);

            Console.WriteLine("Available worker threads: {0}", availableWorkerThreads);
            Console.WriteLine("Available completion port threads: {0}", availableCompletionPortThreads);

            // Queue 10 work items to the ThreadPool
            for (int i = 0; i < 10; i++)
            {
                ThreadPool.QueueUserWorkItem(WorkItem);
            }

            // Wait for the work items to complete
            Thread.Sleep(1000);

            // Get the number of available threads in the ThreadPool again
            ThreadPool.GetAvailableThreads(out availableWorkerThreads, out availableCompletionPortThreads);

            Console.WriteLine("Available worker threads: {0}", availableWorkerThreads);
            Console.WriteLine("Available completion port threads: {0}", availableCompletionPortThreads);

            Console.WriteLine("Main thread exits");
        }

        // A work item that prints a message and sleeps for 100 milliseconds
        static void WorkItem(object state)
        {
            Console.WriteLine("WorkItem");
            Thread.Sleep(100);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The output of the program may look something like this:

Available worker threads: 32766
Available completion port threads: 1000
WorkItem
WorkItem
WorkItem
WorkItem
WorkItem
WorkItem
WorkItem
WorkItem
WorkItem
WorkItem
Available worker threads: 32766
Available completion port threads: 1000
Main thread exits
Enter fullscreen mode Exit fullscreen mode

Note that the number of available worker threads does not change before and after queuing the work items. This is because the ThreadPool creates new threads as needed to execute the work items, up to the maximum number of threads. However, if the number of work items exceeds the maximum number of threads, the number of available threads will decrease and the work items will be queued.

Best Practices for ThreadPool in C

Using ThreadPool can improve the performance and scalability of the application, but it also requires some care and attention to avoid common pitfalls and errors. Here are some of the best practices for using ThreadPool in C#:

  • Use ThreadPool for short-lived and CPU-bound work items. ThreadPool is designed for executing small and fast tasks that do not block or wait for external resources. If the work items are long-running or I/O-bound, they can consume the threads in the ThreadPool and reduce its responsiveness and throughput. For such work items, it is better to use dedicated threads or asynchronous methods.
  • Avoid modifying the minimum and maximum number of threads in the ThreadPool. The ThreadPool has a dynamic and adaptive algorithm that adjusts the number of threads in the pool based on the workload and system resources. Changing the minimum and maximum number of threads can interfere with this algorithm and degrade the performance and efficiency of the ThreadPool. Unless there is a specific reason to do so, it is better to leave the default settings of the ThreadPool.
  • Use thread-safe data structures and synchronization mechanisms when accessing shared data. As we discussed earlier, thread safety is essential for avoiding concurrency issues when using ThreadPool. To ensure thread safety, we should use data structures and synchronization mechanisms that are designed for concurrent access, such as ConcurrentDictionary, ConcurrentQueue, lock, Interlocked, Monitor, etc.
  • Handle exceptions and errors in the work items. If a work item throws an unhandled exception, it can terminate the thread that executes it and affect the stability of the application. To prevent this, we should use try-catch blocks to catch and handle any exceptions or errors that may occur in the work items. We can also use the Task class to queue work items and handle exceptions using the Exception property or the ContinueWith method.
  • Use cancellation tokens to cancel work items. Sometimes, we may want to cancel a work item that is queued to the ThreadPool, for example, if the user requests to stop the operation or if the application needs to shut down. To cancel a work item, we can use a CancellationToken object that can be passed to the work item as a state object or as a parameter. The work item can check the IsCancellationRequested property of the CancellationToken object and exit gracefully if it is true. We can also use the Task class to queue work items and cancel them using the CancellationTokenSource class or the Cancel method.

Conclusion

In this article, we learned how to use ThreadPool in C# to perform parallel tasks. We saw some practical examples of using ThreadPool to queue work items and retrieve results. We also discussed some of the challenges and best practices of using ThreadPool in C#. ThreadPool is a powerful and convenient class for concurrent programming, but it also requires some care and attention to use it effectively and correctly. I hope that this article has helped you understand and appreciate the ThreadPool in C#.

Top comments (0)