Azure Functions — Semaphore-Based Concurrency Control
The Problem
Azure Functions are designed to scale out. When traffic spikes, the runtime spins up multiple instances — and within each instance, multiple function invocations can run concurrently. This is great for throughput, but it creates a dangerous situation whenever your function accesses:
- A shared in-memory resource (cache, dictionary, list)
- A connection pool with a hard limit (database, third-party API)
- A file on disk
- A static client that isn't thread-safe
- An external service with a rate limit (e.g., 10 requests/second)
Without concurrency control, multiple concurrent invocations race against each other. You get corrupted state, connection pool exhaustion, throttling errors, and bugs that only appear under load and are nearly impossible to reproduce locally.
Semaphores are the solution — they act as a traffic gate, allowing only N invocations to proceed at a time, making the rest wait their turn.
What Is a Semaphore?
A semaphore is a concurrency primitive that controls access to a shared resource by maintaining a count of how many threads (or tasks) are currently allowed to proceed. Think of it as a ticket booth with a fixed number of tickets.
SemaphoreSlim (initialCount: 3)
│
┌────────────┼────────────┐
▼ ▼ ▼
Invocation 1 Invocation 2 Invocation 3 ← All 3 hold a ticket — proceed
│ │ │
─────┼────────────┼────────────┼──────────── semaphore count = 0
│
Invocation 4 ← No ticket available — WAITS
Invocation 5 ← No ticket available — WAITS
Invocation 6 ← No ticket available — WAITS
│
(When Invocation 1 finishes, it releases its ticket)
│
Invocation 4 ← Gets the ticket — proceeds
In .NET, the class used inside Azure Functions is SemaphoreSlim — an asynchronous, lightweight semaphore designed specifically for async/await code.
SemaphoreSlim vs Semaphore
| Feature | SemaphoreSlim | Semaphore |
|---|---|---|
Async support (WaitAsync) | ✅ Yes | ❌ No |
| Cross-process support | ❌ No | ✅ Yes |
| Performance | Fast (no kernel calls) | Slower (kernel object) |
| Use in Azure Functions | ✅ Correct choice | ❌ Wrong choice |
Always use SemaphoreSlim in Azure Functions. It supports await, which means it releases the thread while waiting instead of blocking it — critical in async function code.
Why Azure Functions Specifically Need Semaphores
Azure Functions adds unique challenges beyond normal .NET concurrency:
1. Multiple Concurrent Invocations per Instance
By default, the Azure Functions runtime allows multiple invocations to execute simultaneously within a single host process. For HTTP triggers, this is effectively unbounded. For queue/service bus triggers, maxConcurrentCalls controls it — but even at low values, concurrency is real.
2. Static Clients Are Shared Across Invocations
The recommended pattern for Azure Functions is to declare HTTP clients, database connections, and service clients as static or inject them as singletons. This is correct for performance — but it means ALL concurrent invocations share the same client object. If that client isn't thread-safe, you need a semaphore.
3. Cold Start Means Class Initialization Happens Once
When an instance cold-starts, static fields are initialized once. If two invocations hit the function before initialization completes, you have a race condition on initialization itself — another place semaphores help.
4. Scale-Out Is Not the Same as Thread Safety
Many developers confuse "APIM rate limiting" or "Azure Functions scale settings" with thread safety. These control how many instances spin up — but within each instance, concurrency control is your responsibility.
Setting Up SemaphoreSlim in Azure Functions
The Golden Rule: Declare at Static or Singleton Scope
A SemaphoreSlim only controls concurrency if all competing invocations share the same instance. If you declare it inside the function method, every invocation creates its own semaphore — completely useless.
// ❌ WRONG — new semaphore per invocation, controls nothing
public class MyFunction
{
[FunctionName("ProcessOrder")]
public async Task Run([QueueTrigger("orders")] string message)
{
var semaphore = new SemaphoreSlim(1, 1); // New instance every invocation
await semaphore.WaitAsync(); // Only this invocation sees it
try { /* ... */ }
finally { semaphore.Release(); }
}
}
// ✅ CORRECT — single instance shared by all invocations on this host
public class MyFunction
{
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
[FunctionName("ProcessOrder")]
public async Task Run([QueueTrigger("orders")] string message)
{
await _semaphore.WaitAsync();
try { /* ... */ }
finally { _semaphore.Release(); }
}
}
In Isolated Worker (Dependency Injection) Model
In the isolated worker model (.NET 6+, .NET 8), use dependency injection to share the semaphore as a singleton:
// Program.cs
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults()
.ConfigureServices(services =>
{
// Register as singleton — one instance for the lifetime of the host
services.AddSingleton<SemaphoreSlim>(_ => new SemaphoreSlim(3, 3));
// Or wrap it in a named service for clarity
services.AddSingleton<ConcurrencyLimiter>();
})
.Build();
await host.RunAsync();
// ConcurrencyLimiter.cs — wrapper for clarity and testability
public class ConcurrencyLimiter
{
private readonly SemaphoreSlim _semaphore;
public ConcurrencyLimiter(IConfiguration config)
{
var maxConcurrent = config.GetValue<int>("MaxConcurrentOperations", 5);
_semaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent);
}
public Task WaitAsync(CancellationToken cancellationToken = default)
=> _semaphore.WaitAsync(cancellationToken);
public void Release() => _semaphore.Release();
public int CurrentCount => _semaphore.CurrentCount;
}
// MyFunction.cs — inject the limiter
public class MyFunction
{
private readonly ConcurrencyLimiter _limiter;
public MyFunction(ConcurrencyLimiter limiter)
{
_limiter = limiter;
}
[Function("ProcessOrder")]
public async Task Run([QueueTrigger("orders")] string message,
CancellationToken cancellationToken)
{
await _limiter.WaitAsync(cancellationToken);
try
{
await ProcessOrderAsync(message);
}
finally
{
_limiter.Release();
}
}
}
Core Usage Patterns
Pattern 1: Mutual Exclusion (One at a Time)
Use SemaphoreSlim(1, 1) when only one invocation at a time should access a resource — equivalent to a lock but async-safe.
// Only one invocation can write to the file at a time
private static readonly SemaphoreSlim _fileLock = new SemaphoreSlim(1, 1);
[FunctionName("WriteReport")]
public async Task Run([TimerTrigger("0 */5 * * * *")] TimerInfo timer)
{
await _fileLock.WaitAsync();
try
{
await File.AppendAllTextAsync("/tmp/report.txt",
$"{DateTime.UtcNow:o} - Report generated\n");
}
finally
{
_fileLock.Release();
}
}
Pattern 2: Bounded Concurrency (N at a Time)
Use SemaphoreSlim(N, N) when you want to allow N concurrent operations — for example, limiting concurrent calls to a third-party API that throttles at 5 simultaneous requests.
// Max 5 concurrent calls to the external pricing API
private static readonly SemaphoreSlim _pricingApiGate = new SemaphoreSlim(5, 5);
[FunctionName("GetPrice")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req)
{
await _pricingApiGate.WaitAsync();
try
{
var price = await _pricingClient.GetPriceAsync(req.Query["productId"]);
return new OkObjectResult(new { price });
}
finally
{
_pricingApiGate.Release();
}
}
Pattern 3: Timeout — Don't Wait Forever
Always specify a timeout when the resource might be held for a long time. WaitAsync returns false if the timeout expires before the semaphore is acquired.
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(3, 3);
[FunctionName("ProcessItem")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req)
{
// Wait at most 10 seconds for a slot
bool acquired = await _semaphore.WaitAsync(TimeSpan.FromSeconds(10));
if (!acquired)
{
// Return 503 instead of waiting indefinitely
return new ObjectResult(new
{
error = "service_busy",
message = "Server is busy. Please retry in a few seconds.",
retryAfterSeconds = 10
})
{ StatusCode = 503 };
}
try
{
await DoWorkAsync();
return new OkResult();
}
finally
{
_semaphore.Release();
}
}
Pattern 4: CancellationToken — Respect Function Cancellation
Azure Functions passes a CancellationToken that fires when the host is shutting down or the request is cancelled. Always pass it to WaitAsync so waiting invocations don't hang the shutdown.
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(2, 2);
[FunctionName("ProcessMessage")]
public async Task Run(
[ServiceBusTrigger("orders")] ServiceBusReceivedMessage message,
CancellationToken cancellationToken)
{
try
{
await _semaphore.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// Host is shutting down — exit gracefully without processing
_logger.LogWarning("Function cancelled while waiting for semaphore.");
return;
}
try
{
await ProcessMessageAsync(message, cancellationToken);
}
finally
{
_semaphore.Release();
}
}
Pattern 5: Timeout + CancellationToken Combined
The most robust pattern for production — respects both timeout and cancellation:
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(5, 5);
[FunctionName("ProcessOrder")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
CancellationToken cancellationToken)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
try
{
await _semaphore.WaitAsync(linkedCts.Token);
}
catch (OperationCanceledException)
{
if (cancellationToken.IsCancellationRequested)
{
// Function host is shutting down
return new StatusCodeResult(503);
}
// Timeout expired — server busy
return new ObjectResult(new { error = "timeout_waiting_for_slot" })
{ StatusCode = 503 };
}
try
{
var result = await ProcessOrderAsync(req, cancellationToken);
return new OkObjectResult(result);
}
finally
{
_semaphore.Release();
}
}
Real-World Scenarios
Scenario 1: Protecting a Non-Thread-Safe Static HTTP Client
public class ExternalApiFunction
{
// Static HttpClient — shared across all invocations (correct for perf)
private static readonly HttpClient _httpClient = new HttpClient
{
BaseAddress = new Uri("https://api.external.com"),
Timeout = TimeSpan.FromSeconds(30)
};
// The external API only allows 3 concurrent connections per key
private static readonly SemaphoreSlim _apiGate = new SemaphoreSlim(3, 3);
private readonly ILogger<ExternalApiFunction> _logger;
public ExternalApiFunction(ILogger<ExternalApiFunction> logger)
{
_logger = logger;
}
[FunctionName("CallExternalApi")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req,
CancellationToken cancellationToken)
{
var acquired = await _apiGate.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken);
if (!acquired)
{
_logger.LogWarning("Semaphore timeout — API gate full. CurrentCount={Count}",
_apiGate.CurrentCount);
return new ObjectResult(new { error = "too_many_requests" }) { StatusCode = 429 };
}
_logger.LogInformation("Semaphore acquired. Remaining slots: {Count}",
_apiGate.CurrentCount);
try
{
var response = await _httpClient.GetAsync("/data", cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
return new OkObjectResult(content);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "External API call failed.");
return new StatusCodeResult(502);
}
finally
{
_apiGate.Release();
_logger.LogInformation("Semaphore released. Remaining slots: {Count}",
_apiGate.CurrentCount);
}
}
}
Scenario 2: Lazy Initialization Race Condition
When expensive resources (database connections, SDK clients) need to be initialized once, a semaphore prevents multiple invocations from initializing simultaneously during cold start.
public class DatabaseFunction
{
private static CosmosClient? _cosmosClient;
private static readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1);
[FunctionName("QueryDatabase")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req,
CancellationToken cancellationToken)
{
var client = await GetOrCreateClientAsync(cancellationToken);
// ... use client
return new OkResult();
}
private static async Task<CosmosClient> GetOrCreateClientAsync(
CancellationToken cancellationToken)
{
// Fast path — already initialized, no semaphore needed
if (_cosmosClient != null) return _cosmosClient;
await _initLock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring the lock
// (another invocation may have initialized while we waited)
if (_cosmosClient != null) return _cosmosClient;
_cosmosClient = new CosmosClient(
Environment.GetEnvironmentVariable("CosmosConnectionString"),
new CosmosClientOptions
{
ConnectionMode = ConnectionMode.Direct,
MaxRetryAttemptsOnRateLimitedRequests = 3
});
return _cosmosClient;
}
finally
{
_initLock.Release();
}
}
}
This is the classic double-checked locking pattern — safe with SemaphoreSlim because the semaphore provides the memory barrier needed for visibility.
Scenario 3: Rate-Limiting Outbound Calls
A third-party API allows 10 requests per second. Use a semaphore combined with a sliding delay to enforce this:
public class RateLimitedFunction
{
// Max 10 concurrent outbound calls
private static readonly SemaphoreSlim _rateLimiter = new SemaphoreSlim(10, 10);
// Track when slots were last used to enforce per-second rate
private static readonly System.Collections.Concurrent.ConcurrentQueue<DateTime>
_requestTimestamps = new();
[FunctionName("CallRateLimitedApi")]
public async Task Run(
[QueueTrigger("api-requests")] string requestPayload,
CancellationToken cancellationToken)
{
await _rateLimiter.WaitAsync(cancellationToken);
try
{
// Enforce 1-second spacing between requests if needed
await EnforceRateWindowAsync(cancellationToken);
_requestTimestamps.Enqueue(DateTime.UtcNow);
await CallThirdPartyApiAsync(requestPayload, cancellationToken);
}
finally
{
_rateLimiter.Release();
}
}
private static async Task EnforceRateWindowAsync(CancellationToken cancellationToken)
{
// Remove timestamps older than 1 second
var cutoff = DateTime.UtcNow.AddSeconds(-1);
while (_requestTimestamps.TryPeek(out var oldest) && oldest < cutoff)
_requestTimestamps.TryDequeue(out _);
// If we've already made 10 requests in the last second, wait
if (_requestTimestamps.Count >= 10)
{
if (_requestTimestamps.TryPeek(out var earliest))
{
var waitMs = (int)(1000 - (DateTime.UtcNow - earliest).TotalMilliseconds);
if (waitMs > 0)
await Task.Delay(waitMs, cancellationToken);
}
}
}
}
Scenario 4: Parallel Fan-Out With Bounded Concurrency
When a function fans out to process multiple items in parallel (e.g., Durable Fan-Out, or Task.WhenAll), a semaphore controls how many child tasks run at once:
[FunctionName("ProcessBatch")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
CancellationToken cancellationToken)
{
var items = await JsonSerializer.DeserializeAsync<List<string>>(
req.Body, cancellationToken: cancellationToken);
if (items == null || items.Count == 0)
return new BadRequestResult();
// Allow max 5 items to be processed in parallel
var semaphore = new SemaphoreSlim(5, 5);
var results = new System.Collections.Concurrent.ConcurrentBag<string>();
var tasks = items.Select(async item =>
{
await semaphore.WaitAsync(cancellationToken);
try
{
var result = await ProcessItemAsync(item, cancellationToken);
results.Add(result);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
return new OkObjectResult(results.ToList());
}
Note: In this scenario the semaphore is local to the function invocation (not static) — that is intentional and correct, because you are controlling fan-out within a single invocation, not across invocations.
Scenario 5: Durable Functions Activity Throttling
In Durable Functions, multiple activity functions can run in parallel. Use a semaphore in the orchestrator to throttle activity invocations:
[FunctionName("OrchestratorFunction")]
public async Task RunOrchestrator(
[OrchestrationTrigger] IDurableOrchestrationContext context)
{
var items = context.GetInput<List<string>>();
// Process in batches of 5 using a semaphore-style chunking approach
// Note: SemaphoreSlim is not replay-safe in orchestrators — use chunking instead
var batches = items
.Select((item, index) => new { item, index })
.GroupBy(x => x.index / 5)
.Select(g => g.Select(x => x.item).ToList());
foreach (var batch in batches)
{
var batchTasks = batch
.Select(item => context.CallActivityAsync<string>("ProcessActivity", item));
await Task.WhenAll(batchTasks);
}
}
Critical: Never use
SemaphoreSlimdirectly inside an orchestrator function — orchestrators replay andWaitAsyncis not deterministic across replays. Use the chunking pattern shown above, or usecontext.CreateTimerfor delays.
Scenario 6: Protecting a Shared In-Memory Cache
public class CachedDataFunction
{
private static Dictionary<string, CacheEntry>? _cache;
private static readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1, 1);
[FunctionName("GetCachedData")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req,
CancellationToken cancellationToken)
{
var key = req.Query["key"].ToString();
// Read — check cache without lock first (read is safe if we only mutate carefully)
if (_cache != null && _cache.TryGetValue(key, out var entry) && !entry.IsExpired)
{
return new OkObjectResult(entry.Value);
}
// Write — acquire lock for cache refresh
await _cacheLock.WaitAsync(cancellationToken);
try
{
// Double-check — another invocation may have refreshed while we waited
if (_cache != null && _cache.TryGetValue(key, out entry) && !entry.IsExpired)
{
return new OkObjectResult(entry.Value);
}
// Fetch fresh data
var data = await FetchDataFromSourceAsync(key, cancellationToken);
_cache ??= new Dictionary<string, CacheEntry>();
_cache[key] = new CacheEntry(data, DateTime.UtcNow.AddMinutes(5));
return new OkObjectResult(data);
}
finally
{
_cacheLock.Release();
}
}
private record CacheEntry(object Value, DateTime ExpiresAt)
{
public bool IsExpired => DateTime.UtcNow > ExpiresAt;
}
}
Configuring the Semaphore Count
The right initialCount depends on what you are protecting:
For Connection Pools
Match the semaphore count to the connection pool size minus a safety buffer:
// SQL connection pool default is 100 — leave headroom for other operations
private static readonly SemaphoreSlim _dbSemaphore = new SemaphoreSlim(80, 80);
// CosmosDB default max connections is 50
private static readonly SemaphoreSlim _cosmosSemaphore = new SemaphoreSlim(40, 40);
From Configuration (Recommended for Production)
Never hardcode semaphore counts — make them configurable so you can tune without redeployment:
// local.settings.json / Application Settings
{
"MaxConcurrentDbOperations": "10",
"MaxConcurrentApiCalls": "5"
}
// Program.cs — isolated worker model
services.AddSingleton<SemaphoreSlim>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var maxConcurrent = config.GetValue<int>("MaxConcurrentDbOperations", 10);
return new SemaphoreSlim(maxConcurrent, maxConcurrent);
});
Sizing Guide
| Resource | Recommended SemaphoreSlim Count | Reason |
|---|---|---|
| Mutual exclusion (file write) | 1 | Only one writer at a time |
| SQL Server connection pool | 70–80% of pool size | Leaves headroom |
| CosmosDB RU throttling | Tune based on RU limit and operation cost | Start at 5, increase |
| External API (strict rate limit) | Match the API's concurrent request limit | E.g., Stripe allows 100/sec |
| In-memory cache write | 1 | Prevent concurrent mutations |
| Azure Blob Storage | 10–20 | Blob SDK handles retries anyway |
| Fan-out parallel tasks | 5–20 | Balance throughput vs resource use |
Observability: Monitoring Semaphore Health
Logging Semaphore State
Always log when a semaphore is acquired, released, and especially when it times out — this is how you know if your count is too low:
public class ObservableSemaphore
{
private readonly SemaphoreSlim _semaphore;
private readonly ILogger _logger;
private readonly string _name;
private long _totalWaits;
private long _totalTimeouts;
public ObservableSemaphore(string name, int initialCount, ILogger logger)
{
_semaphore = new SemaphoreSlim(initialCount, initialCount);
_name = name;
_logger = logger;
}
public async Task<bool> WaitAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _totalWaits);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
_logger.LogDebug("Semaphore [{Name}] wait started. Available slots: {Count}",
_name, _semaphore.CurrentCount);
bool acquired = await _semaphore.WaitAsync(timeout, cancellationToken);
stopwatch.Stop();
if (!acquired)
{
Interlocked.Increment(ref _totalTimeouts);
_logger.LogWarning(
"Semaphore [{Name}] TIMEOUT after {Ms}ms. " +
"TotalWaits={Waits}, TotalTimeouts={Timeouts}",
_name, stopwatch.ElapsedMilliseconds,
_totalWaits, _totalTimeouts);
}
else
{
_logger.LogDebug(
"Semaphore [{Name}] acquired in {Ms}ms. Available slots: {Count}",
_name, stopwatch.ElapsedMilliseconds, _semaphore.CurrentCount);
}
return acquired;
}
public void Release()
{
_semaphore.Release();
_logger.LogDebug("Semaphore [{Name}] released. Available slots: {Count}",
_name, _semaphore.CurrentCount);
}
public int CurrentCount => _semaphore.CurrentCount;
public long TotalTimeouts => _totalTimeouts;
}
Application Insights Custom Metrics
Track semaphore wait time and contention as custom metrics so you can alert and build dashboards:
public class InstrumentedSemaphore
{
private readonly SemaphoreSlim _semaphore;
private readonly TelemetryClient _telemetry;
private readonly string _name;
public InstrumentedSemaphore(string name, int count, TelemetryClient telemetry)
{
_semaphore = new SemaphoreSlim(count, count);
_telemetry = telemetry;
_name = name;
}
public async Task<IDisposable> AcquireAsync(
TimeSpan timeout, CancellationToken cancellationToken)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
bool acquired = await _semaphore.WaitAsync(timeout, cancellationToken);
sw.Stop();
_telemetry.TrackMetric($"Semaphore.{_name}.WaitMs", sw.ElapsedMilliseconds);
_telemetry.TrackMetric($"Semaphore.{_name}.AvailableSlots", _semaphore.CurrentCount);
if (!acquired)
{
_telemetry.TrackMetric($"Semaphore.{_name}.Timeout", 1);
throw new SemaphoreFullException(
$"Semaphore [{_name}] timeout after {timeout.TotalSeconds}s");
}
_telemetry.TrackMetric($"Semaphore.{_name}.Acquired", 1);
// Return a disposable so callers can use "using" syntax
return new SemaphoreReleaser(_semaphore, _telemetry, _name);
}
private class SemaphoreReleaser : IDisposable
{
private readonly SemaphoreSlim _semaphore;
private readonly TelemetryClient _telemetry;
private readonly string _name;
private bool _disposed;
public SemaphoreReleaser(SemaphoreSlim semaphore,
TelemetryClient telemetry, string name)
{
_semaphore = semaphore;
_telemetry = telemetry;
_name = name;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_semaphore.Release();
_telemetry.TrackMetric($"Semaphore.{_name}.Released", 1);
}
}
}
Usage with the using pattern — clean and exception-safe:
[FunctionName("ProcessRequest")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
CancellationToken cancellationToken)
{
using var _ = await _instrumentedSemaphore.AcquireAsync(
TimeSpan.FromSeconds(10), cancellationToken);
// Semaphore is held here
var result = await DoWorkAsync(req, cancellationToken);
return new OkObjectResult(result);
// Semaphore released automatically when "using" block exits
}
Log Analytics Query (KQL)
Query Application Insights to monitor semaphore health in Azure Log Analytics:
// Semaphore timeout rate over time
customMetrics
| where name startswith "Semaphore."
| where name endswith ".Timeout"
| summarize TimeoutCount=sum(value) by name, bin(timestamp, 5m)
| render timechart
// Average wait time per semaphore
customMetrics
| where name endswith ".WaitMs"
| summarize AvgWaitMs=avg(value), P95WaitMs=percentile(value, 95)
by name, bin(timestamp, 1h)
| order by P95WaitMs desc
// Available slots over time — detect sustained saturation
customMetrics
| where name endswith ".AvailableSlots"
| summarize AvgAvailableSlots=avg(value) by name, bin(timestamp, 1m)
| render timechart
Drawbacks and Limitations
Understanding where semaphores fall short is just as important as knowing how to use them.
1. Instance-Scoped Only — Not Cross-Instance
This is the most critical limitation. SemaphoreSlim only controls concurrency within a single Azure Functions host instance. When Azure scales out to multiple instances, each has its own semaphore — completely independent.
Instance 1: SemaphoreSlim(3,3) ← controls 3 concurrent ops on this instance
Instance 2: SemaphoreSlim(3,3) ← controls 3 concurrent ops on this instance
Instance 3: SemaphoreSlim(3,3) ← controls 3 concurrent ops on this instance
│
▼
Total possible concurrent ops = 3 × N instances
(N is unknown and changes dynamically with scale-out)
If you need cross-instance concurrency control, you need a distributed lock:
- Azure Blob Storage leases — simple, cheap, built-in
- Azure Redis Cache (StackExchange.Redis with
LockAsync) — low latency - Azure Cosmos DB optimistic concurrency — for data-centric scenarios
- Durable Functions locks (
context.LockAsync) — for orchestration scenarios
// Cross-instance mutex using Azure Blob lease
public class DistributedLockService
{
private readonly BlobContainerClient _container;
public async Task<IDisposable?> TryAcquireLockAsync(
string lockName, TimeSpan leaseDuration, CancellationToken cancellationToken)
{
var blobClient = _container.GetBlobClient($"locks/{lockName}");
// Create the blob if it doesn't exist
if (!await blobClient.ExistsAsync(cancellationToken))
{
await blobClient.UploadAsync(
BinaryData.FromString("lock"), overwrite: false, cancellationToken);
}
try
{
var leaseClient = blobClient.GetBlobLeaseClient();
var lease = await leaseClient.AcquireAsync(leaseDuration, cancellationToken: cancellationToken);
return new BlobLease(leaseClient, lease.Value.LeaseId);
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Lock already held by another instance
return null;
}
}
}
2. Semaphore Not Released on Unhandled Exceptions
If you forget the finally block, an exception will leave the semaphore unreleased — permanently reducing available slots until the host restarts.
// ❌ DANGEROUS — exception leaves semaphore permanently acquired
await _semaphore.WaitAsync();
await DoWork(); // throws — semaphore never released
// ✅ CORRECT — always release in finally
await _semaphore.WaitAsync();
try
{
await DoWork();
}
finally
{
_semaphore.Release(); // Always executes
}
3. Priority Starvation
SemaphoreSlim is FIFO-ish but does not guarantee strict ordering. Under sustained high load, some waiters may wait significantly longer than others. There is no built-in priority mechanism.
4. Potential Deadlock With Nested Semaphores
If your code acquires multiple semaphores in different orders across different code paths, you risk deadlock:
// ❌ Deadlock risk — Invocation A holds _semaphoreA, waits for _semaphoreB
// Invocation B holds _semaphoreB, waits for _semaphoreA
await _semaphoreA.WaitAsync();
try
{
await _semaphoreB.WaitAsync(); // Deadlock if another path reverses the order
}
finally { _semaphoreA.Release(); }
// ✅ Always acquire semaphores in the same order across all code paths
// Or use a single semaphore to govern the entire operation
5. Does Not Survive Host Restart
If the Azure Functions host recycles (scale-in, deployment, crash), the semaphore is reset to its initial count. Any in-flight work that held the semaphore is abandoned. You need idempotent processing logic to handle this.
6. Memory and Handle Overhead at Scale
Each SemaphoreSlim is a managed object. Creating thousands of them (e.g., one per user session) wastes memory. Use a fixed pool of semaphores keyed by resource, not per-request objects.
Drawbacks Summary Table
| Limitation | Impact | Mitigation |
|---|---|---|
| Instance-scoped only | High — cross-instance races not controlled | Use distributed lock (Blob lease, Redis) |
| Unreleased on exception | High — slots lost permanently per host lifetime | Always use try/finally |
| No cross-process scope | High — same as instance-scoped | Distributed lock |
| Priority starvation under load | Medium — some waiters may wait much longer | Bounded timeout + 503 response |
| Deadlock with nested semaphores | Medium — can hang function host | Consistent acquisition order; single semaphore |
| Lost on host restart | Medium — in-flight work abandoned | Idempotent processing, queue visibility timeouts |
| No fairness guarantee | Low — FIFO is approximate | Acceptable for most workloads |
| Memory overhead if overused | Low | One semaphore per resource, not per request |
Common Mistakes and How to Fix Them
Mistake 1: Instance Scope — Declaring Inside the Method
Already covered in setup, but the most common error deserves emphasis:
// ❌ New semaphore every invocation — does nothing
[FunctionName("MyFunc")]
public async Task Run(...)
{
var sem = new SemaphoreSlim(1, 1); // Useless
await sem.WaitAsync();
...
}
// ✅ Static or injected singleton
private static readonly SemaphoreSlim _sem = new SemaphoreSlim(1, 1);
Mistake 2: Missing finally Block
// ❌ Exception leaks the semaphore
await _semaphore.WaitAsync();
var result = await DoWork(); // throws — semaphore never released
_semaphore.Release(); // never reached
// ✅
await _semaphore.WaitAsync();
try { var result = await DoWork(); }
finally { _semaphore.Release(); }
Mistake 3: Calling Release() Without Acquiring
Calling Release() without a matching WaitAsync() increments the count beyond maxCount. With SemaphoreSlim(1, 1) this throws SemaphoreFullException. With SemaphoreSlim(n, n) where the current count is already n, same result.
// ❌ Release without acquire — will throw SemaphoreFullException
_semaphore.Release();
_semaphore.Release();
// ✅ Only release once per successful WaitAsync
bool acquired = await _semaphore.WaitAsync(timeout);
if (acquired)
{
try { await DoWork(); }
finally { _semaphore.Release(); }
}
Mistake 4: Using lock Instead of SemaphoreSlim in Async Code
// ❌ Never use lock in async context — blocks the thread pool thread
lock (_lockObject)
{
await DoAsyncWork(); // This won't even compile — can't await inside lock
}
// ✅ Use SemaphoreSlim for async mutual exclusion
await _semaphore.WaitAsync();
try { await DoAsyncWork(); }
finally { _semaphore.Release(); }
Mistake 5: Assuming SemaphoreSlim Controls Cross-Instance Concurrency
// ❌ This only works within ONE function instance
// If Azure scales to 10 instances, you get 10 × 1 = 10 concurrent operations
private static readonly SemaphoreSlim _sem = new SemaphoreSlim(1, 1);
// ✅ For cross-instance: use distributed lock
// For queue triggers: set maxConcurrentCalls=1 in host.json + single instance
host.json Settings That Work Alongside Semaphores
Semaphores work at the code level. These host.json settings control concurrency at the runtime level — use both together for complete control:
{
"version": "2.0",
"extensions": {
"serviceBus": {
"messageHandlerOptions": {
"maxConcurrentCalls": 5,
"autoComplete": false
}
},
"queues": {
"batchSize": 4,
"maxDequeueCount": 5,
"newBatchThreshold": 2
},
"eventHubs": {
"maxEventBatchSize": 10,
"partitionCount": 4
}
},
"functionTimeout": "00:05:00"
}
The relationship between host.json and semaphores:
host.json setting | What it controls | Semaphore controls |
|---|---|---|
maxConcurrentCalls (Service Bus) | How many messages pulled from queue simultaneously | Concurrency within those messages |
batchSize (Queue Storage) | Messages dequeued per poll | Still need semaphore for shared resource access |
functionTimeout | Maximum function run time | Semaphore timeout should be less than this |
| Scale-out instance count | Number of host instances | SemaphoreSlim has no effect across instances |
Best Practices Summary
Scope — always declare SemaphoreSlim as static readonly or as a DI singleton. Never inside the function method.
Always use try/finally — the semaphore must be released even if the work throws. No exceptions.
Set a timeout — never call WaitAsync() without a timeout in a function. A stuck resource should return 503, not hang the invocation forever.
Pass CancellationToken — link function cancellation to semaphore wait so shutdown is clean.
Log timeouts as warnings — timeout is a signal your semaphore count is too low or your work is too slow. Make it visible.
Make the count configurable — set it in Application Settings, not hardcoded. You will need to tune it.
Know the cross-instance limitation — SemaphoreSlim is not a distributed lock. For cross-instance control, use Blob leases, Redis, or Durable Functions.
Never use lock in async code — only SemaphoreSlim supports await.
Do not use SemaphoreSlim inside Durable orchestrators — not replay-safe. Use batching or context.LockAsync instead.
One semaphore per resource — create a named semaphore for each distinct resource you are protecting (database, API, file), not a single global one for everything.