Managing Concurrent Access with Semaphores in C# .NET

Concurrency control is vital in multithreaded programming to ensure that shared resources are not overwhelmed by simultaneous accesses. In C#, the Semaphore class provides a robust mechanism for managing concurrent access to a resource. This article explains how to use semaphores in C#, illustrates their use with examples, and compares scenarios with and without semaphores to highlight their benefits.

What is a Semaphore?

A semaphore is a synchronization primitive that controls access to a resource by multiple threads. It maintains a count of available resources, allowing a specified number of threads to access the resource concurrently. When the count reaches zero, additional threads are blocked until the resource is released by another thread.

Example Without Semaphore

In this example, multiple threads access a shared resource without any limitation, which can lead to resource contention.

using System;
using System.Threading;
class Program
{
    static void Main()
    {
        for (int i = 1; i <= 5; i++)
        {
            Thread t = new Thread(AccessResource);
            t.Name = "Thread " + i;
            t.Start();
        }
        Console.ReadLine();
    }
    private static void AccessResource()
    {
        Console.WriteLine("{0} is accessing the resource.", Thread.CurrentThread.Name);

        // Simulate some work
        Thread.Sleep(2000);

        Console.WriteLine("{0} is done accessing the resource.", Thread.CurrentThread.Name);
    }
}

Example With Semaphore

This example demonstrates the use of a semaphore to limit concurrent access to a shared resource.

using System;
using System.Threading;
class Program
{
    private static Semaphore semaphore = new Semaphore(2, 2);
    static void Main()
    {
        for (int i = 1; i <= 5; i++)
        {
            Thread t = new Thread(AccessResource);
            t.Name = "Thread " + i;
            t.Start();
        }

        Console.ReadLine();
    }
    private static void AccessResource()
    {
        Console.WriteLine("{0} is waiting to enter the semaphore.", Thread.CurrentThread.Name);
        semaphore.WaitOne(); // Wait until the semaphore is available
        Console.WriteLine("{0} has entered the semaphore.", Thread.CurrentThread.Name);
        // Simulate some work
        Thread.Sleep(2000);
        Console.WriteLine("{0} is leaving the semaphore.", Thread.CurrentThread.Name);
        semaphore.Release(); // Release the semaphore
    }
}

Real-Life Use Case Database Connection Pool

A real-life scenario for semaphores is managing a pool of database connections. Assume a system where only a limited number of database connections can be used simultaneously.

using System;
using System.Threading;
class DatabaseConnectionPool
{
    private static Semaphore semaphore = new Semaphore(3, 3);
    static void Main()
    {
        for (int i = 1; i <= 10; i++)
        {
            Thread t = new Thread(AccessDatabase);
            t.Name = "Client " + i;
            t.Start();
        }

        Console.ReadLine();
    }
    private static void AccessDatabase()
    {
        Console.WriteLine("{0} is waiting for a database connection.", Thread.CurrentThread.Name);
        semaphore.WaitOne(); // Wait until a database connection is available
        Console.WriteLine("{0} has obtained a database connection.", Thread.CurrentThread.Name);
        // Simulate database access
        Thread.Sleep(3000);
        Console.WriteLine("{0} is releasing the database connection.", Thread.CurrentThread.Name);
        semaphore.Release(); // Release the database connection
    }
}

Comparison With vs. Without Semaphore

  1. Concurrency Control
    • Without Semaphore: All threads access the resource concurrently without any restriction, which can lead to resource contention and overloading.
    • With Semaphore: The semaphore restricts the number of concurrent accesses, ensuring controlled access to the resource.
  2. Thread Blocking
    • Without Semaphore: Threads are not blocked and the user to access the resource immediately.
    • With Semaphore: Threads are blocked if the semaphore count is zero, meaning the maximum allowed concurrent accesses are already in progress.
  3. Resource Management
    • Without Semaphore: No mechanism to manage or limit resource usage, leading to potential resource exhaustion or performance degradation.
    • With Semaphore: Ensures resource usage is within safe limits, preventing exhaustion and maintaining performance stability.
  4. Code Simplicity
    • Without Semaphore: Simpler code as it doesn't involve synchronization primitives.
    • With Semaphore: Includes additional logic for synchronization, adding complexity but providing necessary control.

Conclusion

Using a semaphore is crucial in scenarios where you need to manage access to limited resources among multiple threads. It ensures that the number of concurrent accesses remains within defined limits, providing thread safety and preventing resource contention. Semaphores are particularly useful in real-world applications like managing database connections, limiting access to file handles, and other critical sections where controlled concurrency is essential.

References: https://learn.microsoft.com/en-us/dotnet/api/system.threading.semaphore?view=net-8.0