DEV Community

Supratim Roy
Supratim Roy

Posted on • Edited on

Efficient Thread Synchronization in .NET with SemaphoreSlim

Why Do We Need Semaphores?

Semaphores are used in programming to control access to shared resources in multi-threaded environments. They prevent race conditions, deadlocks, and excessive resource usage, ensuring smooth execution of concurrent processes.

Key Reasons for Using Semaphores

1. Preventing Race Conditions

  • When multiple threads access a shared resource simultaneously, it can lead to inconsistent data.
  • Semaphore ensures that only a limited number of threads can access the resource at a time.

Example: Multiple threads writing to a shared log file might corrupt the log.

2. Controlling Concurrent Access

  • Semaphores limit the number of threads that can execute a critical section at the same time.
  • Unlike a simple lock (lock in C#), semaphores allow multiple threads instead of just one.

Example: A database connection pool allowing only 5 concurrent connections.

3. Avoiding Deadlocks

  • When multiple threads wait indefinitely for a resource, it causes a deadlock.
  • Using a properly configured semaphore prevents such situations by restricting entry.

4. Managing Resource Usage

  • Some system resources are expensive (like CPU, I/O, or memory).
  • Semaphores help optimize usage by allowing only a few threads to access them at a time.

Example:

  • A print server that processes only 3 jobs simultaneously.
  • A web scraper that limits concurrent HTTP requests to avoid server overload.

5. Supporting Asynchronous Execution (With SemaphoreSlim)

  • SemaphoreSlim is useful in async programming to prevent excessive parallel execution while keeping the application responsive.

Example:

  • An API rate limiter allowing only 10 requests per second.
  • A background task processor handling multiple jobs efficiently.

What is SemaphoreSlim?

SemaphoreSlim is a lightweight, managed version of Semaphore in .NET that is designed for controlling access to a limited resource by multiple threads. It works similarly to Semaphore but provides better performance in asynchronous scenarios.

It is defined in the System.Threading namespace.

Why is SemaphoreSlim Useful?

  1. Limits Concurrent Access: It ensures that only a fixed number of threads can access a critical section simultaneously. Useful when managing connections, file I/O, or shared resources like database calls.
  2. Optimized for Asynchronous Programming: Unlike Semaphore, SemaphoreSlim supports the await pattern with WaitAsync(), making it more efficient for async/await workloads.
  3. More Lightweight than Semaphore: SemaphoreSlim does not use kernel-mode objects, making it more performant for in-memory scenarios.
  4. Avoids Lock Contention: Instead of blocking a thread (like lock or Monitor), it allows waiting asynchronously, improving overall system responsiveness.

Example of SemaphoreSlim:

Here's a practical example showing how to use SemaphoreSlim to limit concurrent access to a resource (simulating 10 tasks trying to access a resource limited to 3 at a time):

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    // Initialize SemaphoreSlim with 3 concurrent access slots
    private static SemaphoreSlim semaphore = new SemaphoreSlim(3);

    static async Task Main(string[] args)
    {
        // Create 10 tasks to simulate concurrent operations
        Task[] tasks = new Task[10];

        for (int i = 0; i < 10; i++)
        {
            int taskNumber = i;
            tasks[i] = Task.Run(() => DoWork(taskNumber));
        }

        await Task.WhenAll(tasks);
        Console.WriteLine("All tasks completed!");
    }

    static async Task DoWork(int taskId)
    {
        Console.WriteLine($"Task {taskId} waiting to enter...");

        // Wait to acquire the semaphore (blocks if 3 are already in use)
        await semaphore.WaitAsync();

        try
        {
            Console.WriteLine($"Task {taskId} started processing");
            // Simulate some work with random duration
            await Task.Delay(new Random().Next(1000, 3000));
            Console.WriteLine($"Task {taskId} completed");
        }
        finally
        {
            // Release the semaphore slot for other waiting tasks
            semaphore.Release();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. SemaphoreSlim(3) creates a semaphore allowing 3 concurrent entries
  2. WaitAsync() asynchronously waits for an available slot
  3. When a slot is available, the task enters and does its work
  4. Release() frees up the slot when the task is done
  5. Only 3 tasks can execute simultaneously; others wait their turn

Sample output might look like:

Task 0 waiting to enter...
Task 1 waiting to enter...
Task 2 waiting to enter...
Task 0 started processing
Task 1 started processing
Task 2 started processing
Task 3 waiting to enter...
Task 4 waiting to enter...
[Task 0 completes]
Task 3 started processing
[Task 1 completes]
Task 4 started processing
...
Enter fullscreen mode Exit fullscreen mode

This is a thread-safe way to manage concurrent access while being more performant than the traditional Semaphore class, especially in async scenarios.

Top comments (0)