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.

To download the source code for this article, you can visit our GitHub repository.

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.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

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.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!