When you begin your journey with multithreading, you’ll quickly encounter challenges related to accessing resources shared between multiple threads simultaneously, which can lead to race conditions.
Race condition (or race hazard) in software development is a situation when the result of the operation is dependent on the order of the preceding operations that is not guaranteed and can be treated as a bug if one or more possible results occurring under particular circumstances are undesired.
To mitigate this issue, C#/.NET provides several useful tools in its standard libraries. However, many developers either use them without fully understanding their implications or are unaware of the better part of the available options.
In this article series, my goal is to introduce you to some of the available options for resolving multithreading issues and explain how they work. This will help you make informed decisions about their usage in both future and current projects.
General Purpose Synchronization Primitives
Let me begin by describing synchronization methods that have a broad range of use cases and should be the go-to choice or the first solution whenever you encounter synchronization issues. These types typically offer the best balance between performance and readability, and in most cases, you can treat them as a baseline for measuring and selecting the optimal synchronization solution.
Monitor (lock)
Most, if not all, .NET developers are familiar with the lock keyword, which acquires a mutual-exclusion lock for a given object, limiting access to the specified code segment to just one thread at a time., and utilise most of the JUnit library’s extension points.
private readonly object _lockObject = new(); public void Operation() { lock (_lockObject) { // only one thread at the time can be here } }
The above code snippet is equivalent to:
private readonly object _lockObject = new(); public void Operation() { bool lockWasTaken = false; try { System.Threading.Monitor.Enter(_lockObject, ref lockWasTaken); // only one thread at the time can be here } finally { if (lockWasTaken) { System.Threading.Monitor.Exit(_lockObject); } } }
Generally, it is preferable to use the lock keyword instead of explicitly using the Monitor class, as it abstracts away the boilerplate code and helps prevent small mistakes that could lead to hours of debugging hidden deadlocks in your application.
As you will quickly discover, you cannot use the await keyword inside a lock synchronization block. Yes, you can work around this limitation by using Monitor explicitly, but should you? There are several well-considered reasons why using await inside a lock block is prohibited.
For example, with the following code, you could break the exclusivity of the lock due to the re-entrant nature of Monitor (the ability of lock being re-entered multiple times by the same thread holding the lock). This could lead to unexpected behaviour, such as deadlocks or other synchronization issues.
public class ViewService { private readonly List<Note> _localNotes = new(); private readonly object _lock = new(); private readonly DbContext _dbContext; public ViewService(DbContext dbContext) { _dbContext = dbContext; } // 1. This method is called by Thread 1. public async Task RefreshNotesAsync() { // 2. Lock is obtained by Thread 1. lock (_lock) { // 3. Thread 1 is yielding the control var fetchedNotes = await _dbContext.Notes.ToListAsync(); // 7. Db call has ended and threadpool has scheduled // further execution on Thread 2 so now we have two threads // executing 'locked' code segment... _localNotes.Clear(); _localNotes.AddRange(fetchedNotes); // 8. Thread 2 will attempt to release the lock // but will fail with exception } } // 4. While waiting for db response Thread 1 is used to execute this method. public async Task AddNoteAsync(Note note) { // 5. Since Thread 1 is the one holding the first lock // it can re-enter the Monitor lock (_lock) { // 6. Despite the fact that lock was not released Thread 1 executes here.... await _dbContext.Notes.AddAsync(note); await _dbContext.SaveChangesAsync(); _localNotes.Add(note); } } }
An important step to underline is the 8th where beneath the syntax sugar Thread 2 makes the call to Monitor.Exit() which will throw the exception because the Monitor knows that Thread 2 was not the one holding the lock.
Monitor has to offer more than .Enter(object) and .Exit(object) methods it also exposes .Wait(object) and .Pulse(object) which are used among other things in SemaphoreSlim implementation — something I’ll describe later in this article. But what are those methods and how are they different from Enter/Exit?
The Enter/Exit method pair is used when you want to acquire an exclusive access to a lock, whereas Wait/Pulse allows for cooperative notification between threads. Let’s break it down:
When one thread calls Enter with an object, any other thread that attempts to call Enter with the same object before the first thread releases it will have to wait until the first thread releases it. These waiting threads are placed in a queue, and their order is determined by the order in which they called the Enter method. This is clear and straightforward, but…

Yes! When you call Enter on an object for which the lock is already taken, you are placed in what’s called the ready queue, where each next thread will obtain the lock in order as soon as Release is called. However, this is not the case when you use Wait, as calling Wait puts the thread into a waiting queue, and it will stay there regardless of calls to Release
Now, imagine a scenario where you’ve entered the lock, and as the code executes, at some point it cannot proceed because the current state does not meet the preconditions. You know that another thread can fix the state at some point, but to allow that to happen, you need to release the lock. So, what can you do?
That’s exactly where Wait comes in handy. It does two things:
Releases the lock — this allows another thread in the ready queue to obtain the lock and continue its work.
Enters the waiting queue — the thread is placed in the waiting queue, where it can be awakened later when the state is corrected by another thread.
And how do you inform a thread in the waiting queue that it’s time to proceed? As you might have guessed, by using Pulse, which notifies the first element in the waiting queue to move to the ready queue. (Yes, it may still have to wait in the ready queue, but eventually, it will re-enter the lock.) You can call Pulse multiple times, and each call will move one thread from the waiting queue to the ready queue. There’s also a PulseAll method which, as you can probably guess, wakes up all the threads in the waiting queue. 😃
ℹ️ Same as for Wait only the thread that currently holds the lock can call the Pulse and PulseAll methods.
Proceeding with more examples, you could implement something like this (though it may not be the best approach for a real-world production scenario, it does illustrate the point):
.
Releases the lock — this allows another thread in the ready queue to obtain the lock and continue its work.
public class CurrencyExchanger { private decimal _rate; private object _lock = new(); // 1. We call exchange with some amount public decimal Exchange(decimal amount) { // 2. Lock is obtained lock (_lock) { if (_rate == 0) { // 3. We discover that _rate was not set... // so we are releasing the lock and wait in waiting queue... System.Threading.Monitor.Wait(_lock); // 8. PulseAll was called so we moved to ready queue and since lock // was released in step 7 we obtain it again here and proceed } return amount * _rate; } } // 4. Some good samaritanin has updated the rate public void SetRate(decimal rate) { // 5. We are obtaining the lock as nothing holds it // and we have to be able to Pulse lock (_lock) { _rate = rate; // 6. We call PulseAll to notify the waiting queue that rate is set System.Threading.Monitor.PulseAll(_lock); // 7. We release the lock } } }
You can learn everything about the lock from the documentation below.
The lock statement – synchronize access to shared resources – C# reference
Use the C# lock statement to ensure that only a single thread exclusively reads or writes a shared resource, blocking…
learn.microsoft.com
One important concept to remember, which is likely intuitive, is double-checked locking. This involves performing an additional check after acquiring the lock, in case the initial fast-path attempt (usually without locking) failed, or the state has changed since the first check. This helps avoid unnecessary locking and can improve performance in scenarios where the lock is rarely needed.
// Fast path - check if we have value before entering the lock if (connection is not null) { return connection; } lock (_connectionCreationLock) { // second check to verify if another thread did not create connection during our wait if (connection is not null) { return connection; } // create the connection... }
Additionally, there are plans for .NET 9 (as of the time of writing this article) to introduce new Lock class that can be used as the argument of the Monitor or lock statement to be more explicit with intent of the object, but not only that, as it can bring some performance improvements.
I will not cover that topic in this article, but if you are interested, feel free to check another blog post about it and the proposal document.
SemaphoreSlim
Let’s proceed with another synchronization primitive that you’ll most commonly use when you need to incorporate asynchronous code within a synchronized context. However, as you’ll see, this isn’t the only use case for it!
SemaphoreSlim is a lightweight version of the Semaphore class, which I won’t go into more detail about over here. The key distinction between SemaphoreSlim and the Monitor described earlier is its ability to wait asynchronously to acquire a lock. This means that instead of blocking the current execution thread while waiting for the lock, the thread pool can dispatch current thread to do other work and return to attempt acquiring the lock once it becomes available.
SemaphoreSlim in its internal implementation is using Monitor to control the concurrent access and notify waiting threads about it being released — check its source code if you are interested in details.
Moreover, this synchronization primitive allows more than one thread to enter the synchronization block at the same time! The number of threads allowed to enter is determined by the programmer through the constructor parameter initialCount. This parameter specifies how many threads can access the block concurrently and sets a maximum limit on the number of threads allowed to enter.
The previous sentence might sound a little strange, as it seems like specifying the maximum number of threads allowed should be the same as setting the limit. However, not exactly. To understand this distinction, you need to know that a semaphore doesn’t track thread identity. This means that if Thread A enters a SemaphoreSlim, it doesn’t need to be the same thread that releases it. Any thread, like Thread B, can release the semaphore. The synchronization primitive only cares about maintaining the correct count of threads within the synchronized context at any given time. It’s the programmer’s responsibility to ensure that the semaphore is properly acquired and released.
The following code will work perfectly fine, even though you are acquiring and releasing the lock from different threads:
private readonly static SemaphoreSlim _semaphore = new(1); public static async Task Execute() { for (var i = 0; i < 3; i++) { // We acquire lock on thread 1 await _semaphore.WaitAsync(); // Lock is released on free background thread Task.Run(() => _semaphore.Release()); Console.WriteLine(i); } }
Run it with an IDE — we can’t enforce intelligent batching as of writing this article due to the liIn the end, what determines whether a thread can acquire the lock is a counter that tracks how many available spots there are. For example, if the counter has a value of 2, it means that two threads can acquire the lock. When the counter reaches 0, the next thread attempting to acquire the lock will have to wait until a spot becomes available.
The initialCount parameter defines how many threads can acquire the lock at the same time before others have to wait (as you already know). However, since the semaphore operates with a counter and doesn’t track thread identity, what do you think will happen if you call Release more times than you acquire the lock?
private readonly static SemaphoreSlim _semaphore = new(initialCount: 1, maxCount: 2); public static async Task Execute() { await _semaphore.WaitAsync(); // do some work... // yes, we can explicitly tell how many times we want to release _semaphore.Release(2); }
When the Execute() method is first called, it acquires the lock on the semaphore. At this point, if another thread tries to enter this method, it will have to wait until the first thread calls Release on the semaphore. Once the first thread finishes, it calls Release(2), which is the same as calling Release twice. Since you’ve specified a maxCount as 2, this will allow up to two threads to enter the semaphore without waiting. As a result, when the second thread enters and the third enters before the second releases the lock, neither will need to wait. However, there’s a bug in this example: Release(2) is called without checking the current number of threads allowed, which could exceed the maxCount and cause a SemaphoreFullException to be thrown.
ℹ️ In the examples before the maxCount was mentioned, semaphore slim constructor had one parameter which caused setting maxCount to int.MaxValue (so essentially setting no limit) which might be prone to bugs. As you can guess, in nearly all of the cases you want to explicitly set both parameters and maxCount to be equal to initialCount.
One important thing to note about SemaphoreSlim is that mixing asynchronous and synchronous waits can introduce overhead for synchronous waits, as Task objects may be created to queue after asynchronous waiters.
Why does this happen? When performing a synchronous wait using Wait(), and there are no asynchronous waiters, the mechanism relies on Monitor with Wait() and Pulse() to block until the semaphore is released. However, if there are already asynchronous waiters in the queue, a synchronous wait creates a Task object, which is enqueued alongside the asynchronous waiters. The synchronous wait is then resolved by calling asyncWaitTask.GetAwaiter().GetResult(), effectively awaiting the task in a blocking manner (source code reference).
When to choose SemaphoreSlim?
One thing is clear: acquiring a lock (when it doesn’t block) is faster with Monitor than with SemaphoreSlim. This is because SemaphoreSlim has to perform more work to provide asynchronous waiting functionality. Therefore, unless you have a very specific use case, Monitor should be your first choice.
Some examples of use cases for SemaphoreSlim:
- Code that requires asynchronous operations within the scope of the synchronized block like interacting with a database where you have to make a pre-flight call to check some preconditions before in-memory operation
- Code segment that can be accessed by a limited number of threads at the time as for example external services APIs that enforce throttling limits to avoid 409 status code responses or code resource intensive execution path in code in order to reduce resource consumption
Summary
I hope the first part helped you gain a deeper understanding of how basic synchronization primitives work, enabling you to use them more consciously. Additionally, I trust you can appreciate the advantages of the simple approach of using these primitives as your first choice for synchronizing work.
In the next part, I’ll explore specialized collections for resolving race condition issues. See you there!

