.NET Synchronisation APIs - Part 1 - In-Process Synchronisation

Introduction

This is the first in a series of posts on .NET synchronisation. I will cover:

  • In-process synchronisation (this post)
  • Out-of-process synchronisation on the same machine
  • Distributed synchronisation

In .NET, in-process synchronisation mechanisms are used to restrict access to shared resources and manage concurrency in multi-threaded applications. The mechanisms I'm going to present on this post can be used only for in-proc code, for synchronising threads, meaning, they cannot be used to synchronise multiple processes, on the same or on different machines - I will write more posts on these subjects and link them here. Here I will be using the terms "locking" and "synchronising on" interchangeably, to mean the same thing.

I won't go into too much theory, but thread synchronisation exists for preventing:

  • Race conditions: two threads trying to modify the same data simultaneously
  • Data corruption: data shared by two or more threads becomes inconsistent because one makes changes without the other knowing about it
  • Deadlocks: two or more threads waiting on one another (synchronisation can also cause this!)

We can group the different locking/access control mechanisms into many ways:

  • Same access mode for all vs different access modes: all of the same nature or read/write
  • Single access to the shared resource versus multiple simultaneous accesses (up to some count, for example)
  • Allows asynchronous calls versus only synchronous calls: can a lock be obtained asynchronously?
  • Operates at native operating system kernel level versus at user level: kernel level requires calls to operating system functions, which makes it "heavier" and operating system-dependant, user level is just managed code
  • Thread ownership or not: only the thread that has the "lock" can release it or no ownership at all, meaning, not actual locks
  • Supports a timeout for getting the lock? Or can we check first if we can acquire it?

Some of the APIs I cover here support out-of-process synchronisation, but I'll leave that for the next post. I'll be covering:

Let's start from the beginning, from the simplest locking mechanism.

The lock Statement

The lock statement is part of the C# language and is just syntactic sugar for the Monitor or Lock classes (explained next), it provides a simple way to ensure only one thread can execute a block of code at a time. It acquires a lock on a specific object and releases it when the block exits (including if an exception is thrown).

lock is for single access to a resource. Sample code:

object _lock = new object();

lock (_lock)
{
    //do something secure
}

Even if some exception occurs inside the lock block, the lock is still released. You should avoid locking on shared resources, such as static fields or types, as well as locking on an instance. See more here.

Multiple threads can be queued waiting to obtain a lock, but only one can have it at any given time. When the lock is released, the next thread on the queue will acquire it.

Lock

lock can actually be implemented by the (surprise, surprise!) Lock class, as of .NET 8, or by Monitor, described next. When we start a lock block, if the type of the argument is Lock, then its EnterScope is called implicitly, which returns an Lock.Scope instance that release the lock when disposed of. There are also Enter/TryEnter and Exit for controlling leaving the block explicitly. If the argument to lock is not a Lock instance, then the Monitor class is used instead.

So, the previous example can be shown as:

var lock = new Lock();

using (lock.EnterScope())
{
    //do something secure
}

Note: Lock.Scope does not implement IDisposable because it is a ref struct, yet, its Dispose method is still called implicitly at the end of an using block. Read about it here.

Monitor

As I said before, the static Monitor class offers basically the same functionality as the lock statement, but with programmatic access:

object _lock = new object();

Monitor.Enter(_lock);

//do something secure

Monitor.Exit(_lock);

But, we need to wrap this in a try/finally block to get it working right:

object _lock = new object();

try
{
    Monitor.Enter(_lock);
    //do something secure
}
finally
{
    Monitor.Exit(_lock);
}

This is exactly what lock does behind the scene if its argument is not a Lock instance. As you can see, Monitor offers Enter and Exit methods, and, from .NET 9, it also offers TryEnter. The difference between Enter and TryEnter is that the former blocks until the lock can be acquired and the latter will return immediately if it cannot get the lock at this time. TryEnter  can also take a timeout for waiting for the lock to be available. You can also use IsEntered to check if the lock is currently acquired by the current thread. It's interface is very similar to that of the Lock class.

Monitor does offer something more than lock: Pulse, PulseAll, and Wait methods. Wait releases a lock and then tries to acquire it again. Pulse and PulseAll notify the first/all threads waiting for a lock about a change on the lock's state. These three are more for advanced, more cooperative, scenarios. All of the methods are synchronous.

Always make sure the lock is released, use a finally clause for that. In general, I'd say, except for advanced scenarios, there is no good reason to use Monitor instead of lock.

MethodImpl Attribute

For completeness, I will also cover the [MethodImpl] attribute. When applied to a method with the MethodImplOptions.Synchronized flag, it synchronises (locks) access to it, in pretty much the same way as lock. What happens is, for instance methods, it locks on the instance, and for static method, it locks on the type instead:

[MethodImpl(MethodImplOptions.Synchronized)]
public void Synchronised Method()
{    
    //do something secure
}

This is essentially the same as having a lock surrounding all of the method's code.

This is easy to use, but, as you can see, it does not respect the general locking guidelines I presented earlier: it either locks on the instance or on its type. In any case, it can be a convenient alternative to lock.

Mutex

Mutex is used to provide mutual exclusion to a specific portion of the code. Like Semaphore it is a classical synchronisation primitive, but it differs in the sense that a Semaphore limits the number of threads that can access a specific resource simultaneously whilst a Mutex is used to grant exclusive access to a shared resource, ensuring that only one thread at a time can access it. Also, a Mutex enforces lock ownership.

Its constructor allows specifying whether the Mutex  is initially owned (locked) or not:

using var mutex = new Mutex(initiallyOwned: false);

//some thread
mutex.WaitOne();
//do something secure
mutex.ReleaseMutex(); //or just let it be disposed

WaitOne is used to acquire a single lock (with or without a timeout) and ReleaseMutex releases it - note that only the thread owning the lock can call it. Mutexes operate at the kernel level and only offers synchronous methods.

More about Mutex here.

Semaphore

Semaphore is a more complex synchronisation mechanism, because it can allow for more than one access at the same time, and all accesses are of the same kind. It is one of the classic synchronisation mechanisms that exists in UNIX as well as Win32 and it operates at kernel level (calls to native operating system functions).

When a Semaphore is created, we need to specify how many concurrent accesses we will allow at one time and what is the initial count (slots taken):

using var semaphore = new Semaphore(initialCount: 1, maximumCount: 3);

semaphore.WaitOne();
//do something
semaphore.Release(releaseCount: 1);

As you can see, the constructor of Semaphore allows specifying:

  • initialCount: the initial number of requests that can be granted concurrently
  • maximumCount: maximum number of requests for the semaphore that can be granted concurrently

maximumCount must be always greater or equal to initialCount, if supplied, and also greater or equal to 1. initialCount can be 0 or more.

WaitOne can be used to acquire the lock (with or without a timeout) and Release releases one or more locks, and can be called by any thread, in Semaphore there is no concept of lock ownership. All methods are synchronous.

SemaphoreSlim

SemaphoreSlim is a lightweight, fast synchronisation primitive designed for limiting concurrent access to resources within a single app process, and it operates at user level. Unlike Semaphore, it is optimised for asynchronous scenarios to prevent blocking threads and it operates at user level.

SemaphoreSlim features a CurrentCount property, which is initially set to the initialCount constructor parameter, and is the number of available slots. It can go up to maxCount. Each time Release is called, it is incremented by the number of slots released.

using var semaphore = new SemaphoreSlim(initialCount: 1, maximumCount: 3);

await semaphore.WaitAsync();
//do something
semaphore.Release(releaseCount: 1); 

More about Semaphore and SemaphoreSlim, their differences and similarities, here.

ReaderWriterLock

ReaderWriteLock is a synchronisation primitive that operates at kernel level and offers two access kinds:

  • Read: multiple reads can exist simultaneously
  • Write: only one write can exist at a given time

Simply put: single writer, multiple readers.

So, if ReaderWriteLock has some threads currently reading from it (read locks), any attempt to acquire a write lock will wait (or fail, if the timeout is exceeded); any number of new read attempts will succeed. If a write lock is in place, all read or write attempts will be put on hold (or fail, if the timeout is exceeded) This is particularly useful in some scenarios and can offer advantages over synchronisation primitives that just blindly lock. There are methods for getting write and read locks (AcquireReaderLockAcquireWriterLock) as well as for releasing them (ReleaseReaderLockReleaseWriterLock) and also for going from a reader lock into a writer one (UpgradeToWriterLock) and for going back (DowngradeFromWriterLock).

An example:

using var readerWriterLock = new ReaderWriterLock();
var collection = new List<int>();

void Reader()
{
    readerWriterLock.AcquireReaderLock();
    //go through the collection
    readerWriterLock.ReleaseReaderLock();
}

void ReaderThatCanTurnWriter()
{
    readerWriterLock.AcquireReaderLock();
    //go through the collection
    var cookie = readerWriteLock.UpgradeToWriterLock(TimeSpan.FromSeconds(5));
    //make modifications
    //return to read lock
    readerWriterLock.DowngradeFromWriterLock(ref cookie);
    readerWriterLock.ReleaseReaderLock();
}

void Writer()
{
    readerWriterLock.AcquireWriterLock();
    //add something to the list
    collection.Add(Random.Shared.Next());
    readerWriterLock.ReleaseWriterLock();
}

var readerThread = new Thread(Reader);
var writerThread = new Thread(Writer);

Perhaps all the ReleaseXXXLock calls should be done in a finally block, so as to make sure they are called.

ReaderWriterLockSlim

ReaderWriterLockSlim is a simplified version of ReaderWriteLock that does not allow cross-process synchronisation and only operates at user lever, doesn't make any switch to kernel level. It also offers a Try version of each lock acquisition method.

ReaderWriterLockSlim offers similar methods to ReaderWriteLockEnterReadLock/EnterWriteLock/TryEnterReadLock/TryEnterWriteLock for acquiring a lock, ExitReadLock/ExitWriteLock for releasing it, plus EnterUpgradeableReadLock/TryEnterUpgradeableReadLock for getting a read lock that can be upgraded to write and ExitUpgradeableReadLock for returning to read mode.

It's usage is essentially the same:

using var readerWriterLock = new ReaderWriterLockSlim();
var collection = new List<int>();

void Reader()
{
    readerWriterLock.EnterReadLock();
    //go through the collection
    readerWriterLock.ExitReadLock();
}

void ReaderThatCanTurnWriter()
{
    if (readerWriterLock.TryEnterUpgradeableReadLock(TimeSpan.FromSeconds(5)))
    {
        //go through the collection
        readerWriterLock.EnterWriteLock();
        //make modifications
        //return to read lock
        readerWriterLock.ExitWriteLock();
        //leave the upgradeable lock
        readerWriterLock.ExitUpgradeableReadLock();    
    }
}

void Writer()
{
    readerWriterLock.EnterWriteLock();
    //add something to the list
    collection.Add(Random.Shared.Next());
    readerWriterLock.ExitWriteLock();
}

var readerThread = new Thread(Reader);
var writerThread = new Thread(Writer);

Events

An event is a kernel-level object that can be used to signal (release and acquire a lock) a single thread at a time. They come in two kinds:

  • AutoResetEvent: signals (releases) a single waiting thread and then goes back to locked state
  • ManualResetEvent: when signalled, remains like that until the lock is acquired again
Both share a base class, EventWaitHandle, which defines the base operations.

ManualResetEvent must be manually reset after being set, while AutoResetEvent automatically resets once it has signaled a waiting thread. Set is used for signalling, Reset to reset the signalled state, WaitOne for waiting, and it can take a timeout.

Example usage for AutoResetEvent and ManualResetEvent:

using var evt = new AutoResetEvent(initiallyOwned: true); //or ManualResetEvent(initiallyOwned: true);
//thread waits for a signal evt.WaitOne(); //signals other thread waiting to continue evt.Set(); //only manual reset event needs this //evt.Reset();

There are also a lightweight version, ManualResetEventSlim (no auto reset version). It behaves in exactly the same way, but cannot be used for inter-process synchronisation (for the next post):

using var evt = new ManualResetEventSlim(initiallyOwned: true); //or ManualResetEvent(initiallyOwned: true);
//thread waits for a signal evt.WaitOne(); //signals other thread waiting to continue evt.Set(); evt.Reset();

CountdownEvent

CountdownEvent is a synchronization primitive that unblocks its waiting threads after it has been signaled a certain number of times.

Example:

var counter = new CountdownEvent(10);
//T1 thread waits for a signal until it has been signalled 10 times
counter.Wait();

//T2 signals other threads waiting to continue
counter.Signal();

CountdownEvent has method Wait to wait for it to be signalled and Signal for that purpose. There is also a Reset method that returns the instance's state (CurrentCount) to the original state (InitialCount). These methods can be called by any thread, there is no ownership.

Read about CountdownEvent here.

Spinners

SpinLock is a low-level synchronisation primitive that avoids context switching by keeping a thread in a "busy-wait" loop until a resource becomes available.

SpinWait is a lightweight synchronisation primitive that waits for a condition to be met by continuously checking (spinning). It is generally used in tight loops to avoid thread blocking.

Both operate at user level (only managed code) and perform "busy-wait" (spinning) operations, but are quite different in their usage.

SpinLock example:

var spinLock = new SpinLock();
var lockTaken = false;

try
{
    spinLock.Enter(ref lockTaken);
    //do something secure
}
finally
{
    if (lockTaken)
    {
        spinLock.Exit();
    }
}; 

And for SpinWait:

SpinWait.SpinUntil(() => <some condition>, TimeSpan.FromSeconds(1));

Of course, we must provide <some condition> and it will be checked every second, in this example. 

Read more about SpinLock here and about SpinWait here.

Base Classes

WaitHandle is the abstract base class for AutoResetEvent, ManualResetEvent, Mutex, and Semaphore. It defines methods for opening these synchronisation objects by name, which is useful for cross-process synchronisation, to be covered on the next post. There is also EventWaitHandle that is the immediate base class for AutoResetEvent and ManualResetEvent. The other classes described here do not have any base class other than IDisposable (not all). These base classes offer some methods that are reused by the concrete classes, such as Set and Reset, and SignalAndWait, WaitOne, WaitAll, and WaitAny, these last two used for waiting on multiple objects.

For example, if we want to wait for all/any in a list of synchronisation objects (Mutex, for example) we can have:

Mutex[] mutexes = ...;

int firstOneSignalled = WaitHandle.WaitAny(mutexes); //returns the index of the first mutex signalled

WaitHandle.WaitAll(mutexes); //waits for all of the mutexes to be signalled

Both of these methods can take a timeout parameter.

Deadlocks

A thread deadlock in .NET occurs when two or more threads are unable to proceed with their execution because each is waiting for a resource to be released by another thread in the deadlock. This typically happens in multi-threaded or multi-tasking scenarios when improper synchronization or resource handling occurs. 

Causes include:

  • Circular wait: this happens when two or more threads are waiting for each other to release resources, creating a cycle of dependency
  • Locking shared resources in different orders: when multiple threads lock shared resources in a different order, it can lead to deadlocks
  • Improper use of synchronisation primitives: failing to release a lock, acquiring a lock recursively, or holding a lock for too long
  • Using synchronous calls in asynchronous code: synchronous blocking calls (calling Task.Wait or accessing Task.Result) in an asynchronous context can cause deadlocks

Conclusion

So, based on the criteria I introduced earlier, we can group these primitives in different ways.

By access mode:

Access Mode APIs
Same lock
Lock
Monitor
Mutex
Semaphore/SemaphoreSlim
SpinLock
SpinWait
ManualResetEvent/AutoResetEvent/ManualResetEventSlim 
Read/Write ReaderWriterLock/ReaderWriterLockSlim

By number of simultaneous accesses allowed:

Number of Accesses APIs
Single lock
Lock
Monitor
Mutex
SpinLock
SpinWait
ManualResetEvent/AutoResetEvent/ManualResetEventSlim 
CountdownEvent
Multiple Semaphore/SemaphoreSlim (of the same type)
ReaderWriterLock/ReaderWriterLockSlim (of different types)

By synchronicity:

Operations APIs
Synchronous lock
Lock
Monitor
Mutex
Semaphore
ReaderWriterLock
SpinLock
SpinWait
ManualResetEvent/AutoResetEvent/ManualResetEventSlim 
CountdownEvent
Asynchronous SemaphoreSlim
ReaderWriterLockSlim

By operating mode:

ModeAPIs
Nativelock
Lock
Monitor
Mutex
Semaphore
ReaderWriterLock
ManualResetEvent/AutoResetEvent/ManualResetEventSlim 
CountdownEvent
ManagedSemaphoreSlim
ReaderWriterLockSlim
SpinLock
SpinWait

By lock ownership:

OwnershipAPIs
Thread Ownedlock
Lock
Monitor
Mutex
ReaderWriterLock/ReaderWriterLockSlim
SpinLock
SpinWait
ManualResetEvent/AutoResetEvent/ManualResetEventSlim 
Not OwnedSemaphore/SemaphoreSlim
CountdownEvent

There's a lot more to each of these synchronisation APIs, and I barely scratched its surface.

Some general guidelines for synchronisation are:

  • Only acquire a lock if you really need to as it can introduce some performance penalty
  • Always keep a lock for the minimum amount of time possible, and as close as possible to the critical code
  • Use lock hierarchy: always acquire locks in a consistent, defined order across all threads
  • Avoid nested locks: limit or eliminate the use of nested locks
  • Never forget to release a lock
  • Don't forget to dispose of the synchronisation object when you no longer need if, if it's IDisposable
  • Do not lock on a shared object
  • Use the right synchronisation API: read/write if there is need, not just mutual exclusion
  • Prefer the slim versions over the non-slim ones, when they exist (for example, Semaphore versus SemaphoreSlim)
  • Prefer the slim versions to non-slim, when possible - for example, no cross-process synchronisation
  • Use timeouts: when possible, for example, use Monitor.TryEnter or SemaphoreSlim.WaitAsync with a timeout to avoid infinite blocking
  • Prefer asynchronous APIs: Use async/await instead of blocking calls like Thread.SleepTask.Wait, or Task.Result
  • Break cyclic dependencies: analyse the dependency graph and break any cyclic dependencies in resource usage
  • Use proper concurrency-aware APIs: use thread-safe collections rather than manual thread locking
To learn more, read the Microsoft guidelines and overview here:


Comments

Popular posts from this blog

C# Magical Syntax

.NET 10 Validation

OpenTelemetry with ASP.NET Core