Locking (Synchronization) in C#
Amir Doosti
Software Engineer | 20+ Years of Expertise | .NET, Industrial Automation with Beckhoff, Microservices Architecture
Concurrency and multithreading are powerful features in modern programming, but they bring challenges, especially in managing shared resources. C# offers various synchronization primitives to prevent race conditions and ensure thread-safe operations. In this article, we’ll explore the different types of locking mechanisms available in C#, their usage scenarios, and code examples.
Types of locking
It is essential to know why so many synchronization objects exist and what are their usages. We can categorize locking capabilities in C# in different ways. They can be categorized by the exclusiveness or based on single process or multi process locking capability.
Exclusiveness:
In this article I won’t go through signals because I covered them in “Signaling in C#”.
Single/Multi process support:
lock Statement
The lock statement is a high-level synchronization primitive.
It prevents multiple threads from accessing a critical section simultaneously by acquiring an exclusive lock on an object.
Use lock when you need simple mutual exclusion for a block of code.
Note that it can lead to deadlocks if not used carefully.
private static readonly object _logLock = new object();
public void LogMessage(string message)
{
lock (_logLock)
{
File.AppendAllText("log.txt", message + Environment.NewLine);
}
}
Monitor Class
Monitor provides greater flexibility than lock, such as enabling threads to wait for a condition to be signaled. lock is actually based on Monitor. Monitor has methods like Enter an Exit to specify the block of code that you like to include for locking.
private static Mutex _mutex = new Mutex();
public void AccessResource()
{
if (_mutex.WaitOne())
{
try
{
Console.WriteLine("Mutex lock acquired.");
}
finally
{
_mutex.ReleaseMutex();
}
}
}
Here is a producer-consumer queue using Monitor:
private Queue<int> _queue = new Queue<int>();
private readonly object _queueLock = new object();
public void Produce(int item)
{
lock (_queueLock)
{
_queue.Enqueue(item);
Monitor.Pulse(_queueLock); // Notify waiting consumers
}
}
public void Consume()
{
lock (_queueLock)
{
while (_queue.Count == 0)
{
Monitor.Wait(_queueLock); // Wait for items
}
int item = _queue.Dequeue();
Console.WriteLine($"Consumed: {item}");
}
}
Mutex
A Mutex is a cross-process synchronization primitive, meaning it can synchronize threads across multiple applications. The following example shows how we can use Mutex to avoid execution of multiple instance of a program:
static void Main()
{
using (var mutex = new Mutex(false, "MyAppMutex"))
{
if (!mutex.WaitOne(0, false))
{
Console.WriteLine("Application is already running.");
return;
}
Console.WriteLine("Application started.");
Console.ReadLine();
}
}
Semaphore and SemaphoreSlim
A Semaphore restricts the number of threads accessing a resource. SemaphoreSlim is a lighter, in-process version.
Semaphore is like a petrol station which has limited capacity and if it is full, no more cars can enter and if one car exits, then one car can enter.
领英推荐
Semaphore has a capacity which limits the number of threads that can access a shared resource. If we reduce the capacity to one, then it works like a Mutex.
Semaphore doesn’t have “Owner” so any thread can call Release method. It is called “thread agnostic”.
The following example limits the number of simultaneous connections to a database:
private static SemaphoreSlim _semaphore = new SemaphoreSlim(3); // Max 3 connections
public async Task AccessDatabase()
{
await _semaphore.WaitAsync();
try
{
Console.WriteLine("Accessing database...");
await Task.Delay(1000); // Simulate work
}
finally
{
_semaphore.Release();
}
}
ReaderWriterLock and ReaderWriterLockSlim
ReaderWriterLock and ReaderWriterLockSlim allow multiple readers or a single writer at any given time, optimizing for scenarios where reads vastly outnumber writes.
One of the uses of this class is designed to provide better access to a shared resource like a file for both reading and writing for example when the number of read access is much higher than write access.
They have actually two types of lock inside, a write lock which is exclusive and a read lock.
The following example shows how to use ReaderWriteLockSlim to read configuration values that are infrequently updated.
private ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
private Dictionary<string, string> _config = new Dictionary<string, string>();
public string ReadConfig(string key)
{
_lock.EnterReadLock();
try
{
return _config[key];
}
finally
{
_lock.ExitReadLock();
}
}
public void WriteConfig(string key, string value)
{
_lock.EnterWriteLock();
try
{
_config[key] = value;
}
finally
{
_lock.ExitWriteLock();
}
}
SpinLock
SpinLock avoids thread context switching by repeatedly checking for lock availability. It’s suitable for very short critical sections.
Look at the example implements a high frequency counter:
private SpinLock _spinLock = new SpinLock();
private int _counter = 0;
public void IncrementCounter()
{
bool lockTaken = false;
try
{
_spinLock.Enter(ref lockTaken);
_counter++;
}
finally
{
if (lockTaken) _spinLock.Exit();
}
}
Interlocked Class
Interlocked provides atomic operations, such as incrementing and exchanging values, without explicit locking.
Let’s see the usage in action, an atomic counter:
private int _counter = 0;
public void IncrementCounter()
{
Interlocked.Increment(ref _counter);
Console.WriteLine($"Counter: {_counter}");
}
Conclusion
Choose the locking mechanism based on your specific requirements:
Understanding these tools allows you to write efficient, thread-safe applications tailored to your needs.
#lock #multithread #monitor #mutex #semaphore #semaphoreslim #readerwriterlock #readerwriterlockslim #spinlock #interlock #csharp #dotnet
Senior Java Developer @ Code Nomads
3 个月Nice, briefly explained all kind of locks. Thanks for sharing.
Software Engineer | Backend Engineer | C#.NET | Cloud & Microservices | System Design | Eager to Relocation | #leadership
3 个月A static lock is shared across all instances of your current class. This means that any operation involving a lock on any instance of your current class will block operations on other instances. This can lead to unnecessary contention and reduced concurrency.