To implement this system, we’ll create a simple C# console application that demonstrates the use of locks, mutexes, and semaphores for controlling access to shared resources across multiple threads. Each synchronization primitive will be used in a different scenario, and we’ll discuss their performance and appropriate use cases.
1. Using lock
for Synchronization:
The lock
statement in C# is used to ensure that only one thread can enter a particular section of code at a time. It’s a simple and effective way to prevent race conditions.
using System;
using System.Threading;
class LockExample
{
private static int counter = 0;
private static readonly object lockObj = new object();
public static void Increment()
{
lock (lockObj)
{
// Critical section
counter++;
Console.WriteLine($"Counter: {counter} (Thread {Thread.CurrentThread.ManagedThreadId})");
}
}
public static void Main()
{
Thread[] threads = new Thread[5];
for (int i = 0; i < 5; i++)
{
threads[i] = new Thread(Increment);
threads[i].Start();
}
foreach (var thread in threads)
{
thread.Join();
}
}
}
Explanation:
- The
lock
statement is used to synchronize access to theIncrement
method. Only one thread can execute the code within thelock
block at a time. - This prevents multiple threads from modifying the
counter
variable simultaneously, avoiding race conditions.
Use Case:
- The
lock
statement is ideal for synchronizing access to shared resources within a single application, where the critical section is relatively short and lightweight.
2. Using Mutex
for Cross-Process Synchronization:
A Mutex
is similar to a lock but can also be used to synchronize threads across different processes.
using System;
using System.Threading;
class MutexExample
{
private static int counter = 0;
private static readonly Mutex mutex = new Mutex();
public static void Increment()
{
mutex.WaitOne(); // Acquire the mutex
try
{
// Critical section
counter++;
Console.WriteLine($"Counter: {counter} (Thread {Thread.CurrentThread.ManagedThreadId})");
}
finally
{
mutex.ReleaseMutex(); // Release the mutex
}
}
public static void Main()
{
Thread[] threads = new Thread[5];
for (int i = 0; i < 5; i++)
{
threads[i] = new Thread(Increment);
threads[i].Start();
}
foreach (var thread in threads)
{
thread.Join();
}
}
}
Explanation:
- The
Mutex
is acquired withWaitOne
and released withReleaseMutex
. - Even if the threads belong to different processes, the
Mutex
ensures that only one thread can access the critical section at a time.
Use Case:
- A
Mutex
is suitable when you need to synchronize access to a resource across multiple processes, not just within a single application. This makes it more powerful thanlock
but also potentially more expensive in terms of performance.
3. Using Semaphore
to Limit Access:
A Semaphore
controls access to a resource by limiting the number of threads that can access it concurrently.
using System;
using System.Threading;
class SemaphoreExample
{
private static readonly Semaphore semaphore = new Semaphore(2, 2); // Allow 2 threads at a time
private static int counter = 0;
public static void Increment()
{
semaphore.WaitOne(); // Enter the semaphore
try
{
// Critical section
counter++;
Console.WriteLine($"Counter: {counter} (Thread {Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(1000); // Simulate work
}
finally
{
semaphore.Release(); // Exit the semaphore
}
}
public static void Main()
{
Thread[] threads = new Thread[5];
for (int i = 0; i < 5; i++)
{
threads[i] = new Thread(Increment);
threads[i].Start();
}
foreach (var thread in threads)
{
thread.Join();
}
}
}
Explanation:
- The
Semaphore
is initialized with a count of 2, meaning that up to 2 threads can access the critical section concurrently. - If more than 2 threads try to enter, they will be blocked until one of the current threads exits the semaphore.
Use Case:
- A
Semaphore
is useful when you want to limit the number of threads that can access a resource simultaneously, such as limiting the number of connections to a database or a network resource.
4. Performance Comparison and Use Cases
-
Locks:
- Best for intra-process synchronization where the critical section is small and quick.
- Minimal overhead but limited to a single process.
- Commonly used when you need a simple, fast, and easy-to-read synchronization mechanism.
-
Mutexes:
- Suitable for both intra-process and cross-process synchronization.
- Heavier than
lock
due to the potential need to communicate across process boundaries. - Use when you need to ensure mutual exclusion across multiple processes.
-
Semaphores:
- Allows a configurable number of threads to enter the critical section, making it more flexible.
- Useful when managing a limited pool of resources (e.g., database connections).
- Slightly more complex to manage but offers greater control over concurrency.
In summary, each synchronization primitive serves a different purpose:
- Use
lock
when you need quick and simple thread synchronization within a single process. - Use
Mutex
when you need to synchronize threads across different processes or when thelock
statement is insufficient. - Use
Semaphore
when you need to limit the number of threads accessing a resource concurrently, offering more complex synchronization control.
These tools allow you to manage concurrency in C# effectively, depending on the specific requirements of your application.