Playing with a few ReaderWriterLocks in .Net
With Delver‘s future clouded by a big question mark, I’m looking for a new job. This reminded me of my previous job hunt, and the part we all “love” about it – job interviews.
Last year, two companies I was interviewing for asked me to implement a ReaderWriterLock. It took me more time than I’d hoped, but I got the implementation done. Since this was one of the hardest interview questions (for me at least), I’ve decided to implement one or two versions, and to test the performance vs the built-in reader writer locks available in .NET.
First, I wrote the test code, which is roughly composed of:
- Shared (singleton) counters object with two counters, X & Y.
- Some reader threads. When a reader reads, it reads both X & Y and makes sure they are equal.
- Some writer threads, that increment X & Y together (thus maintaining X == Y).
Then, I tested this setup without any locks, and as expected the reader threads got different values of X & Y.
Next, I implemented the following locks:
- DummyReaderWriterLock – a horribly inefficient implementation that is reader/writer-oblivious. It just locks the object regardless of reads/writes.
- SemaphoreReaderWriterLock – already a huge improvment, this locks uses a semphore that holds some large number (~2000) locks. Each reader requests only a single lock from the semaphore, and a writer must obtain ALL locks before continuing. Two writers are prevented from deadlocking by a separate Mutex. One immediate problem with this lock is writer starvation. For a writer to obtain the lock, it must first get the mutex and then all locks in the semaphore. This means a single writer is competing against all readers for the semaphore.
- EventReaderWriterLock – implemented by two events and a reader count. The first reader & all writers must get signaled in order to get the lock. Once any reader got the lock, other readers are free to enter without blocking. The last reader out is responsible for signaling the event and letting other writers or readers back in.
I also pulled my team leader Oren into this problem, and he came up with a state based implementation – not that far from my own, with an additional “state” integer that represents whether the lock is in “writing mode”, “reading mode” or free. He also added a few TestAndCompare hacks for checking the state.
Finally, I tested the performance of these locks vs two locks that are availble in .NET 3.5: ReaderWriterLock and ReaderWriterLockSlim. I only tested only one scenario, but was pleasantly surprised to discover the performance of both my EventReaderWriterLock and Oren’s StateReaderWriterLock were identical to that of ReaderWriterLockSlim, and better than ReaderWriterLock! (The performance of DummyReaderWriterLock were literally off the charts).
Here is my implementation:
/// /// A ReaderWriter lock implemented by events. /// /// An AutoReset event that gives ownership of the lock either to one writer or to all the readers. /// A manual reset event that allows readers to enter, and is only reset when the last reader finishes /// /// public class EventReaderWriterLock : IDisposable { /// /// Both readers and writer need this lock to work /// private readonly AutoResetEvent _lockAvailble = new AutoResetEvent(true); /// /// Further readers beyond the first one wait on this object /// private readonly ManualResetEvent _canReadEvent = new ManualResetEvent(false); /// /// Used to synch the reader lock/release /// private readonly object _readerLock = new object(); private int _readers; public void LockReader() { lock (_readerLock) { int oldReaders = _readers++; if (oldReaders == 0) { // I'm the first reader, let's fight for the lock with the writers _lockAvailble.WaitOne(); // got lock, notify all other readers they can read _canReadEvent.Set(); } else { // wait for the first reader to signal me _canReadEvent.WaitOne(); } } } public void ReleaseReader() { lock (_readerLock) { int oldReaders = _readers--; if (oldReaders != 1) { // If I'm not the last reader, I do nothing here return; } // I'm the last, let's forbid other readers but allow writers or a first reader. _canReadEvent.Reset(); _lockAvailble.Set(); } } public void LockWriter() { _lockAvailble.WaitOne(); } public void ReleaseWriter() { _lockAvailble.Set(); } public void Dispose() { _lockAvailble.Close(); _canReadEvent.Close(); } } |
And the entire zip with the other RWLocks and test harness.
A few immediate conclusions:
- Writing a working reader writer lock is a very non-trivial problem for a job interview – but watching an applicant struggle with it can give you insights about his know-how around multi-threaded code.
- Writing an all-purpose RWLock seems like a daunting task. In my exercise I specifically avoided broad considerations such as fairness & readers upgrading to writers. Testing it for all end cases seems almost impossible (though some formal theoretical tools exists for such correction proofs)
- At least for some problems, writing your own lean solution can be better (performance wise) than relying on the de facto standard. While our solutions weren’t better than ReaderWriterLockSlim, they were significantly better than ReaderWriterLock – again, only in the context of this test harness.
Bonus: my first implementation didn’t have a lock statement in LockReader() and ReleaseReader(), and used Interlocked.Increment() and Decrement() to update the _readers variable. Still, it contained a hidden bug – can you find it, and understand why the lock is necessary?
Eli:
Ron,
Nice writeup – I recently looked into RW locks, recalled that you once wrote about it and came back to read it. I’m not sure I understand your performance graph – what are you measuring exactly? Also, are you really creating that many threads – and if so, is it really realistic, or would a better benchmark involve a realistic amount of threads with a vertical load (i.e. large amount of read and write operations to perform)?
11/5/10, 7:16ripper234:
@Eli, I measured how long the test takes to run. While I forgot to attach the test code here, it basically spun a few concurrent reader/writer threads that all had contention on a single lock. They did a very small amount of work inside that lock, then gave it up. If I remember correctly, I had a fixed ratio of readers vs writers. Increasing the number of threads in this case increases the runtime because there is actually more work to do.
In this case I simply wanted to see how well the lock performs under extreme contention. It is not “realistic” – you’d need to think more thoroughly about what exactly you’re trying to measure to get more meaningful results – but even this simplistic testing was interesting in that it exposes differences between the different implementations.
Looking forward to your post on RW locks.
11/5/10, 8:38