ReaderWriterLock, Dispose pattern can also increase reliability
[Update 29/07/2004: Just updated the code, the old one was... broken to say the least :)]
Following Ian’s
snippet about multithreading, and although I love the lock keyword
as much as everybody, a Monitor is in my experience nearly always a bad choice. Why
would you ask? What kind of weird thing the good old technologist have against the
Monitor?
Well most of the time what you really want is let several threads read, but
only one thread only write. Oh, and you don’t want to let someone write while
you’re reading, nor do you want anyone reading while you write.
Let’s have a very quick overview of the ReaderWriterLock object. A ReaderWriterLock is
a synchronization object that lets several readers acquire a reader lock at
the same time. If any reader lock has been acquired, other reader locks will happily
be given, but a request for a writer lock won’t be given until all the
reader locks are released.
Not so complex you would tell me, and you wouldn’t be wrong. But to be really
honest, the api for this object is cluttered, too complex, and as such, the code using
it becomes very unreadable…
And multi threaded unreadable code is an absolute no-go.
Let’s see what the methods are:
- AcquireReaderLock to acquire a reader lock and provide a TimeSpan as a timeout value.
- AcquireWriterLock to acquire a writer lock, and provide a TimeSpan as a timeout value.
- ReleaseReaderLock to release a reader lock.
- ReleaseWriterLock to release a writer lock.
Quite simple isn’t it? Now let’s see a few others as well:
- UpgradeToWriterLock gets tricky, it lets you switch from your reader lock to a writer lock (and of course only if you’re the only reader does it return immediately)
- DowngradeFromWriterLock let’s you release this upgrade.
- ReleaseLock, RestoreLock you don’t really want to know about them, won’t need them, they are evil because they don’t timeout. If you want to know more, msdn is your friend.
So why exactly can’t I just AcquireWriterLock when I already have AcquireReaderLock?
Remember that I said the ReaderWriterLock doesn’t let you acquire a writer lock
unless all reader locks have been released… Which is why you have a different
path! So my typical code would look like that:
private ReaderWriterLock
myLock;
private Hashtable
myObjectCache = new Hashtable();
public void DoSomethingWithALock()
{
myLock.AcquireReaderLock(TimeSpan.FromSeconds(30));
try
{
//
let's see if i have this object in cache
StrongTypedObject myObject = myObjectCache["noItHasNothingToDoWithAKey"] as StrongTypedObject;
if (myObject
== null)
{
//
if not i'll load it
StrongTypedObject myNewObject = new StrongTypedObject("orWithChains");
//
and now I need a write access...
LockCookie myCookie = myLock.UpgradeToWriterLock(TimeSpan.FromMinutes(1));
try
{
myObjectCache["noItHasNothingToDoWithAKey"] =
myNewObject;
}
finally
{
myLock.DowngradeFromWriterLock(ref myCookie);
}
}
}
finally
{
myLock.ReleaseReaderLock();
}
}
Ok so as you can see, the
code is a bit complex. Now the question is, what happens if I want to factor out the
creation of the object in a separate method? That’s where it becomes a real
challenge. If I do that, I can either put in this new method the lock upgrade, or
leave it to the caller to set it up. What is the problem with the lock upgrade being
integrated with the method?
Well this method will be called from different points in your code, or worse, in other
people code. You then require the caller to set up the correct lock environment
(acquiring a reader lock) before calling your method. Not a good thingTM.
The best way to go is obviously to detect if when called, the method is already under
a reader lock or not. This is the code doing it:
public void CreateMyDoSomethingObject()
{
bool isReaderLockHeldOnEntry
= myLock.IsReaderLockHeld;
LockCookie myCookie = null;
if (isReaderLockHeldOnEntry)
myCookie = myLock.UpgradeToWriterLock(TimeSpan.FromSeconds(30));
else
myLock.AcquireWriterLock(TimeSpan.FromSeconds(30));
try
{
StrongTypedObject myNewObject = new StrongTypedObject("orWithChains");
myObjectCache["noItHasNothingToDoWithAKey"] =
myNewObject;
}
finally
{
if (isReaderLockHeldOnEntry)
myLock.DowngradeFromWriterLock(ref myCookie);
else
myLock.ReleaseWriterLock();
}
}
It’s already nearly unreadable.
Now try to have several locks, and you end up with a very complex thing that is not
readable by mortal people anymore.
Which is why I implemented the RWLock object following Ian’s code. With it,
you can rewrite the code as:
public void CreateMyDoSomethingObject()
{
using (RWLock.LockWriter(myLock,
TimeSpan.FromMinutes(1)))
{
StrongTypedObject myNewObject = new StrongTypedObject("orWithChains");
myObjectCache["noItHasNothingToDoWithAKey"] =
myNewObject;
}
}
As you can see, it’s
much much less cluttered. The LockWriter method either upgrade to a writer lock or
acquire a new writer lock, depending on the state of the lock on the current thread
when the method was called. Now you can ensure good synchronization, and neither have
to have a lot of code or leave the caller to deal himself with setting the locks right.
The full code for the class (also attached as a .zip file)
using System;
using System.Threading;
// (C) 2004
Sebastien Lambla
// Unlimited
use license given to Cubiks Ltd for the Cubiks Online platform.
namespace Cubiks.Threading
{
#if DEBUG
public class RWLock
: IDisposable
#else
public struct
RWLock : IDisposable
#endif
{
private static TimeSpan
timeout = TimeSpan.FromSeconds(5);
public static RWLock
LockReader(ReaderWriterLock lockObject)
{
return LockReader
(lockObject, timeout);
}
public static RWLock
LockReader(ReaderWriterLock o, TimeSpan timeout)
{
RWLock
tl = new RWLock(o);
try
{
o.AcquireReaderLock(timeout);
}
catch (ApplicationException
e)
{
#if DEBUG
System.GC.SuppressFinalize(tl);
#endif
throw;
}
return tl;
}
public static RWLock
LockWriter(ReaderWriterLock o)
{
return LockWriter(o,
timeout);
}
public static RWLock
LockWriter(ReaderWriterLock o, TimeSpan timeout)
{
RWLock
tl = new RWLock(o);
try
{
if (o.IsReaderLockHeld)
{
tl.lockCookie
= o.UpgradeToWriterLock(timeout);
tl.lockType
= LockType.WriterFromReader;
}
else
{
o.AcquireWriterLock(timeout);
tl.lockType
= LockType.Writer;
}
}
catch (ApplicationException)
{
#if DEBUG
System.GC.SuppressFinalize(tl);
#endif
throw;
}
return tl;
}
private ReaderWriterLock
target;
private LockType
lockType;
private LockCookie
lockCookie;
private RWLock
(ReaderWriterLock o)
{
lockType
= LockType.None;
lockCookie
= new LockCookie();
target
= o;
}
public void Dispose
()
{
try
{
switch (this.lockType)
{
case LockType.Reader:
{
target.ReleaseReaderLock();
break;
}
case LockType.Writer:
{
target.ReleaseWriterLock();
break;
}
case LockType.WriterFromReader:
{
target.DowngradeFromWriterLock(ref lockCookie);
break;
}
default:
System.Diagnostics.Debug.WriteLine("Problem,
no lock acquired!");
break;
}
}
catch (ApplicationException
exception)
{
System.Diagnostics.Debug.WriteLine(exception.ToString());
}
catch (Exception
e)
{
System.Diagnostics.Debug.WriteLine(e.ToString());
}
//
It's a bad error if someone forgets to call Dispose,
//
so in Debug builds, we put a finalizer in to detect
//
the error. If Dispose is called, we suppress the
//
finalizer.
#if DEBUG
GC.SuppressFinalize(this);
#endif
}
#if DEBUG
~RWLock()
{
//
If this finalizer runs, someone somewhere failed to
//
call Dispose, which means we've failed to leave
//
a monitor!
System.Diagnostics.Debug.Fail("Undisposed
lock");
}
#endif
private enum LockType
{
None,
Reader,
Writer,
WriterFromReader
}
}
}