When you build multi-threaded or asynchronous applications in .NET, controlling concurrent access to shared resources is critical. In this post, we’ll explore one of the most versatile synchronization primitives in .NET: SemaphoreSlim.

What Is SemaphoreSlim?

SemaphoreSlim is a lightweight synchronization primitive designed for controlling access to a resource by limiting the number of threads that can access it concurrently. Unlike a full-fledged Semaphore, SemaphoreSlim is optimized for in-process synchronization and supports asynchronous waiting via the WaitAsync method. This makes it ideal for modern applications that rely on async/await patterns.

Key Features:

  • Lightweight: Minimal overhead compared to traditional semaphores.
  • Async-Friendly: Supports WaitAsync, so you don’t block threads unnecessarily.
  • Local Synchronization: Best suited for controlling concurrency within a single process.

Why Use SemaphoreSlim?

While simple locking mechanisms like the lock statement (or Monitor) are perfect for mutually exclusive access (one thread at a time), SemaphoreSlim allows more than one thread to access a resource concurrently. This is useful in scenarios such as:

  • Throttling API requests.
  • Limiting concurrent database or file operations.
  • Managing a pool of reusable resources (like connection pools).

Example

Let’s start with a simple example. Suppose you have a task that simulates work by waiting for a second. You want only three tasks to run concurrently.

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

class Program
{
    // Create a SemaphoreSlim that allows up to 3 concurrent threads.
    private static readonly SemaphoreSlim semaphore = new SemaphoreSlim(3);

    static async Task Main(string[] args)
    {
        Task[] tasks = new Task[10];
        for (int i = 0; i < tasks.Length; i++)
        {
            int taskId = i; // Local copy for closure safety
            tasks[i] = Task.Run(() => DoWorkAsync(taskId));
        }

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

    static async Task DoWorkAsync(int id)
    {
        Console.WriteLine($"Task {id} is waiting to enter...");
        await semaphore.WaitAsync();
        try
        {
            Console.WriteLine($"Task {id} entered the semaphore.");
            // Simulate some work.
            await Task.Delay(10000);
        }
        finally
        {
            semaphore.Release();
            Console.WriteLine($"Task {id} released the semaphore.");
        }
    }
}

Explanation:

  • Initialization: We initialize the semaphore with a count of 3, meaning three threads can enter the critical section concurrently.
  • WaitAsync: Each task waits asynchronously to acquire a permit. This keeps threads free to do other work if waiting.
  • try-finally: The semaphore is released in a finally block to ensure that even if an exception occurs, the permit is always returned.

Best Practices

  1. Pair Wait and Release: Always ensure that every call to WaitAsync or Wait is paired with a Release to prevent deadlocks.
  2. Use try-finally Blocks: Protect the critical section so that semaphores are always released.
  3. Keep the Critical Section Short: Minimize the code within the semaphore-controlled block to reduce waiting times.
  4. Choose the Right Tool: Use SemaphoreSlim for in-process synchronization, and reserve the traditional Semaphore for inter-process scenarios.

Conclusion

SemaphoreSlim is a super useful tool but it is needed only in very specific scenarios. Hope you learned something today.

Affiliate promo

If you love learning new stuff and want to support me, consider buying a course from Dometrain using this link: Browse courses – Dometrain. Thank you!

Leave a comment

Trending