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
SemaphoreSlimoverSemaphore?
SemaphoreSlimis a lightweight, non-OS-level sync primitive optimized for speed within a single process. Crucially, it supportsWaitAsync(), making it fully non-blocking and ideal for modernasync/awaitcode.
The Code Example
Here is a practical pattern for throttling concurrent asynchronous operations:
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
- 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)).
No comments:
Post a Comment