Updated on
Use ReaderWriterLockSlim instead of lock when many threads read shared data, few threads write it, and each read takes long enough that readers would otherwise queue behind each other. When writes are frequent or the locked code is tiny, a plain lock is simpler and usually just as fast.
We’ll build the same reader and writer workload three times, with lock, the older ReaderWriterLock and ReaderWriterLockSlim, time it on .NET 10, and see what ReaderWriterLockSlim fixes in its predecessor. Then we’ll look at code that awaits while holding the lock, which is where ReaderWriterLockSlim stops working.
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:
using System.Diagnostics;
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 tasks, 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 tasks for reading values from the critical resource. Let’s call them readers. The method also creates tasks that will write values to the critical resource, and we’ll call them writers. We add all created tasks 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 the descendant classes LockReadWrite, ReaderWriterLockReadWrite and ReaderWriterLockSlimReadWrite.
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.
Since .NET 9, the object we lock on can be a dedicated System.Threading.Lock instead of a plain object. The lock statement reads the same, but the compiler routes it through Lock.EnterScope() instead of Monitor, and Microsoft’s C# reference recommends it for best performance. That’s why our sample declares its lock field as private static readonly Lock LockObject = new();.
It’s still a lock that lets one thread in at a time, readers included, so everything below about lock holds for it too. We cover the type itself in how System.Threading.Lock changes what lock compiles to.
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 recursive lock entry. 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.
Upgrading is awkward, too. A thread that holds a reader lock and calls UpgradeToWriterLock() goes to the back of the writer queue, behind every other writer that is already waiting.
These issues are addressed in a newer implementation, the ReaderWriterLockSlim class, and Microsoft’s documentation is blunt about which one to pick: “ReaderWriterLockSlim is recommended for all new development.”
What Is ReaderWriterLockSlim in C#?
ReaderWriterLockSlim is a lock from the System.Threading namespace that lets many threads read a shared resource at the same time, while a thread that writes gets the resource to itself.
A thread enters it in one of three modes. Read mode is shared, and any number of readers can be inside together. Write mode is exclusive: while one thread writes, nobody else gets in, reader or writer. Upgradeable mode sits in between. One thread reads with the right to switch to writing later, while ordinary readers keep reading next to it.
A lock, by contrast, lets exactly one thread in even when every thread only wants to read.
It replaces the older ReaderWriterLock class with simpler rules for recursion and for moving between modes, and Microsoft’s documentation describes its performance as significantly better. Each mode has an Enter method, a TryEnter method with a timeout, and an Exit method that we call from a finally block.
Let’s look at how it behaves in more detail.
In its current implementation, ReaderWriterLockSlim favors writers (Microsoft notes this policy may change in future versions). A thread that asks for a read lock waits while any writer is queued, so the queued writers go first.
By default, it doesn’t support recursion. If the same thread tries to enter the lock a second time, it gets a LockRecursionException instead of a deadlock. 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 spins for a short while before it blocks. A waiting thread retries the lock a few times without giving up its CPU, which avoids an expensive context switch when the lock frees up quickly. Only then does it go to sleep and wait to be woken. When the lock holder keeps the lock for a long time, that spinning is wasted CPU.
Finally, the ReaderWriterLockSlim class provides a simpler API. This time the field is private static readonly ReaderWriterLockSlim ReaderWriterLockSlim = new();. 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 TryEnterReadLock() method also lets us set a timeout.
ReaderWriterLockSlim also implements IDisposable. Our sample keeps one static instance for the life of the app, but a lock that belongs to a shorter-lived object should be disposed along with it.
Upgradeable Locks
The ReaderWriterLockSlim class allows one upgradeable reader thread to acquire the lock. Microsoft’s documentation says: “Only one thread can be in upgradeable mode at any time.” This thread can upgrade to a writer thread without releasing its read access to the critical resource.
Why can’t a thread that already holds a normal read lock just ask for the write lock? Because two such readers could each wait for the other to leave, forever. ReaderWriterLockSlim refuses the move and throws a LockRecursionException, and upgradeable mode is the safe way to do it, since only one thread can hold it.
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 EnterReadLock() and then ExitUpgradeableReadLock().
Let’s look at an example implementation:
public void ReadOrAdd(int writerExecutionDelay)
{
ReaderWriterLockSlim.EnterUpgradeableReadLock();
try
{
var random = Random.Shared.Next(0, NumbersList.Count);
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 EnterUpgradeableReadLock() 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 upgradeable lock by calling the ExitUpgradeableReadLock() method.
Is ReaderWriterLockSlim Faster Than lock?
It is when reads dominate and each reader holds the lock for a while. In our run, 20 reader tasks and 2 writer tasks each looped 100,000 times inside the lock. With lock, every reader waited for every other reader, and the whole job took several times longer than with either reader-writer lock.
Under lock, our 20 readers take turns. Under ReaderWriterLockSlim, they read together, and only the 2 writers need the room to themselves.
With mostly writers, a reader-writer lock lets one thread in at a time just like lock does, and it still pays for extra bookkeeping on every enter and exit. Very short critical sections behave the same way: if a reader is in and out in a few nanoseconds, nobody queues behind it.
So before switching, it’s worth timing our own workload. ReaderWriterLockSlim pays off when reads clearly outnumber writes and the protected work takes real 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.");
With 20 readers and 2 writers, both reader-writer locks finish far ahead of the lock statement:
LockReadWrite execution time: 7440 milliseconds. ReaderWriterLock execution time: 1852 milliseconds. ReaderWriterLockSlim execution time: 1505 milliseconds.
Our two reader-writer locks finished close to each other here, and the gap between them changed from run to run. That doesn’t contradict Microsoft’s advice. Each of our readers holds the lock for 100,000 loop iterations, so the difference between the two classes barely shows. Execution time will also differ across hardware configurations and with each new run.
You can also change the ThreadExecutionConfiguration object to inspect how different values might affect performance.
Can We Use ReaderWriterLockSlim With async and await?
No. ReaderWriterLockSlim belongs to the thread that entered it, and an await can resume our method on a different thread.
Microsoft’s documentation calls this managed thread affinity: the same thread has to enter and exit each mode. After an await, the code that follows usually runs on a thread pool thread, and that thread doesn’t hold the lock. When it calls ExitWriteLock(), we get a SynchronizationLockException, and the lock stays held by the thread that entered it, which has long since moved on to other work.
The compiler won’t warn us. It stops an await inside a lock block with error CS1996, but EnterWriteLock() and ExitWriteLock() are ordinary method calls. The mistake compiles and fails at run time, and only when the continuation lands on another thread.
For async code, the usual replacement is a SemaphoreSlim with a count of one and its WaitAsync() method. We lose shared reading, but it doesn’t care which thread releases it.
Let’s see it happen. This is code not to copy: the sample’s AsyncPitfall class enters the write lock, awaits a short delay, and tries to exit, with the await sitting between EnterWriteLock() and ExitWriteLock() on purpose:
public static class AsyncPitfall
{
public static async Task<string> ExitWriteLockAfterAwait()
{
var readerWriterLockSlim = new ReaderWriterLockSlim();
var threadBefore = Environment.CurrentManagedThreadId;
readerWriterLockSlim.EnterWriteLock();
await Task.Delay(50);
var threadAfter = Environment.CurrentManagedThreadId;
try
{
readerWriterLockSlim.ExitWriteLock();
return $"Exited on the same thread ({threadBefore}).";
}
catch (SynchronizationLockException ex)
{
return $"{ex.GetType().Name} on thread {threadAfter} " +
$"(entered on thread {threadBefore}): {ex.Message}";
}
}
}
We print the result with Console.WriteLine(await AsyncPitfall.ExitWriteLockAfterAwait()); on the last line of Program.cs:
SynchronizationLockException on thread 6 (entered on thread 2): The write lock is being released without being held.
The thread numbers will differ from run to run, and when the method starts on a thread pool thread, the continuation often lands back on that same thread and the exit succeeds. That’s what makes the bug hard to catch: the same code can pass a test many times and still fail later.
Here’s how the three options we’ve seen compare side by side:
lock statement | ReaderWriterLockSlim | ReaderWriterLock |
|
|---|---|---|---|
| Threads inside at once | One | Many readers, or one writer | Many readers, or one writer |
| Same thread enters twice | Allowed | Throws LockRecursionException unless created with SupportsRecursion | Allowed |
| Read first, then write | Not applicable | Upgradeable mode, one thread at a time | UpgradeToWriterLock(), back of the writer queue |
| Timeout | Monitor.TryEnter() or Lock.TryEnter(), not the statement | TryEnterReadLock() and friends return false | AcquireReaderLock(timeout) throws ApplicationException |
await while holding it | Compiler error CS1996 | Compiles, then SynchronizationLockException on exit | Compiles, then ApplicationException on release |
| Use it when | Writes are common or the locked code is short | Reads clearly outnumber writes and each read takes real time | Only in existing code. Microsoft recommends ReaderWriterLockSlim for new code |
If the shared data is a dictionary or a queue, we may not need a lock at all, since the concurrent collections handle thread safety for us. And for async code, SemaphoreSlim and its WaitAsync() method is where we’d start.
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. Just keep it out of async methods that await while holding it, and remember that the new System.Threading.Lock type makes lock faster but still lets only one thread in.
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.
Tested with .NET 10 (SDK 10.0.302) and MSTest 4.5.1.
