- Multithreading can increase performance on single processor systems that simulate concurrency when one thread cannot proceed, another can use the processor
- We discuss many applications of concurrent programming. When programs download large files, such as audio clips or video clips over the Internet, users do not want to wait until an entire clip downloads before starting the playback. To solve this problem, we can put multiple threads to work one thread downloads a clip, while another plays the clip. These activities proceed concurrently. To avoid choppy playback, we synchronize the threads so that the player thread does not begin until there is a sufficient amount of the clip in memory to keep the player thread busy
- Another example of multithreading is the CLR’s automatic garbage collection. C and C++ require programmers to reclaim dynamically allocated memory explicitly. The CLR provides a garbage-collector thread, which reclaims dynamically allocated memory that is no longer needed
- Set an object reference to null when the program no longer needs that object. This enables the garbage collector to determine at the earliest possible moment that the object can be garbage collected. If such an object has other references to it, that object cannot be collected
Thread States: Life Cycle of a Thread:
- A Thread object begins its life cycle in the Unstarted state when the program creates the object and passes a ThreadStart delegate to the object’s constructor. The TtreadStart delegate, which specifies the actions the thread will perform during its life cycle, must be initialized with a method that returns void and takes no arguments. [Note: .NET 2.0 also includes a ParameterizedThreadStart delegate to which you can pass a method that takes arguments. For more information, visit the site msdn2.microsoft.com/en-us/library/xzehzsds.] The thread remains in the Unstarted state until the program calls the Thread’s Start method, which places the thread in the Running state and immediately returns control to the part of the program that called Start. Then the newly Running thread and any other threads in the program can execute concurrently on a multiprocessor system or share the processor on a system with a single processor
- While in the Running state, the thread may not actually be executing all the time. The thread executes in the Running state only when the operating system assigns a processor to the thread. When a Running thread receives a processor for the first time, the thread begins executing the method specified by its ThreadStart delegate
- A Running thread enters the Stopped (or Aborted) state when its ThreadStart delegate terminates, which normally indicates that the thread has completed its task. Note that a program can force a thread into the Stopped state by calling Thread method Abort on the appropriate Thread object. Method Abort throws a ThreadAbortException in the thread, normally causing the thread to terminate. When a thread is in the Stopped state and there are no references to the thread object, the garbage collector can remove the thread object from memory. [Note: Internally, when a thread’s Abort method is called, the thread actually enters the AbortRequested state before entering the Stopped state. The thread remains in the AbortRequested state while waiting to receive the pending ThreadAbortException. When Abort is called, if the thread is in the WaitSleepJoin, Suspended or Blocked state, the thread resides in its current state and the AbortRequested state, and cannot receive the ThreadAbortException until it leaves its current state.]
- A thread is considered Blocked if it is unable to use a processor even if one is available. For example, a thread becomes blocked when it issues an input/output (I/O) request. The operating system blocks the thread from executing until the operating system can complete the I/O request for which the thread is waiting. At that point, the thread returns to the Running state, so it can resume execution. Another case in which a thread becomes blocked is in thread synchronization. A thread being synchronized must acquire a lock on an object by calling Monitor method Enter. If a lock is not available, the thread is blocked until the desired lock becomes available. [Note: The Blocked state is not an actual state in .NET. It is a conceptual state that describes a thread that is not Running.]
- There are three ways in which a Running thread enters the WaitSleepJoin state. If a thread encounters code that it cannot execute yet (normally because a condition is not satisfied), the thread can call Monitor method Wait to enter the WaitSleepJoin state. Once in this state, a thread returns to the Running state when another thread invokes Monitor method Pulse or PulseAll. Method Pulse moves the next waiting thread back to the Running state. Method PulseAll moves all waiting threads back to the Running state
- A Running thread can call Thread method Sleep to enter the WaitSleepJoin state for a period of milliseconds specified as the argument to Sleep. A sleeping thread returns to the Running state when its designated sleep time expires. Sleeping threads cannot use a processor, even if one is available
- Any thread that enters the WaitSleepJoin state by calling Monitor method Wait or by calling Thread method Sleep also leaves the WaitSleepJoin state and returns to the Running state if the sleeping or waiting Thread’s Interrupt method is called by another thread in the program. The Interrupt method causes a ThreadInterruptionException to be thrown in the interrupted thread
- If a thread cannot continue executing (we will call this the dependent thread) unless another thread terminates, the dependent thread calls the other thread’s Join method to "join" the two threads. When two threads are "joined," the dependent thread leaves the WaitSleepJoin state and re-enters the Running state when the other thread finishes execution (enters the Stopped state).
- If a Running Thread’s Suspend method is called, the Running thread enters the Suspended state. A Suspended thread returns to the Running state when another thread in the program invokes the Suspended thread’s Resume method. [Note: Internally, when a thread’s Suspend method is called, the thread actually enters the SuspendRequested state before entering the Suspended state. The thread remains in the SuspendRequested state while waiting to respond to the Suspend request. If the thread is in the WaitSleepJoin state or is blocked when its Suspend method is called, the thread resides in its current state and the SuspendRequested state, and cannot respond to the Suspend request until it leaves its current state.] Methods Suspend and Resume are now deprecated and should not be used
- If a thread’s IsBackground property is set to true, the thread resides in the Background state. A thread can reside in the Background state and any other state simultaneously. A process must wait for all foreground threads (threads not in the Background state) to finish executing and enter the Stopped state before the process can terminate. However, if the only threads remaining in a process are Background threads, the CLR terminates each thread by invoking its Abort method, and the process terminates
- Every thread has a priority in the range between ThreadPriority.Lowest to ThreadPriority.Highest. These values come from the Thread Priority enumeration (namespace System.Threading), which consists of the values Lowest, Below Normal, Normal, AboveNormal and Highest. By default, each thread has priority Normal.
- The Windows operating system supports a concept, called time slicing, that enables threads of equal priority to share a processor. Without time slicing, each thread in a set of equal-priority threads runs to completion (unless the thread leaves the Running state and enters the WaitSleepJoin, Suspended or Blocked state) before the thread’s peers get a chance to execute. With time slicing, each thread receives a brief burst of processor time, called a quantum, during which the thread can execute. At the completion of the quantum, even if the thread has not finished executing, the processor is taken away from that thread and given to the next thread of equal priority, if one is available
- The job of the thread scheduler is to keep the highest-priority thread running at all times and, if there is more than one highest-priority thread, to ensure that all such threads execute for a quantum in round-robin fashion
- thread’s priority can be adjusted with the Priority property, which accepts values from the ThreadPriority enumeration. If the value specified is not one of the valid thread-priority constants, an ArgumentException occurs.
- A thread executes until it dies, becomes Blocked for I/O (or some other reason), calls Sleep, calls Monitor method Wait or Join, is pre-empted by a thread of higher priority or has its quantum expire. A thread with a higher priority than the Running thread can become Running (and hence pre-empt the first Running thread) if a sleeping thread wakes up, if I/O completes for a thread that Blocked for that I/O, if either Pulse or PulseAll is called on an object on which Wait was called, if a thread is Resumed from the Suspended state or if a thread to which the high-priority thread was joined completes
- See the Article Solution: Project "Multithreading (Get Started)"
Thread Synchronization and Class Monitor:
- Often, multiple threads of execution manipulate shared data. If threads with access to shared data simply read that data, then any number of threads could access that data simultaneously and no problems would arise. However, when multiple threads share data and that data is modified by one or more of those threads, and then indeterminate results may occur. If one thread is in the process of updating the data and another thread tries to update it too, the data will reflect only the later update. If the data is an array or other data structure in which the threads could update separate parts of the data concurrently, it is possible that part of the data will reflect the information from one thread while part of the data will reflect information from another thread. When this happens, the program has difficulty determining when the data has been updated properly
- The problem can be solved by giving one thread at a time exclusive access to code that manipulates the shared data. During that time, other thread wishing to manipulate the data should be kept waiting. When the thread with exclusive access to the data completes its data manipulations, one of the waiting threads should be allowed to proceed. In this fashion, each thread accessing the shared data excludes all other threads from doing so simultaneously. This is called mutual exclusion or thread synchronization
- C# uses the .NET Framework’s monitors to perform synchronization. Class Monitor provides the methods for locking objects to implement synchronized access to shared data. Locking an object means that only one thread can access that object at a time. When a thread wishes to acquire exclusive control over an object, the thread invokes Monitor method Enter to acquire the lock on that data object. Each object has a SyncBlock that maintains the state of that object’s lock. Methods of class Monitor use the data in an object’s SyncBlock to determine the state of the lock for that object. After acquiring the lock for an object, a thread can manipulate that object’s data. While the object is locked, all other threads attempting to acquire the lock on that object are blocked from acquiring the locksuch threads enter the Blocked state. When the thread that locked the shared object no longer requires the lock, that thread invokes Monitor method Exit to release the lock. This updates the SyncBlock of the shared object to indicate that the lock for the object is available again. At this point, if there is a thread that was previously blocked from acquiring the lock on the shared object, that thread acquires the lock to begin its processing of the object. If all threads with access to an object attempt to acquire the object’s lock before manipulating the object, only one thread at a time will be allowed to manipulate the object. This helps ensure the integrity of the data
- Deadlock occurs when a waiting thread (let us call this thread1) cannot proceed because it is waiting (either directly or indirectly) for another thread (let us call this thread2) to proceed, while simultaneously thread2 cannot proceed because it is waiting (either directly or indirectly) for thread1 to proceed. Two threads are waiting for each other, so the actions that would enable either thread to continue execution never occur
- lock ( objectReference )
// code that requires synchronization goes here
- if a thread that owns the lock on an object determines that it cannot continue with its task until some condition is satisfied, the thread should call Monitor method Wait and pass as an argument the object on which the thread will wait until the thread can perform its task. Calling method Monitor.Wait from a thread releases the lock the thread has on the object that Wait receives as an argument and places that thread in the WaitSleepJoin state for that object. A thread in the WaitSleepJoin state of a specific object leaves that state when a separate thread invokes Monitor method Pulse or PulseAll with that object as an argument. Method Pulse transitions the object’s first waiting thread from the WaitSleepJoin state to the Running state. Method PulseAll transitions all threads in the object’s WaitSleepJoin state to the Running state. The transition to the Running state enables the thread (or threads) to get ready to continue executing
- There is a difference between threads waiting to acquire an object’s lock and threads waiting in an object’s WaitSleepJoin state. Threads that call Monitor method Wait with an object as an argument are placed in that object’s WaitSleepJoin state. Threads that are simply waiting to acquire the lock enter the conceptual Blocked state and wait until the object’s lock becomes available. Then, a Blocked thread can acquire the object’s lock
- Monitor methods Enter, Exit, Wait, Pulse and PulseAll all take a reference to an object usually keyword this as their argument
- A thread in the WaitSleepJoin state cannot re-enter the Running state to continue execution until a separate thread invokes Monitor method Pulse or PulseAll with the appropriate object as an argument. If this does not occur, the waiting thread will wait forever essentially the equivalent of deadlock
- When multiple threads manipulate a shared object using monitors, ensure that if one thread calls Monitor method Wait to enter the WaitSleepJoin state for the shared object, a separate thread eventually will call Monitor method Pulse to transition the thread waiting on the shared object back to the Running state. If multiple threads may be waiting for the shared object, a separate thread can call Monitor method PulseAll as a safeguard to ensure that all waiting threads have another opportunity to perform their tasks. If this is not done, indefinite postponement or deadlock could occur
- Synchronization to achieve correctness in multithreaded programs can make programs run more slowly, as a result of monitor overhead and the frequent transitioning of threads between the WaitSleepJoin and Running states. There is not much to say, however, for highly efficient, yet incorrect multithreaded programs!
Producer/Consumer Relationship without Thread Synchronization:
- In a producer/consumer relationship, the producer portion of an application generates data and the consumer portion of an application uses that data. In a multithreaded producer/consumer relationship, a producer thread calls a produce method to generate data and place it in a shared region of memory, called a buffer. A consumer thread calls a consume method to read that data. If the producer wishes to put the next data in the buffer but determines that the consumer has not yet read the previous data from the buffer, the producer thread should call Wait. Otherwise, the consumer would never see the previous data, which would be lost to that application. When the consumer thread reads the data, it should call Pulse to allow a waiting producer to proceed, since there is now free space in the buffer. If a consumer thread finds the buffer empty or finds that the previous data has already been read, the consumer should call Wait. Otherwise, the consumer might read "garbage" from the buffer, or the consumer might process a previous data item more than once each of these possibilities results in a logic error in the application. When the producer places the next data into the buffer, the producer should call Pulse to allow the consumer thread to proceed and read that data
- See the Article Solution: Project "ProducerConsumerWithoutSync"
- To solve the problems of lost data and data consumed more than once in the previous project, we will synchronize the access of the concurrent producer and consumer threads to the code that manipulates the shared data by using Monitor class methods Enter, Wait, Pulse and Exit. When a thread uses synchronization to access a shared object, the object is locked, so no other thread can acquire the lock for that shared object at the same time
Producer/Consumer Relationship with Thread Synchronization:
- Forgetting to release the lock on an object when that lock is no longer needed is a logic error. This will prevent the threads in your program from acquiring the lock to proceed with their tasks. These threads will be forced to wait (unnecessarily, because the lock is no longer needed). Such waiting can lead to deadlock and indefinite postponement
- See the Article Solution: Project "ProducerConsumerWithSync"
Producer/Consumer Relationship: Circular Buffer:
- To minimize the waiting for threads that share resources and operate at the same relative speeds, we can implement a circular buffer that provides extra locations in which the producer can place values (if it "gets ahead" of the consumer) and from which the consumer can retrieve those values (if it "catches up" to the producer). Let us assume the buffer is implemented as an array. The producer and consumer work from the beginning of the array. When either thread reaches the end of the array, it simply returns to the first element of the array to perform its next task. If the producer temporarily produces values faster than the consumer can consume them, the producer can write additional values in the extra buffers (if cells are available; otherwise, the producer must, once again, wait). This enables the producer to perform its task even though the consumer is not ready to receive the current value being produced. Similarly, if the consumer consumes faster than the producer produces new values, the consumer can read additional values from the buffer (if there are any; otherwise, the consumer must, once again, wait) and thus "catch up" to the producer. This enables the consumer to perform its task even though the producer is not ready to produce additional values.
- Note that a circular buffer would be inappropriate if the producer and consumer operate at different speeds. If the consumer always executes faster than the producer, then a buffer with one location is enough. Additional locations would waste memory. If the producer always executes faster, a buffer with an infinite number of locations would be required to absorb the extra production.
- The key to using a circular buffer is to define it with enough extra cells to handle the anticipated "extra" production. If, over a period of time, we determine that the producer often produces as many as three more values than the consumer can consume, we can define a buffer of at least three cells to handle the extra production. We do not want the buffer to be too small, because that would cause threads to wait more. On the other hand, we do not want the buffer to be too large, because that would waste memory
- The key to using a circular buffer is optimizing the buffer size to minimize the amount of thread wait time
- Using a lock block to manage the lock on a synchronized object eliminates the possibility of forgetting to relinquish the lock with a call to Monitor method Exit. C# implicitly calls Monitor method Exit when a lock block terminates for any reason. Thus, even if an exception occurs in the block, the lock will be released
Multithreading with GUIs:
- The nature of multithreaded programming prevents you from knowing exactly when a thread will execute. Windows form components are not thread safeif multiple threads manipulate a Windows GUI component, the results may not be correct. To ensure that threads manipulate GUI components in a thread-safe manner, all interactions with GUI components should be performed by the User Interface thread (also known as the UI thread)the thread that creates and maintains the GUI. Class Control provides method Invoke to help with this process. Method Invoke specifies GUI processing statements that the UI thread should execute. The method receives as its arguments a delegate representing a method that will modify the GUI and an optional array of objects representing the parameters to the method. At some point after Invoke is called, the UI thread will execute the method represented by the delegate, passing the contents of the object array as the method’s arguments
- In the GUI threading project, The Exit method of class System.Environment with the ExitCode property as argument. This causes all other threads in this application to terminate. Otherwise, only the UI thread would be terminated when the user closes this application; Thread1, Thread2 and Thread3 would continue executing forever
- See the Article Solution: Project "MultithreadingWithGUIs"
- In this chapter, you learned basic capabilities of the .NET framework that enable you to specify concurrent tasks in your programs. We discussed how to create threads of execution using class THRead and ThreadStart delegates both from the System.Threading namespace.
- We discussed several applications of concurrent programming. In particular, you learned about problems that may occur when multiple threads share the same data. We demonstrated how to synchronize threads using the capabilities of class Monitor to ensure that data is accessed an manipulated properly by multiple threads. We also showed how to implement shared data as a circular buffer to enable threads to operate more efficiently.
Next, you learned that GUI components are not thread safe, so all changes to GUI components should be performed in the user interface thread that creates and maintains the GUI. We showed how to use Control method Invoke and a delegate to allow a thread to specify tasks that the user interface thread should perform on GUI components. This enabled multiple threads to modify GUI components in a thread-safe manner