At the end of this series, let me introduce some less obvious, more advanced synchronization primitives that you can use on a daily basis or during micro-optimizations to shave off milliseconds or microseconds from your hot paths.
Specialized Synchronization Primitives
The previously discussed synchronization primitives were either general-purpose or variants of well-known structures made thread-safe. While each of these structures is typically used for its intended purpose, making them specialized in that sense, I will move on to the types that are more specialized for the multithreaded environment.
ReaderWriterLockSlim
The name of this type already hints at its special use case: it is designed for scenarios where both read and write operations are present. In this context, “read” refers to non-mutating operations, while “write” refers to mutating operations that modify the data.

You might be familiar with the concepts of CQRS (Command Query Responsibility Segregation) or CQS (Command Query Separation), where the core idea is to separate the logic of queries (which are responsible for reading data) from commands (which are designed to mutate data). The reason for this split is that, since queries do not modify data, they can run concurrently without affecting each other’s results. Commands, on the other hand, require exclusivity over the data source or its fragment to maintain data consistency due to their side effects. Even though these patterns are related to architectural design at a higher level of abstraction than discussed here, the same principle applies in this context.
The ReaderWriterLockSlim implements this idea by allowing multiple reading threads to run concurrently while giving an exclusive lock to each writer individually.
To better grasp the idea, it is beneficial to recognize the states in which ReaderWriterLockSlim can be:
Not entered – No threads have entered the lock with either read or write permissions.
Read – One or more threads have entered the lock with permission to read.
Write – One thread has entered the lock with write permission and gained exclusive access; other threads attempting to acquire a lock for either read or write will be blocked.
Here’s an example to illustrate how it works:
public class Account { private readonly Dictionary<String, Transaction> _transactions = []; private readonly ReaderWriterLockSlim _rwLock = new(); public bool TryGetTransaction(string id, out Transaction transaction) { // If there are threads waiting for write permissions // thread will be blocked until those exit _rwLock.EnterReadLock(); // If we reached that point the lock was either in Not Entered or // Read state and now it will be in Read state try { return _transactions.TryGetValue(id, out transaction); } finally { _rwLock.ExitReadLock(); // If we exit and there are no other reading threads or // waiting writers, we will go to Not Entered state } } public void AddTransaction(Transaction transaction) { // If there is other thread with write permission or threads // with read permission this call will be blocked until // those will exit the lock _rwLock.EnterWriteLock(); // After reaching this point lock will be in Write state try { _transactions.Add(transaction.Id, transaction); } finally { _rwLock.ExitWriteLock(); // After this point lock will be in Not Entered state } } }
⚠️ It is the programmer’s responsibility to ensure that the code path accessed via the reader’s lock does not modify the guarded data!
The principle behind how the ReaderWriterLockSlim manages readers and writers is a combination of the managed thread ID, which helps determine whether the thread is a reader or a writer, along with handling recursive calls (similar to Monitor) and counting lock entrances (much like Semaphore).
BThere is one additional operation that can optimize the usage of the ReaderWriterLockSlim, called the UpgradableLock. When acquiring the lock, the thread initially holds a reader lock. However, if certain conditions later determine that a writer lock is needed, the thread can upgrade its current lock to a writer lock without having to release the reader lock first.
This implies one additional state besides the ones described at the beginning.
Upgrade – One thread entered the lock with read permission and an option to be upgraded. At the same time there can be multiple threads with read permission accessing the lock but any new thread attempting to enter the lock as a reader with the option to upgrade will be blocked.
public void FlushTransactionIfExpired(string transactionId) { // We enter the with read permission and upgrade option // because we might need to modify the data under some conditions _rwLock.EnterUpgradeableReadLock(); // The lock from this point is in Upgrade state try { if (!_transactions.TryGetValue(transactionId, out var transaction)) { return; } if (transaction.IsExpired is false) { return; } // We have evaluated the need to obtain write lock to // modify the data so we upgrade _rwLock.EnterWriteLock(); // The lock from this point is in Write state try { _transactions.Remove(transaction.Id); } finally { // We downgrade back to read permission _rwLock.ExitWriteLock(); // The lock from this point is in Upgrade state } } finally { _rwLock.ExitUpgradeableReadLock(); // The lock from this point will be either in Not Entered state // or Read state if there were any threads with read permissions } }
What’s important to note while using upgradable lock is the fact that only one thread can access that state so we cannot use it in the hot-path of the application as that would be equivalent to using Monitor — but slower.
What do you gain by using the ReaderWriterLockSlim? It’s important to note that internally, the ReaderWriterLockSlim uses a SpinLock, which I will describe in more detail at the end of this article. Essentially, you can think of it as a Monitor that allows for shorter wait times (measured in CPU cycles) to acquire the lock. This may seem like it would outperform a regular Monitor, but in simple benchmarks, where the only operation performed within the lock is, for example, retrieving an element from a dictionary, the ReaderWriterLockSlim often performs similarly to a basic lock. The reason is that when the actual operation inside the lock is quick, the overhead of acquiring a read lock in the ReaderWriterLockSlim may not be worth the additional work required, so it would be most beneficial and recommended to use it instead of Monitor when the number of read- related accesses exceeds the number of write related ones.
Interlocked
In part 2, when discussing BlockingCollection and ConcurrentBag, I mentioned that even simple operations like number++ carry a risk of race conditions. On a daily basis, you most likely perform similar operations — such as incrementing, decrementing, swapping, or comparing. Since these operations seem trivial, you might begin to wonder if there’s a more efficient way to synchronize them than using the lock statement (Monitor).
And the answer is yes!
The Interlocked class provides an API that allows us to perform certain operations atomically. Examples of such operations include Interlocked.Increment() and Interlocked.Decrement(), which lets us perform thread-safe versions of operations like number++ or number–. The key performance difference between using these methods and wrapping code in a lock block is that a Monitor involves calling system-level functions to acquire a lock and synchronize access, which is relatively slow. In contrast, Interlocked methods operate directly on CPU instructions, making them much more efficient for simple atomic operations.
Operational models for our extension
Run it with an IDE — we can’t enforce intelligent batching as of writing this article due to the limitations of JUnit 6 API [1]. But we can make it work with existing and newly annotated tests in a regular way that does not impose Feature Toggle state-derived batch order. Efficiently, we will just do more toggle switching (a small library will do it for us) when we run this way, or we can disable the FTs by default (this part may require extra work beyond this article).
Through mvn test — similar to above
Let’s take a look at this example: the following statement number++ will be translated into the following assembly code (assuming no compiler optimizations):
mov eax, [ebp-8] # copy value from the stack to eax register inc eax # increment value within eax register mov [ebp-8], eax # copy value from eax register back to the stack (override)
When you use Interlocked, you’re making a method call, but the assembly for the increment operation can look like this (depending on the CPU architecture):
lea eax, [esp] # copies address of current stack pointer to eax mov edx, 1 # sets 1 as a value of edx register lock xadd [eax], edx # lock ensures that no other core will modify related # cache lines xadd sums values from two arguments and # saves result in first argument address
You get slightly more complicated assembly code in exchange for avoiding the overhead of using heavy locks — that’s a win for me!
The same principle of using different assembly instructions to achieve atomicity applies to all other Interlocked methods as well.
Not to leave you with just a brief overview of the Interlocked class, I also want to highlight two methods: one that demonstrates an important concept, and the other one that might make you wonder why it even exists.
First, let’s take a look at the Interlocked.CompareExchange method (and all its overloads). This method performs a simple operation: it compares the value of the destination reference with the comparand value, and if they are equal, it replaces the value at the destination reference with the new value passed as an argument — all as atomic operation.
This simple method implements the Compare-and-Swap (CAS) instruction and performs an atomic operation at the CPU level. For example, if you use Interlocked.CompareExchange(ref number, 5, 8), it will replace the value of the number variable with 5 only if its current value is 8. The resulting assembly code might look like this:
Or like this by package name:
lea ecx, [esp] # set the stack pointer variable reference into ecx mov edx, 5 # set value of edx register to 5 mov eax, 8 # set value of eax register to 8 lock cmpxchg [ecx], edx # compares the value of [ecx] with value within eax # register (8) and in case of equality replaces value # of [ecx] with value of edx all as atomic operation # ensured by lock prefix
With the above mentioned capability you can implement lock-free (but not wait-free) mechanism like that…
private int _state; private int _isLockTaken = 0; public void UpdateState(int state) { // We will loop in while until we get 0 from CompareExchange which means // that there were no lock taken by another thread before we attempted // to set the value of _isLockTaken to 1 - if we are getting 1 // it means that lock is taken while (Interlocked.CompareExchange(ref _isLockTaken, 1, 0) == 1) { // We will keep blocking thread here - this is not best thing to do ofc } _state = state; _isLockTaken = 0; // we are releasing the lock so other thread in while loop can proceed }
This implementation can be improved, but doing so would require one additional synchronization primitive that I will describe shortly. For now, however, it serves as a good showcase.
The second method I want to mention is Interlocked.Read(long/ulong), which might seem interesting because you may wonder why there’s a special method just for reading a value. The thing is that, nowadays, you typically don’t need it. This method is only necessary to ensure atomicity when reading a long on 32-bit systems. Since a long is 64 bits, it cannot be loaded into a single register on those systems and two separate reads are required. So, this method ensures the read operation is atomic.
Consider this as your fact for the day 😄
SpinLock & SpinWait
The last synchronization primitives I’ll describe in this article are SpinLock and SpinWait. These are low-level primitives that can be used in special cases to improve performance. However, the decision to use them instead of Monitor should be backed by a proper profiling to avoid potential performance degradation.
Let’s start with the concept of the SpinLock by focusing on the first part of its name: “Spin-”. When using locks like Monitor or Semaphore, if the lock is already taken by another thread, your thread is queued to be awakened when the lock is released. During this time, the CPU can switch to another thread (context switching). However, context switching can be relatively expensive, depending on how frequently it occurs, and the latency measures you are operating on. If you’re dealing with milliseconds or higher latencies, context switching may not be a major concern.
To avoid this context switching overhead, the SpinLock “spins”. Instead of queuing the thread for the lock, it repeatedly checks if the lock is available, giving up some CPU cycles to allow other threads to work in the meantime. The trade-off here is that the thread is still active, but for a very brief period (much shorter than the time it would take for context switching), it prevents switching out and performs the spinning operation instead of being involved in other work.
What is SpinWait, then? You must be aware that SpinLock uses SpinWait internally to perform the spinning. Using SpinWait correctly is quite challenging, so SpinLock saves us a lot of debugging time while providing easy-to-use abstraction.
Why using SpinLock must be a well-thought-out decision? When using spinning, you assume that the lock will be released very soon. However, if it is not, you have to repeat the spin… and repeat it… and repeat it. You are essentially creating while(true){} loop. If the lock is held for a longer period, not only will you use as many CPU cycles as you would with a Monitor, but you will also waste those cycles. With a Monitor, you would use those cycles to switch contexts and perform additional work, but with spinning, you are just… spinning in place, without accomplishing anything meaningful.
You might worry that with SpinWait, you could block your process, but this will not happen (unless it is the main thread spinning). The SpinWait struct forces a context switch (Yield) after an arbitrary number of spins which delegates executing thread to perform other tasks to prevent the waiting thread from blocking higher-priority threads or the garbage collector.
⚠️ On single threaded processors every call to SpinWait.SpinOnce() will Yield!
You can check if the next spin will cause a context switch (yielding) using the SpinWait.NextSpinWillYield property and opt to perform an actual wait instead. This is the recommended approach to avoid unnecessary forced context switching, as it allows you to gracefully handle situations where the lock-free mechanism has failed, and you must accept the need to wait.
public class MyCustomLock { private int _state = 0; // 0 = free, 1 = taken private object _lock = new(); public void Release() { lock (_lock) { _state = 0; // mark lock as free Monitor.Pulse(_lock); // signal waiting thread in slow path } } public void AcquireLock() { var spinWait = new SpinWait(); while (true) { // we use interlocked to check if we managed to set lock flag // as taken from 0, the Exchange method returns the previous // value stored under the reference if (Interlocked.Exchange(ref _state, 1) == 0) { // we managed to acquire lock with spinning return; } if (spinWait.NextSpinWillYield) { // next spin would yield so we are avoiding it // by breaking out of the loop break; } // wasting a few cycles, maybe we will get lucky and obtain lock spinWait.SpinOnce(); } // slow path, we spinned to yield threshold lock (_lock) { Monitor.Wait(_lock); if (Interlocked.Exchange(ref _state, 1) == 1) { // since Monitor is re-entrant we will not deadlock // when recursive call will enter lock block again // (but it will block other threads that can lead to a deadlock) AcquireLock(); } } } }
Summary
You’ve walked through a significant portion of the synchronization mechanisms available within C#/.NET standard libraries, but there is still more to explore. I strongly encourage you to check out the remaining options to further expand your toolkit. As you may have observed, there are many choices, each with its own advantages and disadvantages depending on the situation and context. I hope that after reading this article, you’ll be able to make more informed decisions about which synchronization mechanism to use in your project. However, always remember that these choices should be backed up by proper profiling!
Overview of synchronization primitives – .NET
Learn about .NET thread synchronization primitives used to synchronize access to a shared resource or control thread…
learn.microsoft.com

