Thread Synchronization
Any time multiple threads are running within an application, you run the risk of accessing the same data at the same time. While one thread is writing data to a variable, another thread could also be doing the same. As you can see, this type of behavior is undefined and can lead to any number of problems. To solve this, you must use thread synchronization.
For this application you will use the .NET Framework class Monitor to control how your two threads synchronize with each other. The Monitor class is similar to using critical sections in which once a lock is obtained on an object, no other thread can obtain the lock until it released by the original thread. To obtain a lock on an object, you can use the static member function Enter found within the Monitor class. The parameter to this function is a managed class. Once the lock has been obtained on that class, no other thread will be able to obtain the lock until the Exit method is called, contained within the Monitor class. There are two ways to obtain a lock on an object. In the first method, which was just explained, Enter will wait for the object to be released and then attempt to lock the object. However, there is no guarantee that the next time the object is released, the thread wishing to obtain the lock will acquire it. The other method for obtaining a lock is by using the TryEnter function. This is similar to using Enter, but if an object is already locked, the TryEnter function will immediately return without trying to obtain the lock later.
This application will create a custom data object that has the ability to both read and write data. The object, however, will use synchronization to prevent reading and writing at the same time by using methods within the Monitor class. To begin with, create a managed class named IntDataBlock. This class will have two functions in addition to the default constructor. The first function is used to write the data. However, because this is a simulation, no actual data will be written. Give this function the name WriteData. It accepts an integer parameter named nData and does not return a data type. The second function is used to read the data just written. This function is named ReadData and accepts no parameters but returns an integer. You can use Listing 19.2 to aid you when you are entering in these functions.
Because this is a multithreaded application and the act of reading data infers that data has actually been written first, you have to prevent the case where data might be read before it is written. To do this, you will need to create a Boolean member variable that is initialized to false so that the data class in guaranteed to write before it reads. Create a private Boolean variable and initialize it to false within the default constructor. So, what happens if the ReadData function is called before the WriteData function and the ReadData has already acquired the lock on the IntDataBlock object? The Monitor class contains a method to temporarily suspend the lock acquisition and give control of the lock to the next object in the waiting queue. Therefore, if the lock is first obtained within the ReadData class, ReadData will relinquish control of the lock to the WriteData method, which is trying to acquire it by calling the Monitor method Wait. You can see how this works by looking at line 50 of Listing 19.2.
Listing 19.2 Creating a Multithread-Aware Data Object
1: #include "stdafx.h"
2:
3: #using <mscorlib.dll>
4: #include <tchar.h>
5:
6: using namespace System;
7: using namespace System::Threading;
8:
9: public __gc class IntDataBlock
10: {
11: public:
12: IntDataBlock()
13: {
14: m_nLastData = 0;
15: m_bIsReading = false;
16: }
17:
18: void WriteData( int nData )
19: {
20: Monitor::Enter( this );
21: {
22: Console::WriteLine( "Entered Write Monitor" );
23: if( m_bIsReading )
24: {
25: Console::WriteLine( "Write Waiting" );
26: Monitor::Wait( this );
27: }
28: m_nLastData = nData;
29:
30: Console::Write("Writing: {0}...", __box(nData));
31:
32: Console::Write("Done\n");
33:
34: m_bIsReading = true;
35: Console::WriteLine( "Write Pulsing" );
36: Monitor::Pulse(this);
37: }
38: Console::WriteLine( "Leaving Write Monitor" );
39: Monitor::Exit( this );
40: }
41:
42: int ReadData()
43: {
44: Monitor::Enter( this );
45: {
46: Console::WriteLine( "Entered Read Monitor" );
47: if( !m_bIsReading )
48: {
49: Console::WriteLine( "Read Waiting" );
50: Monitor::Wait( this );
51: }
52: Console::WriteLine("Reading: {0}\n", __box(m_nLastData));
53:
54: m_bIsReading = false;
55: Console::WriteLine( "Read Pulsing" );
56: Monitor::Pulse( this );
57: }
58: Console::WriteLine( "Leaving Read Monitor" );
59: Monitor::Exit( this );
60:
61: return m_nLastData;
62: }
63:
64: private:
65: int m_nLastData;
66: bool m_bIsReading;
67: };
The ReadData function, at this point, has acquired the lock and because nothing has been written, it has called the Wait method. Therefore, control of the lock now resides with the WriteData method. Because the object is writing, and therefore the m_bIsReading flag is false, WriteData can perform its simulated data write. Because the WriteData method is finished with the lock, it seems only natural to just release the lock using the Exit method. However, the ReadData method is currently in a waiting state. Simply releasing the lock will not signal to the ReadData method that the lock has been released. To signal a thread that is waiting for a lock, you call the Monitor function named Pulse. This will place the thread that is waiting for the lock in the ready queue. Once the thread that is holding the lock finally releases the lock, the waiting thread will then gain control of it. This back-and-forth passing of control of the lock will guarantee that you are not writing data at the same time you are reading it.
When creating an application using multiple threads, you need to be aware of all the possible execution sequences. In the sample code trace just covered, we used an example in which the ReadData method could obtain the lock first, but this isn't guaranteed. In fact, the WriteData method could obtain the lock first. Due to this, simply using the Enter and Exit methods within the Monitor class may not be good enough. This is the reason for using the Wait and Pulse methods and the bool flag to control the sequence of events.
This process of ensuring that objects aren't being concurrently accessed is known as thread safety and is a topic that should not be taken lightly. Any time you decide to create a threaded application, you need to inspect the code thoroughly to remove any concurrent data-access issues. However, it is possible to take this to an extreme by using thread-safe objects and procedures too much. Keep in mind that as you add thread-safe code, you will start running into performance issues. The first solution you should investigate is to avoid even having to use thread-safety constructs altogether. Try to find a solution that works in a threaded environment without relying on kernel objects or some of the .NET objects created for enforcing thread safety.
In some cases, you can't avoid having to use thread-safe helper functions or objects. If that's the case, use them as minimally as possible. Don't design your application with the mindset that you will make every object thread safe, even if you don't use that object across thread boundaries. Only ensure data-access safety for those objects that will be used by multiple threads.
Top |
No comments:
Post a Comment