Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

The Double-Checked Triple Lock Pattern for ReaderWriterLockSlim

5.00/5 (8 votes)
4 May 2023CPOL5 min read 17K   118  
Using the ReaderWriterLockSlim correctly and avoiding a common mistake
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:

  1. 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.
  2. 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 structs that implement IDisposable to do the exit.

For example, instead of doing:

C#
rwLock.EnterUpgradeableReadLock();
try
{
  DoWhatsNeededHere();
}
finally
{
  rwLock.ExitUpgradeableReadLock();
}

We could just do:

C#
using (rwLock.UpgradeableLock())
  DoWhatsNeededHere();

To achieve that, we need code like this:

C#
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:

C#
using (rwLock.ReadLock())
{
  var data = TryGetData();
  if (data != null)
    return data;
}

using (rwLock.UpgradeableLock())
{
  // We try to get the data again, as it might have been added while we
  // tried to get the upgradeable lock.

  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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)