← Back to ArticlesFunctions

Azure Functions — Semaphore-Based Concurrency Control

Using SemaphoreSlim to throttle concurrent operations in Azure Functions, protecting downstream services like Service Bus and Blob Storage from overload.

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:

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

FeatureSemaphoreSlimSemaphore
Async support (WaitAsync)✅ Yes❌ No
Cross-process support❌ No✅ Yes
PerformanceFast (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 SemaphoreSlim directly inside an orchestrator function — orchestrators replay and WaitAsync is not deterministic across replays. Use the chunking pattern shown above, or use context.CreateTimer for 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

ResourceRecommended SemaphoreSlim CountReason
Mutual exclusion (file write)1Only one writer at a time
SQL Server connection pool70–80% of pool sizeLeaves headroom
CosmosDB RU throttlingTune based on RU limit and operation costStart at 5, increase
External API (strict rate limit)Match the API's concurrent request limitE.g., Stripe allows 100/sec
In-memory cache write1Prevent concurrent mutations
Azure Blob Storage10–20Blob SDK handles retries anyway
Fan-out parallel tasks5–20Balance 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:

// 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

LimitationImpactMitigation
Instance-scoped onlyHigh — cross-instance races not controlledUse distributed lock (Blob lease, Redis)
Unreleased on exceptionHigh — slots lost permanently per host lifetimeAlways use try/finally
No cross-process scopeHigh — same as instance-scopedDistributed lock
Priority starvation under loadMedium — some waiters may wait much longerBounded timeout + 503 response
Deadlock with nested semaphoresMedium — can hang function hostConsistent acquisition order; single semaphore
Lost on host restartMedium — in-flight work abandonedIdempotent processing, queue visibility timeouts
No fairness guaranteeLow — FIFO is approximateAcceptable for most workloads
Memory overhead if overusedLowOne 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 settingWhat it controlsSemaphore controls
maxConcurrentCalls (Service Bus)How many messages pulled from queue simultaneouslyConcurrency within those messages
batchSize (Queue Storage)Messages dequeued per pollStill need semaphore for shared resource access
functionTimeoutMaximum function run timeSemaphore timeout should be less than this
Scale-out instance countNumber of host instancesSemaphoreSlim 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 limitationSemaphoreSlim 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.