C++/CLI supports the ability to create multiple threads of execution within a single program. Last month, we saw how threads are created and synchronized. This month, we'll see how shared variables can be guarded against compromise during concurrent operations, learn about thread-local storage, and we'll look at interlocked operations.
Other Forms of Synchronization
We can control synchronization of threads directly by using a number of functions in classes Monitor
and Thread
. Listing One contains an example.
Listing One
using namespace System; using namespace System::Threading; int main() { /*1*/ MessageBuffer^ m = gcnew MessageBuffer; /*2a*/ ProcessMessages^ pm = gcnew ProcessMessages(m); /*2b*/ Thread^ pmt = gcnew Thread(gcnew ThreadStart(pm, &ProcessMessages::ProcessMessagesEntryPoint)); /*2c*/ pmt->Start(); /*3a*/ CreateMessages^ cm = gcnew CreateMessages(m); /*3b*/ Thread^ cmt = gcnew Thread(gcnew ThreadStart(cm, &CreateMessages::CreateMessagesEntryPoint)); /*3c*/ cmt->Start(); /*4*/ cmt->Join(); /*5*/ pmt->Interrupt(); /*6*/ pmt->Join(); Console::WriteLine("Primary thread terminating"); } public ref class MessageBuffer { String^ messageText; public: void SetMessage(String^ s) { /*7*/ Monitor::Enter(this); messageText = s; /*8*/ Monitor::Pulse(this); Console::WriteLine("Set new message {0}", messageText); Monitor::Exit(this); } void ProcessMessages() { /*9*/ Monitor::Enter(this); while (true) { try { /*10*/ Monitor::Wait(this); } catch (ThreadInterruptedException^ e) { Console::WriteLine("ProcessMessage interrupted"); return; } Console::WriteLine("Processed new message {0}", messageText); } Monitor::Exit(this); } }; public ref class CreateMessages { MessageBuffer^ msg; public: CreateMessages(MessageBuffer^ m) { msg = m; } void CreateMessagesEntryPoint() { for (int i = 1; i <= 5; ++i) { msg->SetMessage(String::Concat("M-", i.ToString())); Thread::Sleep(2000); } Console::WriteLine("CreateMessages thread terminating"); } }; public ref class ProcessMessages { MessageBuffer^ msg; public: ProcessMessages(MessageBuffer^ m) { msg = m; } void ProcessMessagesEntryPoint() { msg->ProcessMessages(); Console::WriteLine("ProcessMessages thread terminating"); } };
In case 1, a shared buffer of type MessageBuffer
is created. In cases 2a, 2b, and 2c, a thread is created and started such that it processes each message placed in that buffer. Cases 3a, 3b, and 3c create and start a thread that causes a series of five messages to be put into the shared buffer for processing. The two threads are synchronized such that the processor can't process the buffer until the creator has put something there, and the creator can't put another message there until the previous one has been processed. In case 4, we wait until the creator thread has completed its work.
By the time case 5 executes, the processor thread should have processed all of the messages the creator put there, so we tell it to stop work by interrupting it using Thread::Interrupt
. We then wait on that thread in case 6 by calling Thread::Join
, which allows the calling thread to block itself until some other thread terminates. (Instead of waiting indefinitely, a thread can specify a maximum time that it will wait.)
The CreateMessages
thread is quite straightforward. It writes five messages to the shared message buffer, waiting two seconds between each one. To suspend a thread for a given amount of time (in milliseconds), we call Thread::Sleep
. A sleeping thread is resumed by the runtime environment rather than by another thread.
The ProcessMessages
thread is even simpler because it has the MessageBuffer
class do all its work. Class MessageBuffer
's functions are synchronized because only one of them at a time can have access to the shared buffer.
The main program starts the processor thread first. As such, that thread starts executing ProcessMessages
, which causes the parent object's synchronization lock to be obtained. However, it immediately runs into a call to Wait
in case 10, which causes it to wait until it is told to continue; however, it also gives up its hold on the synchronization lock in the meantime, allowing the creator thread to obtain the synchronization lock and to execute SetMessage
. Once that function has put the new message in the shared buffer, it calls Pulse
in case 8, which allows any one thread waiting on that lock to wake up and resume operation. However, this cannot happen until SetMessage
completes execution because it doesn't give up its hold on the lock until that function returns. Once that happens, the processor thread regains the lock, the wait is satisfied, and execution resumes beyond case 10. A thread can wait indefinitely or until a specified amount of time has lapsed. For completeness, the output is shown in Figure 1.
Figure 1: Output of Listing One.
Set new message M-1 Processed new message M-1 Set new message M-2 Processed new message M-2 Set new message M-3 Processed new message M-3 Set new message M-4 Processed new message M-4 Set new message M-5 Processed new message M-5 CreateMessages thread terminating ProcessMessage interrupted ProcessMessages thread terminating Primary thread terminating
Note carefully that the processor thread was started before the creator thread. If they were started in the opposite order, the first message would be added, yet no processor thread would be waiting, so no processor thread is woken up. By the time the processor thread gets to its first call to Wait
, it will have missed the first message and will only be woken up when the second one has been stored.
Managing Threads
By default, a thread is a foreground thread that executes until its entry-point function terminates, regardless of the life span of its parent. On the other hand, a background thread automatically terminates when its parent terminates. We configure a thread as being a background thread by setting Thread
's property IsBackground
. A background thread can also be made a foreground thread by the same approach.
Once a thread has been started, it is alive. We can test for this by inspecting Thread
's property IsAlive
. A thread can give up the rest of its CPU time slice by calling Wait
with a time of zero milliseconds. A thread can get at its own Thread
object via the property CurrentThread::Thread::CurrentThread
.
Each thread has a priority level associated with it and this is used by the runtime environment to schedule the execution of threads. A thread's priority can be set or tested via the property Thread::Priority
. Priorities range from ThreadPriority::Lowest
to ThreadPriority::Highest
. By default, a thread has priority ThreadPriority::Normal
. Because thread scheduling varies from one implementation to another, we should not rely too heavily on priority levels as a means of controlling threads.