The simple threading example we've created so far works well because the two tasks work independently. If you need to communicate between threads, life is not as simple. Trying to access an object on more than one thread at once is inherently dangerous. Threading problems are also infamously hard to diagnose because a single command in a high-level language such as Visual Basic .NET might actually compile to dozens of low-level machine-language instructions. Locking enables you to obtain exclusive access to an object that's used by another thread. You can then safely modify the value without worrying that another thread might try to read or change it while your operation is in progress. Locking is built into Visual Basic .NET through the SyncLock statement. To demonstrate locking, we'll consider a simple example of a server-side object. The same technique applies when you design multithreaded clients; before we look at a full client example, however, we need to consider a couple of additional details. Consider an object exposed through .NET Remoting as a singleton. This object maintains a collection of information supplied by clients in an ArrayList. Clients add a piece of information through the AddInformation method. If several clients call this method simultaneously, however, an error can occur, which will likely lead to losing at least one client's information (as shown in Listing 6-12). Listing 6-12 A singleton object without locking supportPublic Class InformationStorage Inherits MarshalByRefObject Private Information As New ArrayList() Public Sub AddInformation(info As String) Information.Add(info) End Sub End Class To solve this problem, the AddInformation method can obtain an exclusive lock on the ArrayList before it adds the object using the SyncLock statement, as shown in Listing 6-13. Listing 6-13 A singleton object with lockingPublic Class InformationStorage Inherits MarshalByRefObject Private Information As New ArrayList() Public Sub AddInformation(info As String) SyncLock Information Information.Add(info) End SyncLock End Sub End Class This approach efficiently solves the problem. The first client will obtain a lock on the Information collection. When another client attempts to access the Information collection, the CLR forces it to pause and wait until the lock is released. The CLR then allows the next waiting client to create its lock and gain exclusive access to the collection. You don't need to worry about keeping track of who made the first call; the CLR takes care of this queuing automatically and notifies clients as an object becomes available. Locking is a common solution that guarantees data integrity with stateful components that are shared by multiple clients. A common question that developers have with locking is whether it reduces performance. Technically, locking always entails some slowdown because it forces clients to wait, effectively making a multithreaded program act like a single-threaded program for a short interval of time. However, the amount of performance slowdown varies dramatically depending on how coarse the lock is, how long it's held for, how many methods use the locked object, and how many clients are using the class that contains the locked object. In the preceding example, the slowdown would be noticeable if hundreds of clients were continuously adding information. However, the situation is dramatically different with the next example (shown in Listing 6-14), which uses SyncLock to safeguard a global counter that records how many times a method is called. In this case, we can't use the SyncLock statement with the GlobalCounter variable because SyncLock works only with reference types, not value types. The only alternative is to lock the entire current instance of the CountInvocations class. This means that clients will not be able to call any methods of the CountInvocations object. Clearly, this lock is much coarser and has a less desirable effect on user concurrency. Listing 6-14 A coarser lockPublic Class CountInvocations Inherits MarshalByRefObject Private GlobalCounter As Integer Public Sub IncrementCounter() SyncLock Me GlobalCounter += 1 End SyncLock End Sub End Class Incidentally, you can solve this problem in two ways. One easy solution is to replace the GlobalCounter variable with an object that exposes an integer property. For example, an instance of the following Counter class can be locked: Public Class Counter Public Count As Integer End Class Another possible solution is to use the specialized System.Threading.Interlocked class, which is designed to solve just this sort of problem with incrementing or decrementing an ordinary integer variable. The Interlocked class provides shared methods that increment or decrement a variable in a thread-safe manner as a single atomic operation, as shown in Listing 6-15. Listing 6-15 Using the interlocked classPublic Class CountInvocations Inherits MarshalByRefObject Private GlobalCounter As Integer Public Sub IncrementCounter() Interlocked.Increment(GlobalCounter) End Sub End Class Tip You might wonder why you need to lock the global counter in the first place. The problem is that the ordinary increment operation is actually shorthand for referring to two separate operations: reading a value and writing a value. Without synchronization, two clients might overlap. For example, they might both read the current counter value (say, 4) and both try to increment it to the next value (5). The end result is that the counter will be set to 5 twice rather than its rightful value of 6. Race ConditionsWith locking, you risk a couple of new problems. The first problem, race conditions, is really just a matter of not using the correct locking. The example in Listing 6-16 shows the essence of a common race condition problem. A ProcessValue method obtains a lock when reading and writing a value, but it doesn't hold on to the lock for the time-consuming processing stage. This improves performance but raises the chance that another user will modify the value while the process is taking place. Listing 6-16 Potential grounds for a race conditionPublic Class CountInvocations Inherits MarshalByRefObject Private Value As Single Public Sub ProcessValue() SyncLock Me Dim ValueToProcess As Single = Value End SyncLock ' (Process ValueToProcess here). SyncLock Me Value = ValueToProcess End SyncLock End Sub End Class Race conditions can also be caused by unnoticed dependencies (for example, setting value A based on value B and C while another client is changing value C) or by the interference of more than one method. There's no generic way to avoid a race condition. In the preceding situation, it might make sense to check that the current value equals the initially read value before making the update. Another choice is to use a Boolean member variable to keep track of whether the value is currently being processed and refuse to continue if it is. DeadlocksWhereas race conditions can lead to failed updates, deadlocks can stop a component from doing anything at all. Deadlocks are caused when two or more threads wait for each other to release a resource. For example, consider the two methods in Listing 6-17. Listing 6-17 A likely candidate for a deadlockPublic Sub MethodA() SyncLock ObjectA SyncLock ObjectB ' (Do something with A and B). End SyncLock End SyncLock End Sub Public Sub MethodB() SyncLock ObjectB SyncLock ObjectA ' (Do something with A and B). End SyncLock End SyncLock End Sub Assume MethodA gets a hold of ObjectA and then tries to obtain a lock on ObjectB. In the meantime, MethodB obtains exclusive access to ObjectB and tries for ObjectA. Both methods will wait for each other to release an object, with neither method giving in. In this example, the culprits are nested SyncLock blocks. However, this situation is just as likely with separate objects that are trying to obtain locks on each other. One of the problems with detecting and solving deadlocks is that they might not happen very often. (In fact, they might rely on a client calling just the right combination of methods.) The only way to avoid deadlock situations is through careful programming. Keep the following few guidelines in mind:
Advanced Locking with the Monitor ClassThe SyncLock statement is essentially a wrapper for another .NET class: System.Threading.Monitor. Monitor provides shared methods that enable you to test for locks, obtain locks, release locks, and notify other clients waiting for a locked object. Table 6-4 lists the methods of the Monitor class.
You can rewrite a block of SyncLock code using the Monitor class. For example, Listing 16-18 shows how to convert a SyncLock block into code that uses the Monitor class: Listing 6-18 SyncLock-equivalent code with the Monitor class' SyncLock code. SyncLock Obj ' (Use object here.) End SyncLock ' Monitor equivalent of SyncLock code. Try Monitor.Enter(Obj) ' (Use object here.) Finally Monitor.Exit(Obj) End Try In other words, the SyncLock statement uses exception-handling code that ensures that even if an error is generated, the lock is released on the object. This is a basic level of failsafe code required in any distributed application to prevent deadlocks. In addition, you can make an intelligent attempt to acquire a lock using TryEnter, as shown in Listing 6-19. Listing 6-19 Testing for a lockIf Monitor.TryEnter(Obj, TimeSpan.FromSeconds(60)) Then ' We have exclusive access to Obj. Try ' (Use object here.) Finally Monitor.Exit(Obj) End Try Else ' After 60 seconds, the lock could still not be created. ' Log the error and thrown an exception to alert the client. End If This enables you to implement another safeguard against deadlocks. Namely, if you need to acquire a lock on more than one object, you should use TryEnter on the second object. If you can't acquire your second lock, you should release the first lock and try again later. This pattern is demonstrated in Listing 6-20. Listing 6-20 Avoiding deadlocks' The TaskCompleted flag tracks is set once the operation is ' performed. Dim TaskCompleted As Boolean = False Do Until TaskCompleted ' Attempt to get the first lock for 10 seconds. If Monitor.TryEnter(ObjectA, TimeSpan.FromSeconds(10)) Then ' We have exclusive access on ObjectA. ' Attempt to get the second lock for 10 seconds. If Monitor.TryEnter(ObjectB, TimeSpan.FromSeconds(10)) Then ' We have exclusive access on ObjectA and ObjectB. ' (Perform task with ObjectA and ObjectB.) Monitor.Exit(ObjectB) TaskCompleted = True End If ' This releases the first lock, regardless of whether the ' task was completed. ' This ensures that other classes that may need both objects ' can finish their work. Monitor.Exit(ObjectA) End If If Not TaskCompleted ' The task will attempted in the next loop iteration. ' First, the code will pause for 60 seconds. Thread.Sleep(TimeSpan.FromSeconds(60)) End If Loop Wait, Pulse, and the Provider-Consumer ProblemThe Wait and Pulse methods are designed to solve the classic provider-consumer problem. This occurs when one thread (a consumer) needs to acquire information from another thread (the provider). The consumer can use Wait.Enter to gain an exclusive lock on the provider and check for the data it needs. If the provider still hasn't created the data, however, acquiring this lock will freeze it, ensuring that the data is never created. In this case, the consumer calls the Wait method to move to the wait queue for the consumer object. This indicates that it wants to eventually obtain an exclusive lock on the provider. The provider then calls the PulseAll method to notify all the waiting consumers that the data is ready. The consumer threads then move to the ready queue and take turns acquiring the exclusive locks they need to read information from the provider. Note that to use Wait and PulseAll, you must call Enter to start a synchronized block. The provider-consumer problem rarely appears in a distributed application because .NET handles low-level infrastructure details such as creating class instances for a client and managing remote object lifetime. (These details are beyond the scope of this book.) |