Some projects suffer from a bad usage of the upgradeable-read-lock. If all the situations involve getting an upgradeable-read-lock to maybe upgrade to a write lock, without ever using simple read locks, there is unnecessary contention, which is worse than using full locks with the lock keyword.
Introduction
In C#, we have the lock
keyword, which can lock any object in an exclusive way. That is, once one thread holds the lock, no other thread will be able to acquire the lock until the first thread releases it.
Such a lock is called exclusive (or sometimes a "full" lock) as there is no way for two threads to hold the lock at the same time.
An alternative to a full lock is a reader-writer lock. With a reader-writer lock, many readers can acquire the lock at the same time but a writer lock is actually an exclusive lock. If a thread acquires a write-lock, no other thread can acquire either a read or a write lock.
.NET had first a class named ReaderWriterLock
but it is somewhat obsolete and a new class, named ReaderWriterLockSlim
, is the recommended class for when we want reader-writer locks. Yet, such a class is many times used incorrectly, effectively becoming less efficient than always using a full lock. This article is all about understanding such a problem and fixing it.
The Traits of ReaderWriterLockSlim
Although the class has "Slim" in its name, the ReaderWriterLockSlim
isn't really that slim, and it is recommended that you use its Dispose()
method to release its resources when you are done with it.
Anyway, one of the interesting traits is that the class provides three locking methods, not just two.
Read-locks and write-locks work as expected from a simple reader-writer lock, but there's a third lock, named upgradeable-read-lock.
An upgradeable-read-lock is recommended for cases where the creation/loading of a resource might take a long time, so we don't want two or more threads doing the same work in parallel, but we also don't want to stop readers from reading the object as they might be accessing different data that's already loaded (like in a caching scenario).
So, the practice can be described as:
- Acquire the upgradeable-read-lock
- Check if the data is already there and, if it is, return it. If not...
- Generate/load the data
- Acquire the write lock
- Store the data
- Return the data
The Problems
As I see, there are two main problems with the pattern I just described:
- I didn't talk about releasing the locks. When we use the
lock
keyword, the lock is released automatically when the block of code ends. With a ReaderWriterLockSlim
, we need to explicitly call Exit
for the appropriate type of lock that was acquired. - This pattern is just incomplete if we don't have methods that are just readers. If all the methods check for the presence of data while holding an upgradeable-read-lock, the code is just doing a slower exclusive lock, as the upgradeable-read-lock only allows plain read-locks to be obtained in parallel, not other upgradeable-read-locks.
Solving the Problems
For the first problem, the fact that we need to manually call the right Exit
manually, we can use extension methods and return struct
s that implement IDisposable
to do the exit.
For example, instead of doing:
rwLock.EnterUpgradeableReadLock();
try
{
DoWhatsNeededHere();
}
finally
{
rwLock.ExitUpgradeableReadLock();
}
We could just do:
using (rwLock.UpgradeableLock())
DoWhatsNeededHere();
To achieve that, we need code like this:
public readonly struct UpgradeableLockDisposer:
IDisposable
{
private readonly ReaderWriterLockSlim _rwLock;
public UpgradeableLockDisposer(ReaderWriterLockSlim rwLock)
{
_rwLock = rwLock;
}
public void Dispose()
{
_rwLock.ExitUpgradeableReadLock();
}
}
public static UpgradeableLockDisposer UpgradeableLock(this ReaderWriterLockSlim rwLock)
{
rwLock.EnterUpgradeableReadLock();
return new UpgradeableLockDisposer(rwLock);
}
The struct
was made public
and the UpgradeableLock
is returning it by its type, instead of returning it just as an IDisposable
, to avoid any boxing or any memory allocation. Assuming the compiler is doing the right optimizations, the performance of this helper method should be the same as the one using try
/finally
.
For the second problem, we need a double-checked triple lock pattern. That is, we need to do something like:
using (rwLock.ReadLock())
{
var data = TryGetData();
if (data != null)
return data;
}
using (rwLock.UpgradeableLock())
{
var data = TryGetData();
if (data != null)
return data;
data = CallMethodToGenerateTheData();
using (rwLock.WriteLock())
StoreData(data);
return data.
}
Notice that we use the 3 lock kinds.
First, we use just a read-lock. This means many threads can access the data at the same time. Assuming the data is not there, we need to release the read lock to then acquire the other lock.
Second, we acquire the upgradeable-read-lock and need to check for the data again, as it might have been added while we acquired this second lock. Assuming the data is not there, we can load/generate the data, knowing that no other thread will be doing that, as no two threads can have an upgradeable-lock at the same time.
Finally, when we have the data, we need to get the write-lock, which means all threads that have a read-lock (if any) need to release their locks, to then write the data, before returning.
Thanks to the using
clauses, we don't need to worry about any Exit
calls, but they would be done appropriately when leaving the using
scopes.
A New Problem
A triple lock approach might sound excessive. It will kill the performance, right?
Well... it might. As I said before, the ReaderWriterLockSlim
is not that slim and it is possible that acquiring a full lock and reading a value might be quicker than just acquiring the read-lock. That's one of the reasons that made me write alternative locking classes, which I presented in the article Managed Thread Synchronization.
But still talking about the ReaderWriterLockSlim
, trying to avoid just the read-lock is a no-no. If just upgradeable-locks are used, it is similar to doing full locks all the time... but slower.
Doing just read-locks and write-locks, without ever using the upgradeable lock might be a valid approach if loading the data is actually quick. But, if that's the case, maybe using a ConcurrentDictionary
(assuming we have keyed data) or Lazy
(assuming a single data) would be better.
In any case, if you are using the ReaderWriterLockSlim
, you must be aware that the read-lock must be used. Using just the upgradeable-lock and the write-lock is just wrong.
Also, even if the triple lock pattern seems too much, after the data is loaded, the code would be just using read-locks (possibly in parallel) to read the data and would not use the upgradeable and write locks again.
The Download Sample
The download sample has a ReaderWriterLockSlimExtensions
class with the three lock types being returned as IDisposable
so you can use the using
clause to automate the release of the locks.
If you like, you can just add its file to any project. Feel free to put the class into a namespace if you prefer.
History
- 3rd May, 2023: Initial version