Optimize Multithreaded Apps
.NET makes creating multithreaded programs easy, but you need a proper design to prevent them from running too slowly.
by Bill Wagner
October 2003 Issue
Technology Toolbox: C#
The .NET Framework library provides mechanisms for creating and destroying threads, and for synchronizing thread access to critical resources. You need to create a design that maximizes multiple threads' performance to use these tools effectively. Creating a multithreaded program can cause more bottlenecks, because you introduce more complexity into the program. This is especially true when you create multiple threads that must communicate with one another. The extra processing time for communication between threads, and time when threads are blocked, can have a noticeable effect on your program's performance. If you don't have a compelling reason to create multiple threads, don't do it, because it'll introduce a level of complexity that will only slow down your development cycle.
However, you often have valid reasons to create multiple threads. Multithreaded programs give the illusion of doing more than one task at the same time. It's an illusion unless the app runs on a multiprocessor system: The runtime swaps between the threads you create. You typically create multiple threads when your program has a lengthy task to perform and you need to continue processing user input during that time. Delegating the lengthy tasks to another thread lets your UI continue to respond to input from users. Your design must consider how multiple threads communicate—how and how often you'll retrieve results from the worker thread. I'll walk you through a sample that shows the profound impact these design decisions can have on your program's performance.
The sample is a WinForms app that calculates all the prime numbers smaller than 10,000,000. You have no quick way to calculate whether a number is a prime number; brute force is the only option. You simply divide the number by all the smaller prime numbers until you reach the square root of the number in question. For example, you test if 11 is prime by dividing it by 2 and 3. The result of 5 times 5 is 25, so you don't need to test if 5 is a factor of 11: If it were, you would have found a factor smaller than 5 already. The testing can take some time when you get to large numbers, such as 930,157. This is clearly a case for threads. You fire off one thread to calculate the prime numbers, then update a listbox in the main thread with a list of all the primes.
You update the listbox periodically by using the producer/consumer idiom to push data from the worker thread to the UI thread. The producer/consumer idiom uses a shared buffer that a Monitor object protects to push data from the worker thread to the UI thread. The producer (worker thread) fills the buffer. Once data is in the buffer, the worker thread signals the consumer thread by pulsing a Monitor object. The consumer (UI thread) reads the data now. The consumer then pulses the same Monitor, which signals the producer that it can write more data. This forces a sequence of actions to occur. First, the worker thread fills the buffer. (The UI thread cannot read.) Second, the worker thread signals the UI thread. (The worker thread cannot write.) Third, the UI thread reads the buffer. (The worker thread cannot write.) Fourth, the UI thread signals the worker thread. (The worker thread can write; the UI thread cannot read.) These steps repeat, starting with the first one, until the producer thread has completed all its work (see Listing 1).
Synchronize in the Buffer Class
Listing 1 shows the pertinent routines in the sample. The worker thread calls AddValues to fill the buffer, and the UI thread calls readValues to read the buffer. These two methods are part of a buffer class that acts as a pipeline between the producer and the consumer. All synchronization takes place in this class; neither the producer nor the consumer uses synchronization code. This kind of design is preferable for two important reasons. First, the producer/consumer idiom is a common construct you'll use often for creating multithreaded programs. Second, it isolates the single biggest cause of bottlenecks in multithreaded programs: contention for shared resources. Notice in the preceding series of actions that either the producer or the consumer is locked out of the shared buffer most of the time. This has a profound effect on the time it takes to finish the task. The sample lets you experiment with different buffer sizes to see this effect in action.
Back to top
|