Monday, 7 July 2025

Quick Look: Controlling Concurrency with SemaphoreSlim in C#

When building high-throughput or asynchronous applications in .NET, it is easy to overwhelm external resources—like hitting an external API rate limit or exhausting database connection pools—by running too many tasks at once.

While lock or Monitor limits access to a single thread at a time, C# provides SemaphoreSlim when you need to allow a controlled number of concurrent tasks to access a resource.


What Problem Does SemaphoreSlim Solve?

Imagine you have 1,000 items to process, and each item requires calling a third-party REST API. If you run them all concurrently using Task.WhenAll, you might fire 1,000 simultaneous HTTP requests, leading to:

  • HTTP 429 Too Many Requests or rate-limiting errors.
  • Socket exhaustion or high resource contention.
  • Database thread-pool starvation under heavy loads.

SemaphoreSlim acts as a gatekeeper with a fixed capacity. If you set its capacity to 3, it allows up to 3 tasks to run concurrently. A 4th task must wait in line until one of the first three completes and releases its slot.

Why SemaphoreSlim over Semaphore? 

SemaphoreSlim is a lightweight, non-OS-level sync primitive optimized for speed within a single process. Crucially, it supports WaitAsync(), making it fully non-blocking and ideal for modern async/await code.

 

The Code Example

Here is a practical pattern for throttling concurrent asynchronous operations:

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

public class ApiBatchProcessor
{
    // Limit concurrency to a maximum of 3 tasks at any given time
    private static readonly SemaphoreSlim Throttler = new SemaphoreSlim(initialCount: 3, maxCount: 3);

    public async Task ProcessItemsAsync(IEnumerable<int> itemIds)
    {
        var tasks = itemIds.Select(id => ProcessSingleItemAsync(id));
        await Task.WhenAll(tasks);
    }

    private async Task<int> ProcessSingleItemAsync(int id)
    {
        // Asynchronously wait to enter the semaphore without blocking the thread
        await Throttler.WaitAsync();

        try
        {
            Console.WriteLine($"[Processing] Item {id} started on Thread {Environment.CurrentManagedThreadId}");
            
            // Simulate an expensive API call or database query
            await Task.Delay(1000); 

            return id;
        }
        finally
        {
            // ALWAYS release the semaphore in a finally block to prevent deadlocks
            Throttler.Release();
        }
    }
}

Key Takeaways & Best Practices

  1. Always Use try...finally for Release():
Always call Release() inside a finally block. If the task throws an unhandled exception before releasing the semaphore, that slot is lost forever, leading to an eventual application deadlock.

 

      2. Non-Blocking with WaitAsync():

Never call Wait() inside an async method—use await WaitAsync(). Wait() blocks the underlying thread pool thread while waiting, whereas WaitAsync() frees the thread to do other work until a slot opens up.

 

      3.  Configuring Limits: 

The constructor SemaphoreSlim(initialCount, maxCount) defines how many slots are initially available and the absolute maximum allowed limit. Typically, both values are set to the same number (e.g., new SemaphoreSlim(5, 5)).

Saturday, 5 July 2025

Quick Look: Clean In-Memory Caching Wrapper in .NET

Caching expensive operations—like database queries or external API calls—is one of the easiest ways to improve performance in .NET applications. While IMemoryCache from Microsoft.Extensions.Caching.Memory is easy to use out of the box, boilerplate setup often litters repository and service classes.

Here is a quick look at a simple, reusable CacheService wrapper that leverages C# primary constructors and attributes to make caching clean and declarative.

The Code

using System.Runtime.CompilerServices;
using Microsoft.Extensions.Caching.Memory;

namespace UWAuth.Services;

/// <summary>
/// Implements a service for caching data using <see cref="IMemoryCache"/>.
/// </summary>
public class CacheService(IMemoryCache cache)
{
    private const int CacheDuration = 5;

    /// <summary>
    /// Gets the cached data for the specified key or fetches it using the provided fetch task if not cached.
    /// </summary>
    /// <typeparam name="T">The type of the data to cache.</typeparam>
    /// <param name="fetchTask">The task to fetch the data if not cached.</param>
    /// <param name="customCacheDuration">The custom cache duration in minutes. Default is 5 minutes.</param>
    /// <param name="key">The cache key. Default is the caller member name.</param>
    /// <returns>The cached or fetched data.</returns>
    /// <exception cref="InvalidOperationException">Thrown if the cached data cannot be cast or deserialized.</exception>
    public async Task<T> GetOrFetch<T>(
        Func<Task<T>> fetchTask, 
        int customCacheDuration = CacheDuration, 
        [CallerMemberName] string key = "") 
        where T : class?
    {
        if (cache.TryGetValue(key, out var cached))
        {
            return cached as T ?? throw new InvalidOperationException($"Failed to deserialize cached items for {key}");
        }

        var items = await fetchTask();
        if (items is null)
        {
            return null!;
        }

        cache.Set(key, items, TimeSpan.FromMinutes(customCacheDuration));
        return items;
    }

    /// <summary>
    /// Invalidates the cached data for the specified key.
    /// </summary>
    /// <param name="key">The cache key.</param>
    /// <void>Invalidates the cache entry.</void>
    public void Invalidate(string key) => cache.Remove(key);
}

Key Highlights & Features

  1. Modern C# Primary Constructor Syntax: By defining public class CacheService(IMemoryCache cache), the IMemoryCache dependency is injected directly into the class header—eliminating private field declarations and boilerplate constructor assignments.

  2. Implicit Cache Keys via [CallerMemberName]: Instead of manually defining string keys like "GetUserProfile", the [CallerMemberName] attribute automatically captures the name of the calling method or property. If called inside GetUserInfo(), the key automatically becomes "GetUserInfo".

  3. Get-or-Fetch Pattern: The GetOrFetch<T> method handles the standard checking pattern:

    • Cache Hit: Checks TryGetValue. If found, safely casts and returns the cached object.

    • Cache Miss: Runs the fetchTask delegate, saves the result in IMemoryCache using TimeSpan.FromMinutes(), and returns the fresh data.

  4. Invalidation: Includes a clean Invalidate(string key) helper for manual cache purging when underlying data is updated or deleted.

How to Use It in Your Code

After registering IMemoryCache and CacheService in your DI container (builder.Services.AddMemoryCache()), you can inject CacheService anywhere:

public class UserService(CacheService cacheService, IUserDbContext dbContext)
{
    public async Task<List<User>?> GetActiveUsers()
    {
        // Cache key automatically defaults to "GetActiveUsers" via [CallerMemberName]
        return await cacheService.GetOrFetch(async () => 
        {
            return await dbContext.Users.Where(u => u.IsActive).ToListAsync();
        }, customCacheDuration: 10);
    }

    public void PurgeActiveUserCache()
    {
        cacheService.Invalidate(nameof(GetActiveUsers));
    }
}