"Using" ReaderWriterLockSlim
Disclaimer: Steven pointed out in the comments that a very subtle race condition exists that can lead to deadlocks. Use with caution
When writing multi-threaded applications, it is nearly impossible to avoid the need to protect some shared state so that it is not corrupted. The "best" way to accomplish this has progressed over time as the .NET framework has matured.
lock statement
The "lock" statement was part of the original .NET framework and provides a mutual-exclusion lock. This means that only one thread can access the shared state at a time, which is exactly what we want.
Under the hood, the lock statement is just syntactic sugar for Monitor.Enter() and Monitor.Exit(). If you really want to see for yourself, write a simple lock statement like the one below and open the compiled assembly in Reflector, then look at the IL (not the C#, since Reflector automatically recognizes the underlying lock syntax) — you’ll see calls to [mscorlib]System.Threading.Monitor::Enter and [mscorlib]System.Threading.Monitor::Exit surrounding the code inside.
For example:
private object m_Lock = new object();
private void LockTest()
{
lock (m_Lock)
{
// access the shared state here
}
}
While it is a good thing that only one operation can happen to the shared state at any given time, it can also be a bad thing. The whole purpose of the lock is to prevent the corruption of the shared state, so we obviously don’t want to be reading and writing at the same time — but what if two threads are only trying to read at the same time? That’s pretty harmless, right? Nothing can get corrupted if we’re just reading.
In light of this, the lock statement is too cautious and certainly not optimal. Imagine a scenario with 1 thread writing and 10 threads reading — if we use the lock statement then each of the 10 reading threads must execute exclusively when they could be interleaved, leading to inefficient use of resources and wasted effort.
ReaderWriterLock
The ReaderWriterLock class addresses this inefficiency by allowing single writes but multiple reads. It has much better performance characteristics than the lock statement. It has been included in the .NET framework since version 1.1.
I won’t go into all of the details here, but the ReaderWriterLock class has a few drawbacks that could lead to deadlocks, not to mention that it is quite bulky. It’s been superceded by none other that ReaderWriterLockSlim.
ReaderWriterLockSlim
Just recently the ReaderWriterLockSlim class has been introduced in the .NET Framework 3.5. It is the latest attempt at providing a high-performance and fair lock manager and works very well.
To use the ReaderWriterLockSlim, it’s a simple matter of creating a lock for your class:
private ReaderWriterLockSlim m_Lock = new ReaderWriterLockSlim();
and then wrapping all read or write operations between Enter{Read,Write}Lock and Exit{Read,Write}Lock methods:
private void ReadTest()
{
m_Lock.EnterReadLock();
// access the shared state for reading here
m_Lock.ExitReadLock();
}
private void WriteTest()
{
m_Lock.EnterWriteLock();
// access the shared state for writing here
m_Lock.ExitWriteLock();
}
This works, but can be cumbersome at times (e.g. when there are multiple nested locks) since you must remember to call Exit after every Enter. This feels a lot like doing Monitor.Enter and Monitor.Exit, though unfortunately we don’t have the help from the compiler and a "lock" (or fictitious "lockslim") keyword.
One thing that we could do to increase the usability is to create classes that implement IDisposable and then place these within a "using" statement. Since the compiler ensures that the Dispose method always gets called, we can ensure that the lock is always cleaned up.
Let’s call these classes AcquireReaderLock and AcquireWriterLock. We can use them like this:
public void AcquireReaderLockTest()
{
ReaderWriterLockSlim theLock = new ReaderWriterLockSlim();
using (new AcquireReaderLock(theLock))
{
// access the shared state for reading here
}
}
public void AcquireWriterLockTest()
{
ReaderWriterLockSlim theLock = new ReaderWriterLockSlim();
using (new AcquireWriterLock(theLock))
{
// access the shared state for writing here
}
}
The constructor calls Enter(…)Lock and the Dispose method calls Exit(…)Lock, leaving us with a nice clean syntax very similar to the built-in lock statement. Here is the code for the two helper classes, using the classic Dispose pattern:
public class AcquireReaderLock : IDisposable
{
private ReaderWriterLockSlim m_Lock = null;
private bool m_Disposed = false;
public AcquireReaderLock(ReaderWriterLockSlim rwl)
{
m_Lock = rwl;
m_Lock.EnterReadLock();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!m_Disposed)
{
if (disposing)
{
m_Lock.ExitReadLock();
}
}
m_Disposed = true;
}
}
public class AcquireWriterLock : IDisposable
{
private ReaderWriterLockSlim m_Lock = null;
private bool m_Disposed = false;
public AcquireWriterLock(ReaderWriterLockSlim rwl)
{
m_Lock = rwl;
m_Lock.EnterWriteLock();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!m_Disposed)
{
if (disposing)
{
m_Lock.ExitWriteLock();
}
}
m_Disposed = true;
}
}
I can’t really take credit for this — I’d stumbled across this idea from Noel Lysaght while looking for ways to synchronize access to a dictionary (though the code was originally in VB.NET and I just translated it to C#).
Hope that helps someone!
You can follow any responses to this entry through the RSS 2.0 feed. You can leave a response, or trackback from your own site.
September 23rd, 2008 at 3:45 pm
Hi Matt,
I want to warn you for the given solution. There is a subtle bug that can occur during asynchronous exceptions (read: thread abort exceptions). The problem is that there is a time frame between “m_Lock.EnterReadLock();” statement in the constructor and the start of the try statement. When a async exception happens in this time frame, the finally will never run and the “m_Lock.ExitReadLock();” will therefore not run either. This could then lead to a deadlock.
Note that the C# lock() statement (or in fact the Monitor.Enter call) doesn’t have this problem, because the JIT has a specific optimization for this that eliminates the time frame. Your simple ‘using’ statement doesn’t have this ‘feature’ and therefore, you’ll need another way to protect yourself against this. You should consider to write code like this:
using (var locker = LockAcquirer(theLock))
{
// Because the lock is acquired within the
// try..finally, the operation is safe from
// async exceptions.
locker.AcquireReadLock();
// access the shared state for reading here
}
From an usability perspective, this proposal is actually less pleasing, because users may forget to call AcquireReadLock() leaving your code open for race conditions. And if you really need to be this paranoid, of course depends on your system requirements and the frequency that async exceptions occur. Some hosts are pretty aggressive about killing threads (like ASP.NET and SQL Server).
Joe Duffy wrote an amazing article about this almost two years ago. You can read it here:
http://www.bluebytesoftware.com/blog/CommentView,guid,d9ff204a-a8a5-400e-bcbc-dedb90a7d11a.aspx
And as you could imagine, in response to Joe’s blog, I wrote about it too: http://www.cuttingedge.it/blogs/steven/pivot/entry.php?id=21
September 23rd, 2008 at 4:05 pm
Wow, thanks Steven. That is incredibly sneaky. I’ll definitely take another look at that.
November 14th, 2008 at 1:56 pm
The updated code for the LockAcquirer class described by Steven is below. I’ve renamed it to make it more obvious that it does not implicitly acquire the lock.
using System;
using System.Threading;
namespace NobleTech.Products.Library
{
class ReaderWriterLockMgr : IDisposable
{
private ReaderWriterLockSlim _readerWriterLock = null;
private bool _isDisposed = false;
private enum LockTypes { None, Read, Write }
LockTypes _enteredLockType = LockTypes.None;
public ReaderWriterLockMgr(ReaderWriterLockSlim ReaderWriterLock)
{
_readerWriterLock = ReaderWriterLock;
}
public void EnterReadLock()
{
_readerWriterLock.EnterReadLock();
_enteredLockType = LockTypes.Read;
}
public void EnterWriteLock()
{
_readerWriterLock.EnterWriteLock();
_enteredLockType = LockTypes.Write;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_isDisposed)
{
if (disposing)
{
switch (_enteredLockType)
{
case LockTypes.Read:
_readerWriterLock.ExitReadLock();
break;
case LockTypes.Write:
_readerWriterLock.ExitWriteLock();
break;
}
}
}
_isDisposed = true;
}
}
}
November 24th, 2008 at 8:48 am
After a couple of questions got raised about this I have posted a detailed article addressing the deadlock condition discussed in the comments here: http://www.nobletech.co.uk/Articles/.
PS. My code posted above doesn’t check what you’re not already in a lock before acquiring a new one. Of course the lock itself will throw an exception if you haven’t allowed recursion on it, but if you have then you will need to do this to make sure that each lock you acquire is released correctly at the right time. The full code for the lock manager including these checks is available on the page I mentioned.