In this article, we will explore the lock
statement and the ReaderWriterLockSlim
class. We will discuss the situations in which it’s better to use ReaderWriterLockSlim
over lock
in C#. We will also compare the ReaderWriterLockSlim
class with the ReaderWriterLock
class and examine which ReaderWriterLock
issues are addressed by the ReaderWriterLockSlim
implementation.
Let’s dive in.
What Is the Standard Locking Mechanism in C#
We often design applications as multithreaded to make them more performant. One side-effect of a multithreaded environment is blocking threads while one or more of them wait for the availability of a critical resource that another thread holds. Threads can also be blocked while waiting for results from external resources.
To learn more about atomic operations, thread safety, and the available locking mechanisms in C#, please read What is Locking and How to Use a Locking Mechanism in C#.
Let’s implement the BaseReaderWriter
class, which we will use as a base class for showing the lock
statement, the ReaderWriterLock
and the ReaderWriterLockSlim
classes:
public abstract class BaseReaderWriter { protected readonly List<int> NumbersList = new(); public List<int> ListOfNumbers => NumbersList; public long Execute(ThreadExecutionConfiguration config) { var tasks = new List<Task>(config.ReaderThreadsCount + config.WriterThreadsCount); for (var cnt = 0; cnt < config.ReaderThreadsCount; cnt++) { var readTask = new Task(() => ReadListCount(config.ReaderExecutionsCount, config.ReaderExecutionDelay)); tasks.Add(readTask); } for (var cnt = 0; cnt < config.WriterThreadsCount; cnt++) { var writeTask = new Task(() => AddNumbersToList(config.WriterExecutionsCount, config.WriterExecutionDelay)); tasks.Add(writeTask); } tasks = tasks.OrderBy(x => Random.Shared.Next()).ToList(); var stopwatch = new Stopwatch(); stopwatch.Start(); foreach (var task in tasks) { task.Start(); } Task.WhenAll(tasks).Wait(); stopwatch.Stop(); return stopwatch.ElapsedMilliseconds; } public abstract void AddNumbersToList(int writerExecutionsCount, int writerExecutionDelay); public abstract void ReadListCount(int readerExecutionsCount, int readerExecutionDelay); }
In this example, we implement the Execute()
method. The method takes a ThreadExecutionConfiguration
object to configure the number of threads, their delay, and the number of values they will read or write:
public class ThreadExecutionConfiguration { public int ReaderThreadsCount { get; init; } public int WriterThreadsCount { get; init; } public int ReaderExecutionDelay { get; init; } public int WriterExecutionDelay { get; init; } public int ReaderExecutionsCount { get; init; } public int WriterExecutionsCount { get; init; } }
The Execute()
method creates the configured number of threads for reading values from the critical resource. Let’s call them readers. The method also creates threads that will write values to the critical resource, and we’ll call them writers. We add all created threads in a list, shuffle the list to execute readers and writers in random order, and then call the Start()
method.
The base class BaseReaderWriter
has a NumbersList
. This List
is a critical resource as multiple threads read and write to it simultaneously. It also defines the abstract methods AddNumbersToList()
and ReadListCount()
, which we will override in descendant classes.
The writer threads execute the AddNumbersToList()
method. This method will add integers to the NumbersList
until it reaches the configured iteration count.
The read threads execute the ReadListCount()
method to read random elements from the NumbersList
.
How the Lock Statement Works
The lock
statement provides a mechanism to ensure that multiple threads do not access a critical resource at the same time. By acquiring a lock on a specific object, the statement ensures that while one thread holds the lock, other threads are prevented from entering the section of code protected by that lock.
Let’s take a look at the override of the AddNumbersToList()
method:
public override void AddNumbersToList(int writerExecutionsCount, int writerExecutionDelay) { lock (LockObject) { for (var cnt = 0; cnt < writerExecutionsCount; cnt++) { NumbersList.Add(cnt); Thread.SpinWait(writerExecutionDelay); } } }
And the override of the ReadListCount()
method:
public override void ReadListCount(int readerExecutionsCount, int readerExecutionDelay) { lock (LockObject) { for (var cnt = 0; cnt < readerExecutionsCount; cnt++) { if (NumbersList.Count > 0) { _ = NumbersList[Random.Shared.Next(0, NumbersList.Count)]; } Thread.SpinWait(readerExecutionDelay); } } }
We add locks around those critical operations using the lock
statement to ensure that only one thread adds numbers or reads the element from the list.
The main problem with this implementation is that readers prevent writers from executing their work while holding the lock. The condition in which one thread is waiting for the release of a locked object is called thread contention. If the number of reader threads is significant, they may even prevent writer thread execution for an extended period. We call this situation thread starvation.
The readers will not modify any values, and locking the critical resource for other readers is unnecessary. Let’s look at the ReaderWriterLock
class, the predecessor of the ReaderWriterLockSlim
class, and see how it is designed to address this problem.
Using the ReaderWriterLock Class
This class’s main idea is to simultaneously allow multiple reader threads or only one writer thread to enter a critical section.
The threads are placed in two distinct queues: reader and writer. Once the writer releases the lock, all readers in the queue enter the lock. If a new reader thread starts after that and a writer is already waiting in the queue, this reader is not allowed to enter the lock. This prevents an infinite number of readers from permanently blocking writers.
Let’s look at how to implement the previous example with this class. Instead of the lock object, we initialize the ReaderWriterLock
class field:
private static readonly ReaderWriterLock ReaderWriterLock = new();
We modify the AddNumbersToList()
method:
public override void AddNumbersToList(int writerExecutionsCount, int writerExecutionDelay) { ReaderWriterLock.AcquireWriterLock(Timeout.InfiniteTimeSpan); try { for (var cnt = 0; cnt < writerExecutionsCount; cnt++) { NumbersList.Add(cnt); Thread.SpinWait(writerExecutionDelay); } } finally { ReaderWriterLock.ReleaseWriterLock(); } }
The AcquireWriterLock()
method acquires a writer lock on the critical resource. We pass a timeout as an argument to the method’s call to determine how long it will try to acquire this lock before it throws ApplicationException
.
There is a second overload for AcquireWriterLock()
which takes an integer timeout value. This value specifies the number of milliseconds the thread will try to acquire the lock before throwing an ApplicationException
. A 0 value makes the method fail immediately if it cannot acquire the lock. We can also pass -1 or Timeout.InfiniteTimeSpan
which causes the thread to wait until the lock is free.
Once the writer acquires the lock, no other threads, readers, or writers can enter the critical section protected by the same ReaderWriterLock
instance. After the thread finishes its work, it releases the lock by calling the ReleaseWriterLock()
method.
We also modify the ReadListCount()
method in the same way, using the AcquireReaderLock()
and ReleaseReaderLock()
methods:
public override void ReadListCount(int readerExecutionsCount, int readerExecutionDelay) { ReaderWriterLock.AcquireReaderLock(Timeout.InfiniteTimeSpan); try { for (var cnt = 0; cnt < readerExecutionsCount; cnt++) { if (NumbersList.Count > 0) { _ = NumbersList[Random.Shared.Next(0, NumbersList.Count)]; } Thread.SpinWait(readerExecutionDelay); } } finally { ReaderWriterLock.ReleaseReaderLock(); } }
The ReaderWriterLock
class can improve the application’s overall performance, but we should use it only in scenarios with significantly more readers than writers. If that is not the case, it may perform slower than the lock statement.
Additionally, ReaderWriterLock
allows the lock’s entrance. That means we can acquire the lock multiple times in the same thread. We also have to release the lock every time we acquire it. Otherwise, we can cause a deadlock when one thread tries to enter the lock that is never available. ReaderWriterLock
is slightly more complex than a simple lock
statement, which brings some overhead in acquiring the locks. This implies that it should be used when there is an expectation of high thread contention and when the anticipated time of thread blocking justifies the overhead involved.
This class prioritizes readers over writers, causing slower execution of writers.
These issues are further addressed in a newer implementation known as the ReaderWriterLockSlim
class.
When to Use ReaderWriterLockSlim Over lock in C#
The ReaderWriterLockSlim
is a more optimized implementation of the ReaderWriterLock
class. Let’s describe some of the key changes it brings.
The ReaderWriterLockSlim
in its current implementation, prioritizes writers over readers (this policy may change in future versions). When readers enter the queue, all writers, if any, execute first.
By default, it doesn’t support recursion. That implies the same thread can’t acquire the lock multiple times and cause deadlocks. However, an overload of the constructor allows recursion by setting the recursion policy constructor’s argument to LockRecursionPolicy.SupportsRecursion
. This enables backward compatibility with the ReaderWriterLock
class:
public ReaderWriterLockSlim(LockRecursionPolicy recursionPolicy)
It implements spin locks while waiting to acquire the critical resource. That means that a thread won’t block, but it will wait for a short time and try to acquire the lock again, avoiding expensive context switching. But in the case of long-running threads, this approach can hurt performance.
Finally, the ReaderWriterLockSlim
class provides a simpler API. Let’s take a look at the implementation of the writer thread:
public override void AddNumbersToList(int writerExecutionsCount, int writerExecutionDelay) { ReaderWriterLockSlim.EnterWriteLock(); try { for (var cnt = 0; cnt < writerExecutionsCount; cnt++) { NumbersList.Add(cnt); Thread.SpinWait(writerExecutionDelay); } } finally { ReaderWriterLockSlim.ExitWriteLock(); } }
The thread enters the lock by calling the EnterWriteLock()
method. This method will try to acquire the lock indefinitely. The TryEnterWriteLock()
version of the method allows a timeout configuration similar to the ReaderWriterLock
class’s AcquireWriterLock()
method. The thread will release the lock with the ExitWriteLock()
method.
Now let’s see our updated reader thread implementation:
public override void ReadListCount(int readerExecutionsCount, int readerExecutionDelay) { ReaderWriterLockSlim.EnterReadLock(); try { for (var cnt = 0; cnt < readerExecutionsCount; cnt++) { if (NumbersList.Count > 0) { _ = NumbersList[Random.Shared.Next(0, NumbersList.Count)]; } Thread.SpinWait(readerExecutionDelay); } } finally { ReaderWriterLockSlim.ExitReadLock(); } }
The thread will enter the lock with the EnterReadLock()
method and exit it with the ExitReadLock()
method. The TryEnterReaderLock()
overload also allows configuring the timeout.
Upgradable Locks
The ReaderWriterLockSlim
class allows one upgradable reader thread to acquire the lock. This thread can upgrade to a writer thread without releasing its read access to the critical resource.
The upgradeable mode is intended for cases when the thread mostly reads from the critical resource, but in some cases, needs to write values. Other readers can also enter the lock when an upgradable thread enters the lock. If the upgradeable lock requires the writer lock, it must wait for all readers to exit it. This thread can also downgrade to read mode by calling the EnterReadLock()
method.
Let’s look at an example implementation:
public void ReadOrAdd(int writerExecutionDelay) { var random = Random.Shared.Next(0, NumbersList.Count); ReaderWriterLockSlim.EnterUpgradeableReadLock(); try { if (!NumbersList.Contains(random)) { ReaderWriterLockSlim.EnterWriteLock(); try { NumbersList.Add(random); Thread.SpinWait(writerExecutionDelay); } finally { ReaderWriterLockSlim.ExitWriteLock(); } } } finally { ReaderWriterLockSlim.ExitUpgradeableReadLock(); } }
The thread enters the lock by calling the EnterUgradableReadLock()
method. If it can’t find the random number in the list, it changes the state to a writer lock by calling the EnterWriteLock()
method. If the thread enters the write lock, it must release it by calling the ExitWriteLock()
. After processing, the thread will exit the upgradable lock by calling the ExitUpgradableReadLock()
method.
The Execution Time
When we run the application, it will run the Execute()
method of each implemented class:
var config = new ThreadExecutionConfiguration { ReaderThreadsCount = 20, WriterThreadsCount = 2, ReaderExecutionDelay = 100, WriterExecutionDelay = 100, ReaderExecutionsCount = 100000, WriterExecutionsCount = 100000, }; var lockReadWrite = new LockReadWrite(); Console.WriteLine($"LockReadWrite execution time: {lockReadWrite.Execute(config)} milliseconds."); var readerWriterLockReadWrite = new ReaderWriterLockReadWrite(); Console.WriteLine("ReaderWriterLock execution time: " + $"{readerWriterLockReadWrite.Execute(config)} milliseconds."); var readerWriterLockSlimReadWrite = new ReaderWriterLockSlimReadWrite(); Console.WriteLine("ReaderWriterLockSlim execution time: " + $"{readerWriterLockSlimReadWrite.Execute(config)} milliseconds.");
We can see that in the case of more readers than writers, the ReaderWriterLockSlim
class performs significantly better than the lock statement, or even the ReaderWriterLock
class, which falls in between regarding execution time:
LockReadWrite execution time: 981 milliseconds. ReaderWriterLock execution time: 525 milliseconds. ReaderWriterLockSlim execution time: 371 milliseconds.
Note that execution time may differ across hardware configurations and with each new application run.
You can also change the ThreadExecutionConfiguration
object to inspect how different values might affect performance.
Conclusion
The reader/writer locks work best when most threads are reading critical resources, with writes that are rare and executed quickly.
New implementations should use the new, improved reader/writer lock class: ReaderWriterLockSlim
.
We should carefully examine the application flow and use the appropriate lock mechanism for the situation at hand. Keep in mind that when misused, the ReaderWriterLockSlim
class can decrease an application’s performance.