MSDN Magazine > Issues and Downloads > 2005 > February >  Basic Instincts: Using the ReaderWriterLock Cla...
Basic Instincts
Using the ReaderWriterLock Class
Ted Pattison

In several installments over the past year I have written about multithreading programming techniques in Visual Basic® .NET. In the September 2004 issue of MSDN®Magazine, I discussed the need for thread synchronization and how to write thread-safe code using monitors. While monitors are easy to use, there can be undesirable trade-offs. My discussion this month looks at an alternative technique for writing thread-safe code using the ReaderWriterLock class. As you will see, this approach can result in faster response times and increased levels of throughput.

The Limitations of Monitors
A monitor is a synchronization mechanism that is accessible through the Monitor class in the System.Threading namespace. Let me quickly review monitors and explain how they work. Examine the code in Figure 1, which demonstrates a Point class with two Integer fields. Read and write access to these two fields for any given Point object has been synchronized using calls to Monitor.Enter and Monitor.Exit. Given this code, the Microsoft® .NET Framework provides thread synchronization by blocking threads from entering the monitor once another thread has entered it. The .NET Framework also provides the means to unblock the next thread in line as soon as the thread inside a monitor calls Monitor.Exit and releases its lock.
Imports System.Threading

'*** a thread-safe class
Class Point

  '*** private fields
  Private x As Integer
  Private y As Integer

  '*** constructor does not require synchronization
  Sub New(ByVal x As Integer, ByVal y As Integer)
    Me.x = x
    Me.y = y
  End Sub

  '*** read point position with synchronization
  Sub GetPointPosition(ByRef x As Integer, ByRef y As Integer)
    Monitor.Enter(Me)
    Try
      x = Me.x
      y = Me.y
    Finally
      Monitor.Exit(Me)
    End Try
  End Sub

  '*** update point position with synchronization
  Sub SetPointPosition(ByVal x As Integer, ByVal y As Integer)
    Monitor.Enter(Me)
    Try
      Me.x = x
      Me.y = y
    Finally
      Monitor.Exit(Me)
    End Try
  End Sub

End Class
The purpose of using a monitor in this design is to ensure that a writer thread is able to change both the X position and the Y position of a Point object as an atomic unit of work before any other thread is able to read its underlying data. The resulting synchronization prevents inconsistent reads where another reader thread would be able to see the logical Point at an invalid position.
Figure 2 shows a simplified version of the Point class rewritten using the SyncLock construct. When you use the SyncLock construct in this manner, the Visual Basic .NET compiler expands your code to call Monitor.Enter. The code expansion also supplies a Try statement with a Finally block containing a call to Monitor.Exit. The result is that after compilation the code shown in Figure 2 is almost identical to the code shown in Figure 1. You should see that the Visual Basic .NET programming language provides the SyncLock construct to make this common pattern for writing synchronization code a bit easier to type.
Imports System.Threading

'*** class rewritten to be thread safe
Class Point

  '*** private fields
  Private x As Integer
  Private y As Integer

  '*** constructor does not require synchronization
  Sub New(ByVal x As Integer, ByVal y As Integer)
    Me.x = x
    Me.y = y
  End Sub

  '*** read point position with synchronization
  Sub GetPointPosition(ByRef x As Integer, ByRef y As Integer)
    SyncLock Me
      x = Me.x
      y = Me.y
    End SyncLock
  End Sub

  '*** update point position with synchronization
  Sub SetPointPosition(ByVal x As Integer, ByVal y As Integer)
    SyncLock Me
      Me.x = x
      Me.y = y
    End SyncLock
  End Sub

End Class
So given the effectiveness and simplicity of using monitors and the SyncLock keyword, what are their limitations? The first is that the convenience of the SyncLock keyword is offset by a loss of control. One example of this limitation is that the SyncLock construct does not permit you to pass a timeout value. That means you should avoid using the SyncLock construct in more complicated synchronization schemes where your code could be vulnerable to deadlock scenarios.
If you are required to protect your code against deadlocks when using a monitor, you should avoid the SyncLock construct and replace calls to Monitor.Enter with calls to Monitor.TryEnter. The TryEnter method allows you to specify a timeout interval in milliseconds that can effectively prevent the calling thread from blocking indefinitely. The TryEnter method returns a value of true if the calling thread is able to enter the monitor before the timeout interval is exceeded. The TryEnter method returns a value of false if the timeout interval is exceeded and the calling thread was not able to enter in order to monitor. This should lead you to structuring your code like the code in Figure 3.
Dim LockAcquired As Boolean
LockAcquired = Monitor.TryEnter(Me, 2000)
If LockAcquired Then
  Try
    '*** perform work while inside the monitor
    Me.x = x
    Me.y = y
  Finally
    '*** release lock
    Monitor.Exit(Me)
  End Try
Else
  '*** supply contingency code for when timeout occurs
End If
A second noteworthy problem with using monitors is that they only provide synchronization based on exclusive locking. In other words, a monitor cannot permit access to more than one thread at a time. This is true even when a set of threads are only attempting to perform read operations. This limitation can lead to inefficiencies in a scenario in which there are many read operations for each write operation.
Consider a robust and scalable DBMS such as SQL Server. Think about how much the performance of high-volume applications would degrade if SQL Server supported only exclusive locking but not shared locking. It would have a severely negative impact on concurrency because only one request would be able to read a specific record or a specific table at a time, resulting in slower response times and a decrease in throughput because reader threads would be blocking other reader threads unnecessarily.
The key point is that exclusive locking is inefficient in cases where multiple threads are performing only read operations. Inconsistent reads can't happen until one or more threads update data. Fortunately, the internal plumbing for a DBMS such as SQL Server supplies a more sophisticated concurrency mechanism which mixes shared locking with exclusive locking to speed up response times and increase overall system throughput. SQL Server uses shared locking whenever it can and then resorts to exclusive locking only when it's required.

Synchronization Using ReaderWriterLock
The exclusive locking scheme used by monitors doesn't consider whether threads are readers or writers. Unfortunately, this isn't always the best choice, especially in scenarios in which there are many more read operations than write operations. Sticking with monitors in such a scenario can unnecessarily degrade your response times and overall application throughput. Therefore, you should consider using the ReaderWriterLock class in order to assist with your synchronization.
The ReaderWriterLock class allows you to design a synchronization scheme which employs shared locks together with exclusive locks. This makes it possible to provide access to multiple reader threads at the same time, which effectively reduces the level of blocking. The ReaderWriterLock class also provides exclusive locking for write operations so you can eliminate inconsistent reads.
Figure 4 shows a rewritten version of the Point class, which uses a ReaderWriterLock object instead of a monitor to achieve thread safety. The obvious improvement with this new design is that a reader thread will not block other reader threads when calling the GetPointPosition method.
Class Point

  '*** private fields
  Private x As Integer
  Private y As Integer
  Private lock As New ReaderWriterLock

  '*** constructor does not require synchronization
  Sub New(ByVal x As Integer, ByVal y As Integer)
    Me.x = x
    Me.y = y
  End Sub

  '*** read point position with shared lock
  Sub GetPointPosition(ByRef x As Integer, ByRef y As Integer)
    lock.AcquireReaderLock(Timeout.Infinite)
    Try
      x = Me.x
      y = Me.y
    Finally
      lock.ReleaseReaderLock()
    End Try
  End Sub

  '*** update point position with exclusive lock
  Sub SetPointPosition(ByVal x As Integer, ByVal y As Integer)
    lock.AcquireWriterLock(Timeout.Infinite)
    Try
      Me.x = x
      Me.y = y
    Finally
      lock.ReleaseWriterLock()
    End Try
  End Sub

End Class
In this design a ReaderWriterLock object is created each time a new Point object is created. This internal ReaderWriterLock object is referenced using a private field named lock. The lock field makes it possible for all instance methods associated with a Point object to access the same ReaderWriterLock object.
The GetPointPosition method calls AcquireReaderLock on the ReaderWriterLock object to obtain a shared lock before doing any of its work. This means that two or more threads can call the GetPointPosition method at the same time without blocking one another. The SetPointPosition method, on the other hand, calls AcquireWriterLock to obtain an exclusive lock. Once a thread has called AcquireWriterLock and acquired an exclusive lock, all other reader threads and writer threads will be blocked until this thread can complete its work and call ReleaseWriterLock.
When you call either AcquireReaderLock or AcquireWriterLock, you can optionally pass a timeout value to protect your code against deadlock situations. A timeout value indicates the amount of time a thread is willing to wait in a blocked state before it can obtain the lock it needs to do its work. The version of the Point class shown in Figure 4 passed Timeout.Infinite, a constant with a value of -1, indicating that the thread should wait for an infinite amount of time. However, you can change the blocking behavior by passing a timeout value in milliseconds, as shown in the following code:
Sub SetPointPosition(ByVal x As Integer, ByVal y As Integer)
  lock.AcquireWriterLock(2000)
  Try
    Me.x = x
    Me.y = y
  Finally
    lock.ReleaseWriterLock()
  End Try
End Sub
With this new version of the SetPointPosition method, the calling thread will only wait for two seconds when attempting to obtain an exclusive lock. If the calling thread's wait time exceeds the timeout interval, the AcquireWriterLock method throws an ApplicationException with the error message "This operation returned because the timeout period expired." It is then your responsibility to catch and handle this exception as gracefully as possible.
Now think about what would happen if a single thread calls AcquireWriterLock more than once before calling ReleaseWriterLock? Your intuition might tell you that this thread will acquire an exclusive lock with the first call to AcquireWriterLock and then block on the second call. Fortunately, this is not the case.
The ReaderWriterLock class is smart enough to associate exclusive locks with threads and track an internal lock count. Therefore, multiple calls to AcquireWriterLock will not result in a deadlock. However, this issue still requires your attention because you must ensure that two calls to the AcquireWriterLock method from a single thread are offset by two calls to the ReleaseWriterLock method. If you call AcquireWriterLock twice and only call ReleaseWriterLock once, you haven't released the lock yet.
The behavior that results from performing multiple calls to AcquireReaderLock is similar to that of making multiple calls to AcquireWriterLock. A ReaderWriterLock object simply increments the count of shared locks for any given thread. Once again, you should ensure that two calls to the AcquireReaderLock method are offset by two calls to the ReleaseReaderLock method.

A Tale of Two Queues
There is a classic synchronization problem that exists anytime shared locks are used together with exclusive locks. Consider a situation in which a writer thread is blocked waiting for a reader thread to release its shared lock. What if a second reader thread acquires a shared lock just before the first reader thread released its shared lock. In effect, the second reader thread showed up after the writer thread but was able to jump ahead of it in line.
Next, imagine that a third reader thread acquired a shared lock just before the second reader thread released its shared lock. The writer thread could theoretically block forever as reader threads continue to jump ahead of it in line. This scenario is known as a live lock situation.
Fortunately, the ReaderWriterLock class has been designed to deal with live locks. Each ReaderWriterLock object contains one request queue for reader threads and a separate request queue for writer threads. When a writer thread calls AcquireWriterLock to obtain an exclusive lock, its request is queued up in the writer request queue. When there are one or more writer requests pending and a reader thread calls AcquireReaderLock to obtain a shared lock, it is not instantly granted a shared lock. Instead, the reader thread is blocked and its request to acquire a shared lock is appended to the reader request queue. This effectively forces reader threads to wait until the writer thread has completed its work.
When the writer thread calls ReleaseWriterLock, all the reader threads that have requests pending in the reader request queue are granted shared locks and can complete their work. When all those reader threads release their shared locks, the next writer thread in the queue is granted an exclusive lock so it can complete its work. You should observe that a ReaderWriterLock object plays the role of a traffic cop, alternating back and forth between one writer and a collection of readers.

Upgrading a Shared Lock to an Exclusive Lock
Note that it is not possible for any single thread to hold both a shared lock and an exclusive lock at the same time. This is largely due to the way that the ReaderWriterLock class implements its synchronization scheme using a reader request queue and a writer request queue. Things would get confusing if a single thread had requests in both queues at the same time. However, it is not uncommon that a thread will need to obtain a shared lock at first and then later escalate to an exclusive lock to perform write operations.
Let's look at a common real-world scenario in which a thread must read data to verify the number of units in inventory and then write data to record a purchase. For example, a thread might first acquire a shared lock so that it can read inventory to verify that there are enough units in stock to make the purchase. If the thread determines that the inventory is sufficient to make a purchase, it escalates its shared lock to an exclusive lock so it can decrement the number of units to reflect the purchase.
The key point about using the ReaderWriterLock class is that a thread should never call AcquireReaderLock and then follow that with a call to AcquireWriterLock. If you do this, your call to AcquireWriterLock will block indefinitely. Instead, after calling AcquireReaderLock you should call UpgradeToWriterLock to escalate from a shared lock to an exclusive lock, as shown here:
'*** acquire shared lock
lock.AcquireReaderLock(Timeout.Infinite)

'*** escalate shared lock to exclusive lock
lock.UpgradeToWriterLock(Timeout.Infinite)
While this example demonstrates the way the .NET Framework documentation recommends you write your code, the locking behavior that you will experience is not what you might expect. I was certainly surprised the first time I wrote and tested this type of code. An example will help to illustrate the problems that can occur when escalating shared locks to exclusive locks with the UpgradeToWriterLock method.
Imagine that thread A calls AcquireReaderLock just before thread B calls AcquireWriterLock. In this scenario, thread B cannot obtain an exclusive lock and do its work until thread A has released its shared lock. At this point, what happens if thread A calls UpgradeToWriterLock in an attempt to obtain an exclusive lock for itself? The important question to answer is whether it is thread A or thread B that gets the exclusive lock first?
Your intuition might tell you that thread A gets the exclusive lock first because it already had acquired a shared lock. However, this is incorrect. When thread A calls UpgradeToWriterLock, its shared lock is instantly released and its request to obtain an exclusive lock is placed in the writer request queue behind any pending request. This means that thread B acquires an exclusive lock and can perform its update before thread A. It also means that the underlying data that thread A saw when it had its shared lock can be changed before it is able to obtain its exclusive lock. In other words, the data you are trying to lock down can be changed by other threads between the time that a thread calls UpgradeToWriterLock and the time it actually acquires the exclusive lock.
The key point is that a call to UpgradeToWriterLock doesn't lock down your data. It has the same effect of calling ReleaseReaderLock and then following it with a call to AcquireWriterLock. If you want to read data, verify a value, and then write data, you should do all this work after you have called AcquireWriterLock. You should observe that the lock escalation behavior of the ReaderWriterLock class is not as sophisticated as that which is provided by most DBMSs, such as SQL Server.
This column has examined how to overcome some of the limitations of using monitors by employing the ReaderWriterLock class. Unlike monitors, the ReaderWriterLock class mixes shared locking with exclusive locking to improve response times and to increase system throughput. Be sure to keep in mind that synchronization techniques using the ReaderWriterLock class work best in scenarios where there are many reads for each write and where all write operations are short in duration.

Send your questions and comments for Ted to  instinct@microsoft.com.


Ted Pattison is a cofounder of Barracuda .NET, an education company that assists companies building collaborative applications using Microsoft technologies. Ted is the author of several books including Building Applications and Components with Visual Basic .NET (Addison-Wesley, 2003).

Page view tracker